@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.
- package/package.json +1 -1
- package/src/components/ActivityTimeline.tsx +17 -12
- package/src/components/ActorIdentity.tsx +15 -2
- package/src/components/activity/ActivityFeedPanel.tsx +96 -29
- package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
- package/src/components/activity/types.ts +10 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
- package/src/components/collection/InlineReviewActions.tsx +3 -2
- package/src/components/collection/ReviewDrawer.tsx +35 -7
- package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
- package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
- package/src/components/collection/data.ts +16 -1
- package/src/components/collection/index.ts +2 -1
- package/src/components/collection/scoped-client.ts +4 -1
- package/src/components/collection/types.ts +19 -1
- package/src/components/history/FieldAttribution.tsx +308 -0
- package/src/components/history/RecordActivityPanel.tsx +344 -0
- package/src/components/history/RecordHistoryPanel.tsx +381 -29
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +78 -6
- package/src/components/history/StaleSaveDialog.tsx +28 -7
- package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
- package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +157 -9
- package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
- package/src/components/history/__tests__/restore.test.ts +209 -0
- package/src/components/history/index.ts +72 -1
- package/src/components/history/record-history-panel-default-class-names.ts +1 -1
- package/src/components/history/restore.ts +295 -0
- package/src/components/history/revision-feed.ts +105 -0
- package/src/components/history/revision-history.ts +22 -0
- 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
|
|
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
|
-
|
|
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({
|
|
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'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}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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…' :
|
|
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
|
+
})
|