Skip to content

Commit d90720f

Browse files
committed
feat(editing): wire editor tracking into the editing paths and the UI
The core helpers added in #1897 had no caller: `editorTracking` could only be set by hand-editing a project, no UI offered it, and nothing stamped a feature. This connects them. Configuration lives in a new Editor Tracking section of the layer style panel (enable, the four column names, and the name to record edits under, which is shared with the Comments panel via `geolibre_author_name`). Identity resolves collaboration session name -> local name -> `local-user`; the default is deliberately untranslated, since it is written into the user's data. Stamping happens on every path that authors features: - the geometry editor, per session save. `reconcileEditedFeatures` now classifies each feature — drawn during the session is a create, geometry changed against a baseline captured at load is an update, untouched is left alone. Without the baseline, opening a session and saving would restamp the whole layer, because Geoman re-serializes every feature it loaded. - the Sketches sync, which also carries the tracking columns forward from the store: the editor keeps its own copy of the features and never sees them, so each sync would otherwise wipe them and re-create from scratch. - attribute table saves, on exactly the drafted rows (not the export preview, which runs the same transform). - the Field Calculator and Field Collection capture. The four columns are maintained by the app, so the attribute table lists them but refuses to edit, rename or delete them, and the Field Calculator refuses them as a target — a calculated value there would be overwritten by the next edit's stamp. They are listed as soon as tracking is on, before any feature carries one. Verified in a browser across all four paths in both themes: only the features actually created or changed are stamped, creation columns survive later edits, and features that predate tracking stay empty rather than being credited to whoever opened the layer.
1 parent ca1394f commit d90720f

34 files changed

Lines changed: 1358 additions & 23 deletions

apps/geolibre-desktop/src/components/layout/FieldCollectionDialog.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { useTranslation } from "react-i18next";
33
import * as maplibregl from "maplibre-gl";
44
import type { MapController } from "@geolibre/map";
55
import {
6+
currentEditorIdentity,
67
getAttributeFormField,
78
isAttributeFormFieldVisible,
9+
stampFeatureEditorTracking,
810
useAppStore,
911
validateAttributeFormValues,
1012
type AttributeFormConfig,
@@ -607,7 +609,15 @@ export function FieldCollectionDialog({
607609
return;
608610
}
609611
const fc = current.geojson ?? emptyFeatureCollection();
610-
updateLayer(activeLayer.id, { geojson: appendFeature(fc, feature) });
612+
// Read the tracking config off `current`, not the render-time layer: the
613+
// form can sit open across a configuration change.
614+
const tracked = current.editorTracking?.enabled
615+
? stampFeatureEditorTracking(feature, "create", {
616+
config: current.editorTracking,
617+
userIdentity: currentEditorIdentity(),
618+
})
619+
: feature;
620+
updateLayer(activeLayer.id, { geojson: appendFeature(fc, tracked) });
611621

612622
savedCountRef.current += 1;
613623
setNotice(

apps/geolibre-desktop/src/components/panels/AttributeTable.tsx

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import { useTranslation } from "react-i18next";
22
import {
33
attributeLinkUrl,
44
coerceAttributeFormValue,
5+
currentEditorIdentity,
6+
editorTrackingFieldNames,
7+
ensureEditorTrackingFields,
58
isDuckDBQueryLayer,
9+
stampFeaturePropertiesEditorTracking,
610
useAppStore,
711
validateAttributeFormValues,
812
excludeHiddenFieldsFromGeojson,
913
type AttributeFormConfig,
1014
type AttributeFormFieldConfig,
1115
type AttributeFormFieldError,
16+
type EditorTrackingStampOptions,
1217
} from "@geolibre/core";
1318
import {
1419
getDuckDBLayerRows,
@@ -238,6 +243,7 @@ function applyDraftsToFeatures(
238243
features: Feature[],
239244
drafts: AttributeDrafts,
240245
formFields?: Map<string, AttributeFormFieldConfig>,
246+
tracking?: EditorTrackingStampOptions,
241247
): Feature[] {
242248
// Derived from the whole collection, so an edit to an empty cell adopts the
243249
// column's type rather than the cell's (absent) one.
@@ -247,7 +253,7 @@ function applyDraftsToFeatures(
247253
const rowDrafts = drafts[featureId];
248254
if (!rowDrafts) return feature;
249255

250-
const properties = { ...(feature.properties ?? {}) };
256+
let properties: Record<string, unknown> = { ...(feature.properties ?? {}) };
251257
for (const [column, draft] of Object.entries(rowDrafts)) {
252258
const previousValue = feature.properties?.[column];
253259
// Skip drafts that are invalid JSON for an object-typed cell so we never
@@ -262,6 +268,13 @@ function applyDraftsToFeatures(
262268
: parseAttributeDraft(draft, previousValue, columnTypes?.get(column));
263269
}
264270

271+
// Only the rows that carried a draft reach here, so this stamps exactly the
272+
// features the user edited. Passed only on save — the export preview runs
273+
// the same transform and must not record an edit that never happened.
274+
if (tracking) {
275+
properties = stampFeaturePropertiesEditorTracking(properties, "update", tracking);
276+
}
277+
265278
return { ...feature, properties };
266279
});
267280
}
@@ -461,6 +474,19 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
461474
() => new Set([...joinDerivedColumns, ...virtualFieldColumns]),
462475
[joinDerivedColumns, virtualFieldColumns],
463476
);
477+
// Editor tracking columns are maintained by the app on every create/update,
478+
// so a hand-typed value would be overwritten by the next edit and a rename
479+
// would detach the column from the configuration that names it. They are
480+
// shown, but not edited here — the Editor Tracking section owns them.
481+
const trackingColumns = useMemo(
482+
() => new Set(editorTrackingFieldNames(layer?.editorTracking) ?? []),
483+
[layer?.editorTracking],
484+
);
485+
// Every column the user may not type into or restructure from this table.
486+
const readOnlyColumns = useMemo(
487+
() => new Set([...derivedColumns, ...trackingColumns]),
488+
[derivedColumns, trackingColumns],
489+
);
464490
const features = useMemo(() => layer?.geojson?.features ?? [], [layer?.geojson]);
465491
const isDuckDBLayer = isDuckDBQueryLayer(layer);
466492
const duckdbRows = layer && isDuckDBLayer ? getDuckDBLayerRows(layer.id) : [];
@@ -745,7 +771,10 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
745771
if (!RESERVED_IMAGE_KEYS.has(k)) propKeys.add(k);
746772
}
747773
}
748-
const discoveredColumns = Array.from(propKeys);
774+
// Tracking columns are listed even before any feature carries one, so a layer
775+
// that just turned tracking on shows what it is about to maintain (and the
776+
// Field Calculator's "new field" name check sees them as taken).
777+
const discoveredColumns = ensureEditorTrackingFields(Array.from(propKeys), layer?.editorTracking);
749778
const columnSettings = getColumnSettings(layer);
750779
// Columns rendered in the table, honoring saved order and hidden state.
751780
const columns = visibleColumns(discoveredColumns, columnSettings);
@@ -937,7 +966,19 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
937966

938967
const geojson = {
939968
...layer.geojson,
940-
features: applyDraftsToFeatures(layer.geojson.features, drafts, formFields),
969+
features: applyDraftsToFeatures(
970+
layer.geojson.features,
971+
drafts,
972+
formFields,
973+
layer.editorTracking?.enabled
974+
? {
975+
config: layer.editorTracking,
976+
userIdentity: currentEditorIdentity(),
977+
// One timestamp for the save, so rows edited together agree.
978+
timestamp: new Date().toISOString(),
979+
}
980+
: undefined,
981+
),
941982
};
942983

943984
updateLayer(layer.id, { geojson });
@@ -1113,7 +1154,7 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
11131154
// The Field Calculator must not target a derived column (joined or virtual)
11141155
// either: the calculated value would be overwritten by the re-derivation in
11151156
// the same store update.
1116-
const calculatorTargetColumns = discoveredColumns.filter((col) => !derivedColumns.has(col));
1157+
const calculatorTargetColumns = discoveredColumns.filter((col) => !readOnlyColumns.has(col));
11171158

11181159
const openCalculator = () => {
11191160
const hasColumns = calculatorTargetColumns.length > 0;
@@ -1407,7 +1448,7 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
14071448
</DropdownMenuTrigger>
14081449
<DropdownMenuContent align="end">
14091450
<DropdownMenuItem
1410-
disabled={derivedColumns.has(col)}
1451+
disabled={readOnlyColumns.has(col)}
14111452
onSelect={() => beginColumnRename(col)}
14121453
>
14131454
<Pencil className="me-2 h-3.5 w-3.5" />
@@ -1440,7 +1481,7 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
14401481
<DropdownMenuSeparator />
14411482
<DropdownMenuItem
14421483
className="text-destructive focus:text-destructive"
1443-
disabled={derivedColumns.has(col)}
1484+
disabled={readOnlyColumns.has(col)}
14441485
onSelect={() => setColumnPendingDelete(col)}
14451486
>
14461487
<Trash2 className="me-2 h-3.5 w-3.5" />
@@ -1892,7 +1933,7 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
18921933
: "h-7 min-w-0 px-2 text-xs";
18931934
const config = formFields.get(col);
18941935
const current = draft ?? formatAttributeValue(value);
1895-
const isEditableCell = isEditing && !derivedColumns.has(col);
1936+
const isEditableCell = isEditing && !readOnlyColumns.has(col);
18961937
const linkUrl = isEditableCell ? null : attributeLinkUrl(value);
18971938
const invalidTitle = invalid
18981939
? formError
@@ -1915,7 +1956,9 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
19151956
? t("attributeTable.virtualColumnTitle")
19161957
: joinDerivedColumns.has(col)
19171958
? t("attributeTable.joinedColumnTitle")
1918-
: undefined
1959+
: trackingColumns.has(col)
1960+
? t("attributeTable.trackingColumnTitle")
1961+
: undefined
19191962
}
19201963
>
19211964
{isEditableCell ? (
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {
2+
DEFAULT_EDITOR_IDENTITY,
3+
DEFAULT_EDITOR_TRACKING_CONFIG,
4+
readStoredAuthorName,
5+
setStoredAuthorName,
6+
useAppStore,
7+
type EditorTrackingConfig,
8+
type GeoLibreLayer,
9+
} from "@geolibre/core";
10+
import { Input, Label } from "@geolibre/ui";
11+
import { useMemo, useState } from "react";
12+
import { useTranslation } from "react-i18next";
13+
14+
interface EditorTrackingSectionProps {
15+
layer: GeoLibreLayer;
16+
}
17+
18+
/** The four configurable column names, in the order they are shown. */
19+
const FIELD_KEYS = ["createdByField", "createdAtField", "editedByField", "editedAtField"] as const;
20+
21+
type FieldKey = (typeof FIELD_KEYS)[number];
22+
23+
/**
24+
* The Editor Tracking section of the layer style panel (ArcGIS Layer
25+
* Properties → Editor Tracking): maintain who created each feature and when,
26+
* and who last changed it and when, across every editing path — the geometry
27+
* editor, attribute edits, the Field Calculator, and Field Collection capture.
28+
*
29+
* The four columns are written by the app, so the attribute table shows them
30+
* but refuses to edit, rename or delete them; renaming happens here, where the
31+
* configuration that gives them meaning lives.
32+
*/
33+
export function EditorTrackingSection({ layer }: EditorTrackingSectionProps) {
34+
const { t } = useTranslation();
35+
const setLayerEditorTracking = useAppStore((s) => s.setLayerEditorTracking);
36+
const collabActive = useAppStore((s) => s.collaboration.isActive);
37+
const collabName = useAppStore((s) => s.collaboration.selfName);
38+
39+
const config = layer.editorTracking;
40+
const enabled = config?.enabled === true;
41+
// A live session names its participants, and that name wins over the local
42+
// one (see pickEditorIdentity), so the field is shown but not editable.
43+
const sessionIdentity = collabActive && collabName ? collabName : null;
44+
45+
// Read once per mount: localStorage is not reactive, and this input is the
46+
// only thing in the panel that writes it.
47+
const [authorName, setAuthorName] = useState(() => readStoredAuthorName());
48+
49+
// Field names as typed, so a half-cleared name can be retyped instead of
50+
// snapping back to its stored value on every keystroke.
51+
const [drafts, setDrafts] = useState<Record<FieldKey, string> | null>(null);
52+
const names = useMemo(() => {
53+
if (drafts) return drafts;
54+
return Object.fromEntries(
55+
FIELD_KEYS.map((key) => [key, config?.[key] ?? DEFAULT_EDITOR_TRACKING_CONFIG[key]]),
56+
) as Record<FieldKey, string>;
57+
}, [drafts, config]);
58+
59+
// The same rule `resolveEditorTrackingConfig` enforces, surfaced before the
60+
// configuration is stored rather than as a throw at stamping time.
61+
const invalid = useMemo(() => {
62+
const values = FIELD_KEYS.map((key) => names[key].trim());
63+
if (values.some((value) => value === "")) return "blankName" as const;
64+
if (new Set(values).size !== values.length) return "duplicateName" as const;
65+
return null;
66+
}, [names]);
67+
68+
const write = (patch: Partial<EditorTrackingConfig>) => {
69+
const next: EditorTrackingConfig = {
70+
enabled,
71+
...(Object.fromEntries(FIELD_KEYS.map((key) => [key, names[key].trim()])) as Record<
72+
FieldKey,
73+
string
74+
>),
75+
...patch,
76+
};
77+
// Leave the defaults implicit: a layer that renames nothing stores just
78+
// `{ enabled: true }`, which keeps the project file and its diffs clean.
79+
// A blank name is dropped the same way — the checkbox refuses to turn
80+
// tracking ON while one is blank, but turning it OFF stays available, and
81+
// that must not persist a name nothing can use.
82+
for (const key of FIELD_KEYS) {
83+
if (!next[key] || next[key] === DEFAULT_EDITOR_TRACKING_CONFIG[key]) delete next[key];
84+
}
85+
// Turning tracking off keeps renamed columns configured, so switching it
86+
// back on resumes writing the same columns instead of starting a second
87+
// set beside the data already stamped. With nothing customized there is
88+
// nothing worth persisting, so the layer drops the key entirely.
89+
const customized = FIELD_KEYS.some((key) => next[key] !== undefined);
90+
setLayerEditorTracking(layer.id, next.enabled || customized ? next : undefined);
91+
};
92+
93+
const commitNames = () => {
94+
if (invalid) return;
95+
setDrafts(null);
96+
if (enabled) write({});
97+
};
98+
99+
return (
100+
<div className="space-y-3" data-testid="editor-tracking-section">
101+
<p className="text-sm font-semibold">{t("style.editorTracking.heading")}</p>
102+
<p className="text-xs text-muted-foreground">{t("style.editorTracking.description")}</p>
103+
<label className="flex items-center gap-2 text-xs">
104+
<input
105+
type="checkbox"
106+
checked={enabled}
107+
disabled={!enabled && invalid !== null}
108+
onChange={(event) => write({ enabled: event.target.checked })}
109+
/>
110+
<span>{t("style.editorTracking.enable")}</span>
111+
</label>
112+
113+
{enabled && (
114+
<>
115+
<div className="space-y-1">
116+
<Label htmlFor={`et-identity-${layer.id}`}>{t("style.editorTracking.identity")}</Label>
117+
<Input
118+
id={`et-identity-${layer.id}`}
119+
value={sessionIdentity ?? authorName}
120+
disabled={sessionIdentity !== null}
121+
placeholder={DEFAULT_EDITOR_IDENTITY}
122+
onChange={(event) => setAuthorName(event.target.value)}
123+
onBlur={() => setStoredAuthorName(authorName)}
124+
/>
125+
<p className="text-xs text-muted-foreground">
126+
{sessionIdentity
127+
? t("style.editorTracking.identityFromSession")
128+
: t("style.editorTracking.identityHint", {
129+
name: authorName.trim() || DEFAULT_EDITOR_IDENTITY,
130+
})}
131+
</p>
132+
</div>
133+
134+
{FIELD_KEYS.map((key) => (
135+
<div key={key} className="space-y-1">
136+
<Label htmlFor={`et-${key}-${layer.id}`}>{t(`style.editorTracking.${key}`)}</Label>
137+
<Input
138+
id={`et-${key}-${layer.id}`}
139+
className="font-mono text-xs"
140+
value={names[key]}
141+
onChange={(event) => setDrafts({ ...names, [key]: event.target.value })}
142+
onBlur={commitNames}
143+
/>
144+
</div>
145+
))}
146+
{invalid && (
147+
<p className="text-xs text-destructive">{t(`style.editorTracking.${invalid}`)}</p>
148+
)}
149+
</>
150+
)}
151+
</div>
152+
);
153+
}

apps/geolibre-desktop/src/components/panels/StylePanel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { type MapController } from "@geolibre/map";
5454
import type { ParseKeys, TFunction } from "i18next";
5555
import { useTranslation } from "react-i18next";
5656
import { AttributeFormSection } from "./AttributeFormSection";
57+
import { EditorTrackingSection } from "./EditorTrackingSection";
5758
import { LayerJoinsSection } from "./LayerJoinsSection";
5859
import { VirtualFieldsSection } from "./VirtualFieldsSection";
5960
import { getNetcdfLayerState, NETCDF_IMAGE_SOURCE_KIND } from "../../lib/netcdf-image-symbology";
@@ -5016,6 +5017,16 @@ export function StylePanel({
50165017
<AttributeFormSection key={`af-${layer.id}`} layer={layer} />
50175018
</>
50185019
) : null}
5020+
{/* Editor tracking stamps the features as they are created and edited,
5021+
so it needs the layer's features in the store as well. Keyed like
5022+
the sections above so a half-typed column name never carries over
5023+
to the next layer. */}
5024+
{layer.geojson ? (
5025+
<>
5026+
<Separator />
5027+
<EditorTrackingSection key={`et-${layer.id}`} layer={layer} />
5028+
</>
5029+
) : null}
50195030
</div>
50205031
</ScrollArea>
50215032
<Separator />

apps/geolibre-desktop/src/i18n/locales/ar.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4650,6 +4650,7 @@
46504650
"deleteField": "حذف الحقل",
46514651
"joinedColumnTitle": "عمود مرتبط مشتق من جدول ربط. عدّل جدول الربط أو تعريف الربط بدلاً من ذلك.",
46524652
"virtualColumnTitle": "حقل افتراضي محسوب من تعبير. عدّل تعريفه في لوحة نمط الطبقة بدلاً من ذلك.",
4653+
"trackingColumnTitle": "حقل تتبّع التحرير، يُدار تلقائيًا. أعد تسميته من إعدادات تتبّع التحرير للطبقة.",
46534654
"deleteFieldConfirm": "سيؤدي هذا إلى إزالة الحقل \"{{field}}\" نهائيًا من كل معلم في \"{{layer}}\". وسيُمسح أي نمط أو تسميات تشير إليه. لا يمكن التراجع عن هذا الإجراء.",
46544655
"addFieldDescription": "أضف حقلًا جديدًا إلى كل معلم في \"{{layer}}\".",
46554656
"fieldName": "اسم الحقل",
@@ -5238,6 +5239,20 @@
52385239
"save": "حفظ",
52395240
"noFields": "لا تحتوي الطبقة على حقول سمات للتكوين.",
52405241
"minMaxInvalid": "يجب أن يكون الحد الأدنى أقل من أو يساوي الحد الأقصى."
5242+
},
5243+
"editorTracking": {
5244+
"heading": "تتبع التحرير",
5245+
"description": "سجّل من أنشأ كل معلم ومن غيّره آخر مرة. تُملأ الحقول أدناه تلقائيًا عند رسم المعالم أو تحريرها أو حسابها أو جمعها.",
5246+
"enable": "تتبّع التعديلات في هذه الطبقة",
5247+
"identity": "اسمك",
5248+
"identityHint": "ستُسجَّل التعديلات باسم ”{{name}}“.",
5249+
"identityFromSession": "مأخوذ من جلسة التعاون التي تشارك فيها.",
5250+
"createdByField": "حقل ”أنشأه“",
5251+
"createdAtField": "حقل ”تاريخ الإنشاء“",
5252+
"editedByField": "حقل ”عدّله“",
5253+
"editedAtField": "حقل ”تاريخ التعديل“",
5254+
"blankName": "لا يمكن ترك أسماء الحقول فارغة.",
5255+
"duplicateName": "يجب أن يكون لكل حقل تتبّع اسم مختلف."
52415256
}
52425257
},
52435258
"vectorExport": {

0 commit comments

Comments
 (0)