@startsimpli/ui 0.4.140 → 0.4.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActivityTimeline.tsx +17 -12
  3. package/src/components/ActorIdentity.tsx +15 -2
  4. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  5. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  6. package/src/components/activity/types.ts +10 -5
  7. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  8. package/src/components/collection/InlineReviewActions.tsx +3 -2
  9. package/src/components/collection/ReviewDrawer.tsx +35 -7
  10. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  11. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  12. package/src/components/collection/data.ts +16 -1
  13. package/src/components/collection/index.ts +2 -1
  14. package/src/components/collection/scoped-client.ts +4 -1
  15. package/src/components/collection/types.ts +19 -1
  16. package/src/components/history/FieldAttribution.tsx +308 -0
  17. package/src/components/history/RecordActivityPanel.tsx +344 -0
  18. package/src/components/history/RecordHistoryPanel.tsx +381 -29
  19. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  20. package/src/components/history/RevisionFields.tsx +78 -6
  21. package/src/components/history/StaleSaveDialog.tsx +28 -7
  22. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  23. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  24. package/src/components/history/__tests__/record-history-panel.test.tsx +157 -9
  25. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  26. package/src/components/history/__tests__/restore.test.ts +209 -0
  27. package/src/components/history/index.ts +72 -1
  28. package/src/components/history/record-history-panel-default-class-names.ts +1 -1
  29. package/src/components/history/restore.ts +295 -0
  30. package/src/components/history/revision-feed.ts +105 -0
  31. package/src/components/history/revision-history.ts +22 -0
  32. package/src/components/history/types.ts +153 -1
@@ -0,0 +1,244 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * RestoreConfirmDialog — "this is what will change", before anything is written
5
+ * (bd startsim-zkyk4).
6
+ *
7
+ * A restore is an ordinary new write that REPLACES whatever the field holds now —
8
+ * often somebody else's text, which is the very case restore exists for. So the
9
+ * dialog never asks "are you sure?" in the abstract: it shows each field that
10
+ * would move, from its current value to the restored one, with the same line
11
+ * diff the history rows use, and it names the two cases where the honest answer
12
+ * is "nothing to restore" or "we cannot say".
13
+ *
14
+ * Pure — no fetching, no react-query — like StaleSaveDialog beside it. The panel
15
+ * computed the preview from the trail it already holds ({@link restorePreview}),
16
+ * and owns the call.
17
+ */
18
+ import { useEffect, useRef, type ReactNode } from 'react';
19
+
20
+ import { DiffViewer } from '../diff';
21
+ import type { RestorePreview, RestoreSide } from './restore';
22
+ import { unifiedTextDiff } from './text-diff';
23
+ import {
24
+ STALE_SAVE_DIALOG_DEFAULTS,
25
+ type StaleSaveDialogClassNames,
26
+ } from './stale-save-dialog-default-class-names';
27
+
28
+ export interface RestoreConfirmDialogProps {
29
+ open: boolean;
30
+ preview: RestorePreview | null;
31
+ /** The field being restored, or undefined for the whole record. */
32
+ fields?: string[];
33
+ recordLabel?: string;
34
+ fieldLabel?: (field: string) => string;
35
+ busy?: boolean;
36
+ /** The server's sentence for a refusal other than 412. */
37
+ error?: string | null;
38
+ onConfirm: () => void;
39
+ onCancel: () => void;
40
+ children?: ReactNode;
41
+ /** Shares the stale-save dialog's chrome, so the two modals read as one family. */
42
+ classNames?: StaleSaveDialogClassNames;
43
+ }
44
+
45
+ function text(side: RestoreSide): string {
46
+ if (!side.has) return '';
47
+ const v = side.value;
48
+ if (typeof v === 'string') return v;
49
+ if (v === null) return 'null';
50
+ try {
51
+ return JSON.stringify(v, null, 2);
52
+ } catch {
53
+ return String(v);
54
+ }
55
+ }
56
+
57
+ function Side({ label, side }: { label: string; side: RestoreSide }) {
58
+ return (
59
+ <div className="min-w-0">
60
+ <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
61
+ {side.has ? (
62
+ <pre className="max-h-32 overflow-y-auto whitespace-pre-wrap break-words rounded border border-border bg-muted/40 p-2 font-mono text-xs">
63
+ {text(side)}
64
+ </pre>
65
+ ) : (
66
+ <p className="rounded border border-dashed border-border p-2 text-xs text-muted-foreground">
67
+ <span aria-hidden="true">—</span> not set
68
+ </p>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export function RestoreConfirmDialog({
75
+ open,
76
+ preview,
77
+ fields,
78
+ recordLabel = 'this record',
79
+ fieldLabel = (f) => f,
80
+ busy = false,
81
+ error,
82
+ onConfirm,
83
+ onCancel,
84
+ children,
85
+ classNames,
86
+ }: RestoreConfirmDialogProps) {
87
+ const css = { ...STALE_SAVE_DIALOG_DEFAULTS, ...classNames };
88
+ const cancelRef = useRef<HTMLButtonElement | null>(null);
89
+ const visible = open && preview !== null;
90
+
91
+ useEffect(() => {
92
+ if (!visible) return;
93
+ const onKey = (event: KeyboardEvent) => {
94
+ if (event.key === 'Escape' && !busy) onCancel();
95
+ };
96
+ document.addEventListener('keydown', onKey);
97
+ return () => document.removeEventListener('keydown', onKey);
98
+ }, [visible, busy, onCancel]);
99
+
100
+ // Focus lands on Cancel — the control that cannot write.
101
+ useEffect(() => {
102
+ if (visible) cancelRef.current?.focus();
103
+ }, [visible]);
104
+
105
+ if (!visible || !preview) return null;
106
+
107
+ const target = `v${preview.target}`;
108
+ const what = fields?.length
109
+ ? fields.map(fieldLabel).join(', ')
110
+ : recordLabel;
111
+ const blocked = preview.unknowable.length > 0;
112
+ const count = preview.changes.length + (preview.name ? 1 : 0);
113
+
114
+ return (
115
+ <div
116
+ className={css.backdrop}
117
+ data-testid="restore-confirm-backdrop"
118
+ onClick={(event) => {
119
+ if (event.target === event.currentTarget && !busy) onCancel();
120
+ }}
121
+ >
122
+ <div
123
+ role="dialog"
124
+ aria-modal="true"
125
+ aria-labelledby="restore-confirm-title"
126
+ className={`${css.dialog} max-w-2xl`}
127
+ data-testid="restore-confirm-dialog"
128
+ >
129
+ <div className={css.header}>
130
+ <h2 id="restore-confirm-title" className={css.title}>
131
+ Restore {what} to how it was at {target}?
132
+ </h2>
133
+ <p className={css.reassurance}>
134
+ {preview.noop
135
+ ? `Nothing would change — ${fields?.length ? 'this value is' : 'the record is'} already as it was at ${target}.`
136
+ : `This saves a new version. Nothing is deleted: the current ${count === 1 ? 'value stays' : 'values stay'} in the history and can be restored again.`}
137
+ </p>
138
+ </div>
139
+
140
+ <div className={`${css.body} max-h-[60vh] overflow-y-auto`}>
141
+ {!preview.complete && (
142
+ <p className={css.notice} data-testid="restore-incomplete">
143
+ Not every change since {target} is loaded here, so this list may be incomplete. The
144
+ server works out the restore from the full history.
145
+ </p>
146
+ )}
147
+
148
+ {preview.changes.map((change) => {
149
+ const diffable =
150
+ change.current.has &&
151
+ change.restored.has &&
152
+ typeof change.current.value === 'string' &&
153
+ typeof change.restored.value === 'string';
154
+ return (
155
+ <section
156
+ key={change.field}
157
+ className="@container space-y-2"
158
+ data-testid={`restore-preview-${change.field}`}
159
+ >
160
+ <h3 className="text-xs font-medium text-foreground">
161
+ <code className="rounded bg-muted px-1">{fieldLabel(change.field)}</code>
162
+ {!change.restored.has && (
163
+ <span className="ms-2 text-muted-foreground">will be cleared</span>
164
+ )}
165
+ </h3>
166
+ <div className="grid gap-3 @sm:grid-cols-2">
167
+ <Side label="Now" side={change.current} />
168
+ <Side label={`After restore (${target})`} side={change.restored} />
169
+ </div>
170
+ {diffable && (
171
+ <DiffViewer
172
+ diff={unifiedTextDiff(
173
+ String(change.current.value),
174
+ String(change.restored.value),
175
+ change.field,
176
+ )}
177
+ emptyLabel="No line-level change"
178
+ className="max-h-56 overflow-auto rounded border border-border"
179
+ />
180
+ )}
181
+ </section>
182
+ );
183
+ })}
184
+
185
+ {preview.name && (
186
+ <section className="@container space-y-2" data-testid="restore-preview-name">
187
+ <h3 className="text-xs font-medium text-foreground">name (title)</h3>
188
+ <div className="grid gap-3 @sm:grid-cols-2">
189
+ <Side label="Now" side={preview.name.current} />
190
+ <Side label={`After restore (${target})`} side={preview.name.restored} />
191
+ </div>
192
+ </section>
193
+ )}
194
+
195
+ {preview.withheld.length > 0 && (
196
+ <p className={css.notice} data-testid="restore-withheld">
197
+ Left as they are: {preview.withheld.map(fieldLabel).join(', ')} — your access does
198
+ not include reading {preview.withheld.length === 1 ? 'that field' : 'those fields'}, so
199
+ a restore does not touch {preview.withheld.length === 1 ? 'it' : 'them'}.
200
+ </p>
201
+ )}
202
+
203
+ {blocked && (
204
+ <p className={css.notice} data-testid="restore-unknowable">
205
+ The history stored only part of {preview.unknowable.map(fieldLabel).join(', ')} after{' '}
206
+ {target}, so its value then is not known exactly and cannot be restored.
207
+ {fields?.length ? '' : ' Restore the other fields one at a time instead.'}
208
+ </p>
209
+ )}
210
+
211
+ {error && (
212
+ <p role="alert" className="text-sm text-foreground" data-testid="restore-error">
213
+ {error}
214
+ </p>
215
+ )}
216
+
217
+ {children}
218
+ </div>
219
+
220
+ <div className={css.footer}>
221
+ <button
222
+ type="button"
223
+ ref={cancelRef}
224
+ className={css.secondaryAction}
225
+ data-testid="restore-cancel"
226
+ disabled={busy}
227
+ onClick={onCancel}
228
+ >
229
+ Cancel
230
+ </button>
231
+ <button
232
+ type="button"
233
+ className={css.primaryAction}
234
+ data-testid="restore-confirm"
235
+ disabled={busy || preview.noop || blocked}
236
+ onClick={onConfirm}
237
+ >
238
+ {busy ? 'Restoring…' : fields?.length ? 'Restore this value' : 'Restore this version'}
239
+ </button>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ );
244
+ }
@@ -26,7 +26,7 @@
26
26
  * reason, rather than showing a deletion that never happened.
27
27
  */
28
28
  import { useState } from 'react';
29
- import { ChevronRight, EyeOff, Scissors } from 'lucide-react';
29
+ import { ChevronRight, EyeOff, History, Scissors, Undo2 } from 'lucide-react';
30
30
 
31
31
  import { cn } from '../../lib/utils';
32
32
  import { DiffViewer } from '../diff';
@@ -53,11 +53,14 @@ function preview(value: unknown): string {
53
53
 
54
54
  const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
55
55
 
56
+ const RESTORE_BUTTON =
57
+ 'inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground transition-colors hover:bg-accent';
58
+
56
59
  /** One side of a change. ABSENCE IS RENDERED AS ABSENCE — an em dash with
57
60
  * screen-reader text — never as an empty box that looks like "" was stored. */
58
61
  function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
59
62
  return (
60
- <div className="min-w-0 flex-1">
63
+ <div className="min-w-0">
61
64
  <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
62
65
  {label}
63
66
  </div>
@@ -106,7 +109,7 @@ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange
106
109
  }
107
110
 
108
111
  return (
109
- <div className="space-y-2">
112
+ <div className="@container space-y-2">
110
113
  {kind === 'truncated' && (
111
114
  <p className="flex items-start gap-2 text-xs text-muted-foreground">
112
115
  <Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
@@ -118,7 +121,13 @@ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange
118
121
  </span>
119
122
  </p>
120
123
  )}
121
- <div className="flex flex-wrap gap-3 sm:flex-nowrap">
124
+ {/* SIZED ON THE CONTAINER, NOT THE VIEWPORT (bd startsim-jkkn7.12). This
125
+ panel is a composer meant to be embedded, and its common home is a
126
+ ~370px review rail on a wide screen — where a viewport `sm:` forced the
127
+ sides next to each other and each got a few dozen pixels. Stacked by
128
+ default; side by side only once the field body itself is 24rem wide.
129
+ A grid, not `flex-1` + wrap: a zero flex-basis never wraps. */}
130
+ <div className="grid gap-3 @sm:grid-cols-2">
122
131
  <Side label="Before" has={change.hasBefore} value={change.before} />
123
132
  <Side label="After" has={change.hasAfter} value={change.after} />
124
133
  </div>
@@ -150,9 +159,26 @@ export interface RevisionFieldsProps {
150
159
  /** Map a wire field name to something a reader recognises. Identity by
151
160
  * default — see `types.ts` on why this module never re-cases a name. */
152
161
  fieldLabel?: (field: string) => string;
162
+ /** Offer "Restore this value" on this field (bd startsim-zkyk4). The panel
163
+ * answers false wherever a restore would change nothing — on the row whose
164
+ * value IS the current one — or would be refused. */
165
+ canRestoreField?: (field: string) => boolean;
166
+ onRestoreField?: (field: string) => void;
167
+ /** Offer "Restore this version" — the whole record as it stood after this
168
+ * sitting. Omitted where it would be a no-op or the trail is partial. */
169
+ onRestoreVersion?: () => void;
170
+ /** "Show only this field's history" — the panel's ?field= narrowing. */
171
+ onNarrowToField?: (field: string) => void;
153
172
  }
154
173
 
155
- export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionFieldsProps) {
174
+ export function RevisionFields({
175
+ sitting,
176
+ fieldLabel = (f) => f,
177
+ canRestoreField,
178
+ onRestoreField,
179
+ onRestoreVersion,
180
+ onNarrowToField,
181
+ }: RevisionFieldsProps) {
156
182
  const [open, setOpen] = useState<string | null>(null);
157
183
  const entries: Array<[string, FoldedFieldChange]> = [
158
184
  ...Object.keys(sitting.changed)
@@ -210,8 +236,40 @@ export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionField
210
236
  <span className="ms-auto shrink-0 text-muted-foreground">{KIND_LABEL[kind]}</span>
211
237
  </button>
212
238
  {isOpen && (
213
- <div className="border-t border-border bg-muted/20 px-3 py-2">
239
+ <div className="space-y-2 border-t border-border bg-muted/20 px-3 py-2">
214
240
  <FieldBody field={field} change={change} />
241
+ {(onNarrowToField ||
242
+ (onRestoreField &&
243
+ field !== 'name (title)' &&
244
+ kind !== 'withheld' &&
245
+ (canRestoreField?.(field) ?? true))) && (
246
+ <div className="flex flex-wrap items-center gap-2">
247
+ {onRestoreField &&
248
+ field !== 'name (title)' &&
249
+ kind !== 'withheld' &&
250
+ (canRestoreField?.(field) ?? true) && (
251
+ <button
252
+ type="button"
253
+ className={RESTORE_BUTTON}
254
+ data-testid={`revision-restore-field-${field}`}
255
+ onClick={() => onRestoreField(field)}
256
+ >
257
+ <Undo2 className="h-3.5 w-3.5" aria-hidden="true" />
258
+ Restore this value
259
+ </button>
260
+ )}
261
+ {onNarrowToField && field !== 'name (title)' && (
262
+ <button
263
+ type="button"
264
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline"
265
+ onClick={() => onNarrowToField(field)}
266
+ >
267
+ <History className="h-3.5 w-3.5" aria-hidden="true" />
268
+ Only this field&apos;s history
269
+ </button>
270
+ )}
271
+ </div>
272
+ )}
215
273
  </div>
216
274
  )}
217
275
  </li>
@@ -219,6 +277,20 @@ export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionField
219
277
  })}
220
278
  </ul>
221
279
  )}
280
+
281
+ {onRestoreVersion && (
282
+ <div>
283
+ <button
284
+ type="button"
285
+ className={RESTORE_BUTTON}
286
+ data-testid="revision-restore-version"
287
+ onClick={onRestoreVersion}
288
+ >
289
+ <Undo2 className="h-3.5 w-3.5" aria-hidden="true" />
290
+ Restore this version
291
+ </button>
292
+ </div>
293
+ )}
222
294
  </div>
223
295
  );
224
296
  }
@@ -77,6 +77,21 @@ export interface StaleSaveDialogProps {
77
77
  fieldLabel?: (field: string) => string;
78
78
  /** Extra content, below the fields and above the footer. */
79
79
  children?: ReactNode;
80
+ /**
81
+ * COPY FOR A CONFLICT THAT IS NOT AN EDITOR'S (bd startsim-zkyk4). The defaults
82
+ * are the draft editor's words — "your edits are still here" — which are true
83
+ * there and false for a refused RESTORE, where nothing was typed. A host that
84
+ * reuses this dialog for another kind of write says what that write was; the
85
+ * structure, the safe default focus and the one path that adopts the server's
86
+ * version are unchanged.
87
+ */
88
+ reassurance?: ReactNode;
89
+ /** "while you were editing" — the clause after "<actor> changed <record>". */
90
+ whilePhrase?: string;
91
+ dismissLabel?: string;
92
+ reviewLabel?: string;
93
+ keepMineLabel?: string;
94
+ acceptTheirsLabel?: string;
80
95
  classNames?: StaleSaveDialogClassNames;
81
96
  }
82
97
 
@@ -119,6 +134,12 @@ export function StaleSaveDialog({
119
134
  fieldLabel = (field) => field,
120
135
  children,
121
136
  classNames,
137
+ reassurance = 'Your edits are still here and have not been saved.',
138
+ whilePhrase = 'while you were editing',
139
+ dismissLabel = 'Keep editing',
140
+ reviewLabel = 'See what changed',
141
+ keepMineLabel = 'Save mine, replacing their change',
142
+ acceptTheirsLabel = 'Discard my edits and load theirs',
122
143
  }: StaleSaveDialogProps) {
123
144
  const css = { ...STALE_SAVE_DIALOG_DEFAULTS, ...classNames };
124
145
  const focusTarget = useRef<HTMLButtonElement | null>(null);
@@ -175,18 +196,18 @@ export function StaleSaveDialog({
175
196
  {...(actor?.actorKind ? { kind: actor.actorKind } : {})}
176
197
  {...(actor?.actorKindSource ? { kindSource: actor.actorKindSource } : {})}
177
198
  />
178
- <span>changed {recordLabel} while you were editing.</span>
199
+ <span>changed {recordLabel} {whilePhrase}.</span>
179
200
  </>
180
201
  ) : (
181
202
  /* NEVER "someone", and never a guess at a machine. The record
182
203
  changed; who is a separate question this dialog answers only
183
204
  when the trail actually said. */
184
- <span>{`${recordLabel.charAt(0).toUpperCase()}${recordLabel.slice(1)} changed while you were editing.`}</span>
205
+ <span>{`${recordLabel.charAt(0).toUpperCase()}${recordLabel.slice(1)} changed ${whilePhrase}.`}</span>
185
206
  )}
186
207
  </h2>
187
208
  {/* THE LOAD-BEARING SENTENCE, and it comes first for that reason. */}
188
209
  <p className={css.reassurance} data-testid="stale-save-reassurance">
189
- Your edits are still here and have not been saved.
210
+ {reassurance}
190
211
  </p>
191
212
  </div>
192
213
 
@@ -234,7 +255,7 @@ export function StaleSaveDialog({
234
255
  disabled={busy}
235
256
  onClick={onAcceptTheirs}
236
257
  >
237
- Discard my edits and load theirs
258
+ {acceptTheirsLabel}
238
259
  </button>
239
260
  )}
240
261
  <button
@@ -246,7 +267,7 @@ export function StaleSaveDialog({
246
267
  data-testid="stale-save-dismiss"
247
268
  onClick={onDismiss}
248
269
  >
249
- Keep editing
270
+ {dismissLabel}
250
271
  </button>
251
272
  {onReviewChange && (
252
273
  <button
@@ -256,7 +277,7 @@ export function StaleSaveDialog({
256
277
  data-testid="stale-save-review"
257
278
  onClick={onReviewChange}
258
279
  >
259
- See what changed
280
+ {reviewLabel}
260
281
  </button>
261
282
  )}
262
283
  {onKeepMine && (
@@ -267,7 +288,7 @@ export function StaleSaveDialog({
267
288
  disabled={busy}
268
289
  onClick={onKeepMine}
269
290
  >
270
- {busy ? 'Saving…' : 'Save mine, replacing their change'}
291
+ {busy ? 'Saving…' : keepMineLabel}
271
292
  </button>
272
293
  )}
273
294
  </div>
@@ -0,0 +1,213 @@
1
+ /**
2
+ * RED for bd startsim-vjhbw — "edited by X · 2h ago" beside a field, over
3
+ * `GET /entities/<id>/field-authors/` (server bd startsim-6sso4).
4
+ *
5
+ * The three readings that must never be confused:
6
+ * - an ENTRY: somebody (or something) last wrote this field — say who, mark a
7
+ * machine, and open that field's history on click;
8
+ * - NO ENTRY: not edited SINCE THE TRAIL BEGAN. Live rows predate the trail, so
9
+ * this is never "never edited";
10
+ * - HISTORY OFF (`recorded: false`, `fields: null`): we do not know, and we say
11
+ * that — NOT "nobody edited".
12
+ */
13
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
14
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
15
+
16
+ import {
17
+ FieldAttribution,
18
+ FieldAuthorsNotice,
19
+ fieldAuthorOf,
20
+ normalizeFieldAuthors,
21
+ useFieldAuthors,
22
+ } from '../index'
23
+ import type { FieldAuthorsResponse } from '../types'
24
+
25
+ const NOW = Date.parse('2026-10-07T12:00:00.000Z')
26
+
27
+ const recorded: FieldAuthorsResponse = {
28
+ entity_id: 'e1',
29
+ version: 7,
30
+ history: { enabled: true, declared_by: 'default' },
31
+ recorded: true,
32
+ trail_starts: { version: 3, at: '2026-10-01T09:00:00.000Z', from_creation: false },
33
+ fields: {
34
+ blog: {
35
+ version: 7,
36
+ at: new Date(NOW - 2 * 3_600_000).toISOString(),
37
+ actor_sub: 'u-jurga',
38
+ actor_label: 'jurga@ogmc.example',
39
+ actor_kind: 'person',
40
+ actor_kind_source: 'claim',
41
+ source: 'patch',
42
+ },
43
+ team_verdict: {
44
+ version: 6,
45
+ at: new Date(NOW - 3_600_000).toISOString(),
46
+ actor_sub: 'svc:n8n-ogmc',
47
+ actor_label: 'svc:n8n-ogmc',
48
+ actor_kind: 'machine',
49
+ actor_kind_source: 'claim',
50
+ source: 'upsert',
51
+ },
52
+ title: {
53
+ version: 5,
54
+ at: new Date(NOW - 60_000).toISOString(),
55
+ actor_sub: 'u-alex',
56
+ actor_label: 'alex@ogmc.example',
57
+ actor_kind: 'person',
58
+ actor_kind_source: 'claim',
59
+ source: 'restore',
60
+ restored_from: 2,
61
+ },
62
+ },
63
+ name: null,
64
+ }
65
+
66
+ const off: FieldAuthorsResponse = {
67
+ entity_id: 'e1',
68
+ version: 4,
69
+ history: { enabled: false, declared_by: 'type' },
70
+ recorded: false,
71
+ trail_starts: null,
72
+ fields: null,
73
+ name: null,
74
+ detail: 'History is switched off for records of this type, so field authors are not recorded.',
75
+ }
76
+
77
+ beforeAll(() => {
78
+ jest.useFakeTimers({ now: NOW, doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'queueMicrotask', 'nextTick', 'setImmediate', 'clearImmediate'] })
79
+ })
80
+ afterAll(() => jest.useRealTimers())
81
+
82
+ describe('normalizeFieldAuthors — either spelling, failing closed on kind', () => {
83
+ it('reads snake and camel alike', () => {
84
+ const camel = {
85
+ entityId: 'e1',
86
+ version: 2,
87
+ recorded: true,
88
+ trailStarts: { version: 1, at: '2026-10-01T00:00:00Z', fromCreation: true },
89
+ fields: { blog: { version: 2, at: '2026-10-02T00:00:00Z', actorSub: 'u', actorLabel: 'a@b.c', actorKind: 'person', actorKindSource: 'claim', source: 'patch' } },
90
+ }
91
+ const n = normalizeFieldAuthors(camel as never)
92
+ expect(n.recorded).toBe(true)
93
+ expect(n.trailStarts).toMatchObject({ version: 1, fromCreation: true })
94
+ expect(fieldAuthorOf(n, 'blog')).toMatchObject({ actorLabel: 'a@b.c', actorKind: 'person' })
95
+ })
96
+
97
+ it('an unrecognised kind reads unknown — never person', () => {
98
+ const n = normalizeFieldAuthors({
99
+ ...recorded,
100
+ fields: { blog: { ...recorded.fields!.blog!, actor_kind: 'robot' as never } },
101
+ })
102
+ expect(fieldAuthorOf(n, 'blog')?.actorKind).toBe('unknown')
103
+ })
104
+
105
+ it('history off is NOT the same as an empty field map', () => {
106
+ const n = normalizeFieldAuthors(off)
107
+ expect(n.recorded).toBe(false)
108
+ expect(fieldAuthorOf(n, 'blog')).toBeUndefined()
109
+ })
110
+ })
111
+
112
+ describe('FieldAttribution — an entry', () => {
113
+ it('names the person and how long ago', () => {
114
+ render(<FieldAttribution authors={recorded} field="blog" />)
115
+ const el = screen.getByTestId('field-attribution-blog')
116
+ expect(el).toHaveTextContent(/jurga@ogmc\.example/)
117
+ expect(el).toHaveTextContent(/2 hours ago/)
118
+ expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument()
119
+ })
120
+
121
+ it('ALWAYS marks a machine, through ActorIdentity', () => {
122
+ render(<FieldAttribution authors={recorded} field="team_verdict" />)
123
+ expect(screen.getByTestId('actor-kind')).toHaveTextContent(/machine/)
124
+ // ActorIdentity's accessible title, not a second vocabulary.
125
+ expect(screen.getByTestId('actor-identity')).toHaveAttribute('title', expect.stringMatching(/machine made this change/i))
126
+ })
127
+
128
+ it('says a restore was a restore, and from which version', () => {
129
+ render(<FieldAttribution authors={recorded} field="title" />)
130
+ expect(screen.getByTestId('field-attribution-title')).toHaveTextContent(/restored.*v2/i)
131
+ })
132
+
133
+ it('clicking opens that field’s history', () => {
134
+ const onOpenHistory = jest.fn()
135
+ render(<FieldAttribution authors={recorded} field="blog" onOpenHistory={onOpenHistory} />)
136
+ fireEvent.click(screen.getByRole('button', { name: /history of blog/i }))
137
+ expect(onOpenHistory).toHaveBeenCalledWith('blog')
138
+ })
139
+
140
+ it('is plain text, not a button, when the host offers no history', () => {
141
+ render(<FieldAttribution authors={recorded} field="blog" />)
142
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
143
+ })
144
+
145
+ it('links to the actor’s recent edits when the host gives a URL for them', () => {
146
+ render(<FieldAttribution authors={recorded} field="blog" actorHref={(sub) => `/activity?actor=${sub}`} />)
147
+ expect(screen.getByRole('link', { name: /their recent edits/i })).toHaveAttribute('href', '/activity?actor=u-jurga')
148
+ })
149
+ })
150
+
151
+ describe('FieldAttribution — no entry', () => {
152
+ it('says "not edited since history began", with the date — never "never edited"', () => {
153
+ render(<FieldAttribution authors={recorded} field="angle" />)
154
+ const el = screen.getByTestId('field-attribution-angle')
155
+ expect(el).toHaveTextContent(/not edited since/i)
156
+ expect(el.textContent).not.toMatch(/never/i)
157
+ })
158
+
159
+ it('says "since it was created" when the trail began at creation', () => {
160
+ render(
161
+ <FieldAttribution
162
+ authors={{ ...recorded, trail_starts: { version: 1, at: '2026-10-01T09:00:00Z', from_creation: true } }}
163
+ field="angle"
164
+ />,
165
+ )
166
+ expect(screen.getByTestId('field-attribution-angle')).toHaveTextContent(/not edited since it was created/i)
167
+ })
168
+ })
169
+
170
+ describe('FieldAttribution — history off', () => {
171
+ it('says it is not recorded, and does not claim nobody edited it', () => {
172
+ render(<FieldAttribution authors={off} field="blog" />)
173
+ const el = screen.getByTestId('field-attribution-blog')
174
+ expect(el).toHaveTextContent(/not recorded/i)
175
+ expect(el.textContent).not.toMatch(/not edited|nobody|never/i)
176
+ })
177
+
178
+ it('FieldAuthorsNotice repeats the server sentence once', () => {
179
+ render(<FieldAuthorsNotice authors={off} />)
180
+ expect(screen.getByTestId('field-authors-notice')).toHaveTextContent(/switched off/)
181
+ })
182
+
183
+ it('FieldAuthorsNotice says nothing when history is recorded', () => {
184
+ const { container } = render(<FieldAuthorsNotice authors={recorded} />)
185
+ expect(container).toBeEmptyDOMElement()
186
+ })
187
+ })
188
+
189
+ describe('FieldAttribution — loading', () => {
190
+ it('renders nothing until the authors arrive', () => {
191
+ const { container } = render(<FieldAttribution authors={undefined} field="blog" />)
192
+ expect(container).toBeEmptyDOMElement()
193
+ })
194
+ })
195
+
196
+ describe('useFieldAuthors — injected client, cached per key', () => {
197
+ function Probe({ get }: { get: () => Promise<FieldAuthorsResponse> }) {
198
+ const q = useFieldAuthors({ client: { get }, queryKey: ['entity', 'e1', 'field-authors', 7] })
199
+ return <FieldAttribution authors={q.data} field="blog" />
200
+ }
201
+
202
+ it('fetches once and renders through FieldAttribution', async () => {
203
+ const get = jest.fn().mockResolvedValue(recorded)
204
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
205
+ render(
206
+ <QueryClientProvider client={qc}>
207
+ <Probe get={get} />
208
+ </QueryClientProvider>,
209
+ )
210
+ await waitFor(() => expect(screen.getByTestId('field-attribution-blog')).toHaveTextContent(/jurga/))
211
+ expect(get).toHaveBeenCalledTimes(1)
212
+ })
213
+ })