@startsimpli/ui 0.4.141 → 0.4.143

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 (39) hide show
  1. package/package.json +38 -12
  2. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  3. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  4. package/src/components/activity/types.ts +10 -5
  5. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  6. package/src/components/collection/InlineReviewActions.tsx +3 -2
  7. package/src/components/collection/ReviewDrawer.tsx +35 -7
  8. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  9. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  10. package/src/components/collection/data.ts +16 -1
  11. package/src/components/collection/index.ts +2 -1
  12. package/src/components/collection/scoped-client.ts +4 -1
  13. package/src/components/collection/types.ts +19 -1
  14. package/src/components/history/FieldAttribution.tsx +308 -0
  15. package/src/components/history/RecordActivityPanel.tsx +344 -0
  16. package/src/components/history/RecordHistoryPanel.tsx +331 -15
  17. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  18. package/src/components/history/RevisionFields.tsx +69 -3
  19. package/src/components/history/StaleSaveDialog.tsx +28 -7
  20. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  21. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  22. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  23. package/src/components/history/__tests__/restore.test.ts +209 -0
  24. package/src/components/history/index.ts +72 -1
  25. package/src/components/history/restore.ts +295 -0
  26. package/src/components/history/revision-feed.ts +105 -0
  27. package/src/components/history/revision-history.ts +22 -0
  28. package/src/components/history/types.ts +153 -1
  29. package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
  30. package/src/components/track-changes/__tests__/extension.test.ts +350 -0
  31. package/src/components/track-changes/__tests__/model.test.ts +461 -0
  32. package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
  33. package/src/components/track-changes/describe.ts +76 -0
  34. package/src/components/track-changes/extension.ts +724 -0
  35. package/src/components/track-changes/index.ts +74 -0
  36. package/src/components/track-changes/normalize.ts +364 -0
  37. package/src/components/track-changes/types.ts +373 -0
  38. package/src/components/track-changes/useTrackChanges.ts +214 -0
  39. package/src/components/track-changes/word-diff.ts +97 -0
@@ -49,26 +49,51 @@
49
49
  * = {"enabled": false}` on the type (or the reserved `"*"` row). That is a
50
50
  * decision somebody made, not a silent door, so it gets different words —
51
51
  * "history is not recorded for this kind of record" — and it is said only
52
- * when the host passes `historyEnabled={false}`. The panel cannot see the
53
- * setting itself: it only holds a revisions client, and the revisions
54
- * envelope does not carry the resolved policy. Otherwise the empty state
52
+ * when the policy says so: the host's `historyEnabled={false}`, or — since
53
+ * bd startsim-jkkn7.17 put the resolved policy on the revisions envelope —
54
+ * `history.enabled: false` on the first page. Otherwise the empty state
55
55
  * says "no edits recorded" and nothing more — and never "never changed".
56
+ *
57
+ * ── RESTORE (bd startsim-zkyk4; route bd startsim-ti1yv) ────────────────────
58
+ *
59
+ * "Restore this value" on a changed field and "Restore this version" on a row,
60
+ * offered only when the host injects `client.restore` and only where it would
61
+ * change something. Nothing is written until {@link RestoreConfirmDialog} has
62
+ * shown what will move, computed from the trail this panel already holds by the
63
+ * same backwards undo the server runs. Every restore carries the version the
64
+ * HOST holds (`currentVersion`, read at confirm time so an autosave in between
65
+ * is honoured) — the route 428s without one — and a 412 reuses StaleSaveDialog,
66
+ * where only an explicit "restore anyway" adopts the version the refusal named.
67
+ * A narrowed (`?field=`) trail is that field's own, so it never offers a
68
+ * whole-record restore.
56
69
  */
57
- import { ReactNode, useMemo } from 'react';
58
- import { useInfiniteQuery } from '@tanstack/react-query';
59
- import { Bot, FilePlus2, Pencil, ShieldAlert, Tag } from 'lucide-react';
70
+ import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
71
+ import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
72
+ import { Bot, FilePlus2, Pencil, ShieldAlert, Tag, Undo2, X } from 'lucide-react';
60
73
 
61
74
  import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline';
62
75
  import { ErrorState } from '../states';
63
76
  import { RevisionFields } from './RevisionFields';
77
+ import { RestoreConfirmDialog } from './RestoreConfirmDialog';
78
+ import { StaleSaveDialog } from './StaleSaveDialog';
79
+ import type { StaleSaveConflict } from './useConditionalSave';
80
+ import { newestRevision } from './conditional-write';
81
+ import {
82
+ latestFieldVersions,
83
+ parseRestoreOutcome,
84
+ restorePreview,
85
+ type ParsedRestoreOutcome,
86
+ type RestorePreview,
87
+ } from './restore';
64
88
  import {
65
89
  COLLAPSE_WINDOW_MS,
66
90
  foldRevisions,
91
+ normalizeRevision,
67
92
  sittingHeadline,
68
93
  sittingSummary,
69
94
  type RevisionSitting,
70
95
  } from './revision-history';
71
- import type { EntityRevision, RevisionClient, RevisionPage } from './types';
96
+ import type { EntityRevision, RestoreRequest, RevisionClient, RevisionPage } from './types';
72
97
  import {
73
98
  RECORD_HISTORY_PANEL_DEFAULTS,
74
99
  type RecordHistoryPanelClassNames,
@@ -82,6 +107,7 @@ export const REVISION_ENTRY_TYPES = {
82
107
  renamed: 'revision_renamed',
83
108
  attempt: 'revision_attempt',
84
109
  machine: 'revision_machine',
110
+ restored: 'revision_restored',
85
111
  } as const;
86
112
 
87
113
  const DEFAULT_ICONS = {
@@ -90,10 +116,14 @@ const DEFAULT_ICONS = {
90
116
  [REVISION_ENTRY_TYPES.renamed]: Tag,
91
117
  [REVISION_ENTRY_TYPES.attempt]: ShieldAlert,
92
118
  [REVISION_ENTRY_TYPES.machine]: Bot,
119
+ [REVISION_ENTRY_TYPES.restored]: Undo2,
93
120
  };
94
121
 
95
- function entryType(sitting: RevisionSitting): string {
122
+ /** The timeline entry type for a sitting. Exported so the cross-record feed
123
+ * (RecordActivityPanel) icons a row exactly as this panel does. */
124
+ export function revisionEntryType(sitting: RevisionSitting): string {
96
125
  if (sitting.isAttempt) return REVISION_ENTRY_TYPES.attempt;
126
+ if (sitting.restoredFrom !== undefined) return REVISION_ENTRY_TYPES.restored;
97
127
  if (sitting.action === 'created') return REVISION_ENTRY_TYPES.created;
98
128
  if (sitting.actorKind === 'machine') return REVISION_ENTRY_TYPES.machine;
99
129
  if (Object.keys(sitting.changed).length === 0 && sitting.name) {
@@ -102,10 +132,12 @@ function entryType(sitting: RevisionSitting): string {
102
132
  return REVISION_ENTRY_TYPES.updated;
103
133
  }
104
134
 
135
+ export const REVISION_ENTRY_ICONS = DEFAULT_ICONS;
136
+
105
137
  /** The version this sitting spans. Shown because `version` is the chain's own
106
138
  * gapless sequence, which is the only thing a reader (or a later span-blame
107
139
  * pass) can use to tell a complete trail from a lossy one. */
108
- function versionNote(sitting: RevisionSitting): string {
140
+ export function versionNote(sitting: RevisionSitting): string {
109
141
  return sitting.fromVersion === sitting.toVersion
110
142
  ? `v${sitting.toVersion}`
111
143
  : `v${sitting.fromVersion}–v${sitting.toVersion}`;
@@ -154,6 +186,38 @@ export interface RecordHistoryPanelProps {
154
186
  historyEnabled?: boolean;
155
187
  /** The sentence shown when {@link historyEnabled} is `false`. */
156
188
  declaredOffNote?: ReactNode;
189
+ /** Narrow the trail to one field — `?field=` on the read (bd startsim-jkkn7.8).
190
+ * What `FieldAttribution` opens. A narrowed panel offers no whole-record
191
+ * restore: the narrowed read misses name changes and held-back attempts
192
+ * (bd startsim-jkkn7.18), so a preview over it would be partial. */
193
+ field?: string;
194
+ /** "Show all fields" while narrowed. Omit to hide the control. */
195
+ onClearField?: () => void;
196
+ /** Narrow from inside a row ("only this field's history"). Omit to hide. */
197
+ onNarrowToField?: (field: string) => void;
198
+ /** The version the HOST holds for this record — what a restore asserts. A
199
+ * function is read at confirm time, so a draft editor can pass its live
200
+ * conditional-save version and an autosave between opening the dialog and
201
+ * confirming it does not turn the restore into a 412 against the reviewer's
202
+ * own save. Falls back to the newest loaded revision on an unnarrowed trail. */
203
+ currentVersion?: number | (() => number | undefined);
204
+ /** Whether the viewer may write this record. Restore is offered only when the
205
+ * client has `restore` AND this is not `false`. */
206
+ canRestore?: boolean;
207
+ /**
208
+ * Runs before the restore is sent, and BEFORE `currentVersion` is read — the
209
+ * host's chance to stop pending autosaves and flush unsaved typing, so nothing
210
+ * lands after the restore with the old text and nothing typed is lost. May
211
+ * return a cleanup, run once the restore has settled either way (success,
212
+ * refusal or 412). THROWING ABORTS the restore with the error's message: a
213
+ * flush that was itself refused must not be followed by a restore.
214
+ */
215
+ beforeRestore?: () => void | (() => void) | Promise<void | (() => void)>;
216
+ /** After a restore landed. The host reloads its own state (an editor's local
217
+ * copy) from `outcome.record`; the panel re-reads its trail itself. */
218
+ onRestored?: (outcome: Extract<ParsedRestoreOutcome, { kind: 'restored' }>) => void | Promise<void>;
219
+ /** "this draft", "this topic" — for the confirm and stale dialogs. */
220
+ recordLabel?: string;
157
221
  /** An extra caveat under the trail, for a host that knows something about
158
222
  * this record's coverage the panel cannot. None by default: since bd
159
223
  * startsim-jkkn7.6 every write path records, and a standing "some edits may
@@ -185,23 +249,183 @@ export function RecordHistoryPanel({
185
249
  declaredOffNote =
186
250
  'History is not recorded for this kind of record — it has been switched off for this type, so new edits do not appear here.',
187
251
  incompleteNote = null,
252
+ field,
253
+ onClearField,
254
+ onNarrowToField,
255
+ currentVersion,
256
+ canRestore,
257
+ beforeRestore,
258
+ onRestored,
259
+ recordLabel = 'this record',
188
260
  classNames = {},
189
261
  }: RecordHistoryPanelProps) {
190
262
  const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
191
- const declaredOff = historyEnabled === false;
263
+ const queryClient = useQueryClient();
192
264
 
193
265
  const query = useInfiniteQuery<RevisionPage>({
194
- queryKey: [...queryKey, { pageSize, windowMs }],
195
- queryFn: ({ pageParam }) => client.list({ page: pageParam as number, pageSize }),
266
+ queryKey: [...queryKey, { pageSize, windowMs, field: field ?? null }],
267
+ queryFn: ({ pageParam }) =>
268
+ client.list({ page: pageParam as number, pageSize, ...(field ? { field } : {}) }),
196
269
  initialPageParam: 1,
197
270
  getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
198
271
  });
199
272
 
273
+ // The host's word wins; otherwise the envelope's resolved policy
274
+ // (bd startsim-jkkn7.17). Absent from both says nothing.
275
+ const envelopePolicy = query.data?.pages?.[0]?.history;
276
+ const declaredOff = (historyEnabled ?? envelopePolicy?.enabled) === false;
277
+
200
278
  const revisions = useMemo<EntityRevision[]>(
201
279
  () => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
202
280
  [query.data],
203
281
  );
204
282
 
283
+ /* ------------------------------------------------------------------ */
284
+ /* RESTORE */
285
+ /* ------------------------------------------------------------------ */
286
+
287
+ const restoreEnabled = typeof client.restore === 'function' && canRestore !== false;
288
+ const latest = useMemo(() => latestFieldVersions(revisions), [revisions]);
289
+ const newestLoaded = useMemo(
290
+ () => normalizeRevision(newestRevision(revisions) ?? { id: '', version: 0 }).version,
291
+ [revisions],
292
+ );
293
+
294
+ const [pending, setPending] = useState<{ target: number; fields?: string[] } | null>(null);
295
+ const [busy, setBusy] = useState(false);
296
+ const [restoreError, setRestoreError] = useState<string | null>(null);
297
+ const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
298
+ const [done, setDone] = useState<string | null>(null);
299
+ /** The request a 412 refused, kept so "restore anyway" re-sends exactly it. */
300
+ const refused = useRef<RestoreRequest | null>(null);
301
+ const inFlight = useRef(false);
302
+
303
+ const preview = useMemo<RestorePreview | null>(
304
+ () =>
305
+ pending
306
+ ? restorePreview(revisions, pending.target, {
307
+ ...(pending.fields ? { fields: pending.fields } : {}),
308
+ narrowed: !!field,
309
+ })
310
+ : null,
311
+ [pending, revisions, field],
312
+ );
313
+
314
+ const heldVersion = useCallback((): number | undefined => {
315
+ const held = typeof currentVersion === 'function' ? currentVersion() : currentVersion;
316
+ if (typeof held === 'number' && Number.isInteger(held)) return held;
317
+ // Only an UNNARROWED trail's newest row is the record's current version.
318
+ return field ? undefined : newestLoaded || undefined;
319
+ }, [currentVersion, field, newestLoaded]);
320
+
321
+ const refresh = useCallback(
322
+ () => queryClient.invalidateQueries({ queryKey: [...queryKey] }),
323
+ [queryClient, queryKey],
324
+ );
325
+
326
+ const send = useCallback(
327
+ async (request: RestoreRequest) => {
328
+ if (!client.restore || inFlight.current) return;
329
+ inFlight.current = true;
330
+ setBusy(true);
331
+ setRestoreError(null);
332
+ let outcome: unknown;
333
+ try {
334
+ outcome = await client.restore(request);
335
+ } catch (error) {
336
+ outcome = error;
337
+ }
338
+ const parsed = parseRestoreOutcome(outcome);
339
+ try {
340
+ if (parsed.kind === 'restored') {
341
+ setPending(null);
342
+ setConflict(null);
343
+ refused.current = null;
344
+ const moved = parsed.summary.fields;
345
+ const from = `v${parsed.summary.restoredFrom ?? request.version}`;
346
+ setDone(
347
+ parsed.summary.revision === null
348
+ ? `Nothing to restore — already as it was at ${from}.`
349
+ : `Restored ${moved.length ? moved.map(fieldLabel).join(', ') : 'the record'} from ${from}, saved as v${parsed.summary.revision}.`,
350
+ );
351
+ await onRestored?.(parsed);
352
+ await refresh();
353
+ } else if (parsed.kind === 'stale') {
354
+ // NOT retried. The record moved since the version the host holds; a
355
+ // person decides, in the same dialog an editor's stale save uses.
356
+ setPending(null);
357
+ refused.current = request;
358
+ const next: StaleSaveConflict = {
359
+ ...(parsed.currentVersion !== undefined ? { currentVersion: parsed.currentVersion } : {}),
360
+ ...(parsed.detail ? { detail: parsed.detail } : {}),
361
+ sentVersion: request.expectedVersion,
362
+ refusedBody: { ...request },
363
+ };
364
+ setConflict(next);
365
+ try {
366
+ const page = await client.list({ page: 1, pageSize: 1 });
367
+ const row = newestRevision(page?.results);
368
+ setConflict((cur) =>
369
+ cur === next
370
+ ? row
371
+ ? { ...cur, changedBy: normalizeRevision(row) }
372
+ : { ...cur, identityGap: 'empty' }
373
+ : cur,
374
+ );
375
+ } catch {
376
+ setConflict((cur) => (cur === next ? { ...cur, identityGap: 'error' } : cur));
377
+ }
378
+ } else {
379
+ setRestoreError(parsed.message);
380
+ }
381
+ } finally {
382
+ inFlight.current = false;
383
+ setBusy(false);
384
+ }
385
+ },
386
+ [client, fieldLabel, onRestored, refresh],
387
+ );
388
+
389
+ const confirmRestore = useCallback(async () => {
390
+ if (!pending || inFlight.current) return;
391
+ let cleanup: void | (() => void);
392
+ setBusy(true);
393
+ try {
394
+ cleanup = await beforeRestore?.();
395
+ } catch (error) {
396
+ setBusy(false);
397
+ setRestoreError(
398
+ (error instanceof Error && error.message) ||
399
+ 'Your unsaved edits could not be saved first, so nothing was restored.',
400
+ );
401
+ return;
402
+ }
403
+ try {
404
+ // Read AFTER the host flushed: its version may have just advanced.
405
+ const expectedVersion = heldVersion();
406
+ if (expectedVersion === undefined) {
407
+ setBusy(false);
408
+ setRestoreError(
409
+ 'The current version of this record is not known here, so a restore cannot be checked against it. Reload and try again.',
410
+ );
411
+ return;
412
+ }
413
+ await send({
414
+ version: pending.target,
415
+ ...(pending.fields ? { fields: pending.fields } : {}),
416
+ expectedVersion,
417
+ });
418
+ } finally {
419
+ if (typeof cleanup === 'function') cleanup();
420
+ }
421
+ }, [pending, heldVersion, beforeRestore, send]);
422
+
423
+ const openRestore = useCallback((target: number, fields?: string[]) => {
424
+ setRestoreError(null);
425
+ setDone(null);
426
+ setPending({ target, ...(fields ? { fields } : {}) });
427
+ }, []);
428
+
205
429
  // THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
206
430
  const sittings = useMemo(() => foldRevisions(revisions, windowMs), [revisions, windowMs]);
207
431
 
@@ -209,7 +433,7 @@ export function RecordHistoryPanel({
209
433
  () =>
210
434
  sittings.map((sitting) => ({
211
435
  id: sitting.id,
212
- type: entryType(sitting),
436
+ type: revisionEntryType(sitting),
213
437
  title: sittingHeadline(sitting, fieldLabel),
214
438
  occurredAt: sitting.at,
215
439
  actor: { email: sitting.actorLabel || undefined, id: sitting.actorSub || undefined },
@@ -221,9 +445,26 @@ export function RecordHistoryPanel({
221
445
  tone: sitting.isAttempt ? 'warn' : undefined,
222
446
  subtitle: versionNote(sitting),
223
447
  note: sitting.saves > 1 ? sittingSummary(sitting) : undefined,
224
- detail: <RevisionFields sitting={sitting} fieldLabel={fieldLabel} />,
448
+ detail: (
449
+ <RevisionFields
450
+ sitting={sitting}
451
+ fieldLabel={fieldLabel}
452
+ {...(onNarrowToField && !field ? { onNarrowToField } : {})}
453
+ {...(restoreEnabled && !sitting.isAttempt
454
+ ? {
455
+ // Only where a later row moved the field: on the row whose
456
+ // value IS the current one, a restore changes nothing.
457
+ canRestoreField: (f: string) => (latest[f] ?? -1) > sitting.toVersion,
458
+ onRestoreField: (f: string) => openRestore(sitting.toVersion, [f]),
459
+ ...(!field && sitting.toVersion < newestLoaded
460
+ ? { onRestoreVersion: () => openRestore(sitting.toVersion) }
461
+ : {}),
462
+ }
463
+ : {})}
464
+ />
465
+ ),
225
466
  })),
226
- [sittings, fieldLabel],
467
+ [sittings, fieldLabel, restoreEnabled, latest, field, newestLoaded, openRestore, onNarrowToField],
227
468
  );
228
469
 
229
470
  // Sentence 1 — only when some loaded row really is unknown, so a tenant past
@@ -267,6 +508,30 @@ export function RecordHistoryPanel({
267
508
  {headerAside}
268
509
  </div>
269
510
 
511
+ {field && (
512
+ <p className={cx.notice} data-testid="record-history-narrowed">
513
+ Showing changes to <code className="rounded bg-muted px-1 text-foreground">{fieldLabel(field)}</code>{' '}
514
+ only.{' '}
515
+ {onClearField && (
516
+ <button
517
+ type="button"
518
+ data-testid="record-history-clear-field"
519
+ onClick={onClearField}
520
+ className="inline-flex items-center gap-0.5 underline underline-offset-2 hover:text-foreground"
521
+ >
522
+ <X className="h-3 w-3" aria-hidden="true" />
523
+ Show all fields
524
+ </button>
525
+ )}
526
+ </p>
527
+ )}
528
+
529
+ {done && (
530
+ <p className={cx.notice} role="status" data-testid="restore-done">
531
+ {done}
532
+ </p>
533
+ )}
534
+
270
535
  {anyUnknownKind && (
271
536
  <p className={cx.notice} data-testid="record-history-unknown-kind">
272
537
  Entries marked <strong className="font-medium text-foreground">machine</strong> were
@@ -334,6 +599,57 @@ export function RecordHistoryPanel({
334
599
  {incompleteNote}
335
600
  </p>
336
601
  )}
602
+
603
+ {restoreEnabled && (
604
+ <>
605
+ <RestoreConfirmDialog
606
+ open={pending !== null}
607
+ preview={preview}
608
+ {...(pending?.fields ? { fields: pending.fields } : {})}
609
+ recordLabel={recordLabel}
610
+ fieldLabel={fieldLabel}
611
+ busy={busy}
612
+ error={restoreError}
613
+ onConfirm={() => void confirmRestore()}
614
+ onCancel={() => {
615
+ setPending(null);
616
+ setRestoreError(null);
617
+ }}
618
+ />
619
+ <StaleSaveDialog
620
+ conflict={conflict}
621
+ open={conflict !== null}
622
+ recordLabel={recordLabel}
623
+ whilePhrase="after you opened its history"
624
+ reassurance="Nothing was restored. Restoring now would replace their change too."
625
+ dismissLabel="Cancel"
626
+ reviewLabel="See what changed"
627
+ keepMineLabel="Restore anyway, replacing their change"
628
+ busy={busy}
629
+ fieldLabel={fieldLabel}
630
+ onDismiss={() => {
631
+ setConflict(null);
632
+ refused.current = null;
633
+ }}
634
+ onReviewChange={() => {
635
+ setConflict(null);
636
+ refused.current = null;
637
+ void refresh();
638
+ }}
639
+ // The ONLY path that adopts the version the refusal named — a person
640
+ // has been shown who changed it and chose to replace it anyway.
641
+ {...(conflict?.currentVersion !== undefined
642
+ ? {
643
+ onKeepMine: () => {
644
+ const request = refused.current;
645
+ if (!request || conflict?.currentVersion === undefined) return;
646
+ void send({ ...request, expectedVersion: conflict.currentVersion });
647
+ },
648
+ }
649
+ : {})}
650
+ />
651
+ </>
652
+ )}
337
653
  </section>
338
654
  );
339
655
  }
@@ -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
+ }