@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.
- package/package.json +38 -12
- 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 +331 -15
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +69 -3
- 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-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/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
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/index.ts +74 -0
- package/src/components/track-changes/normalize.ts +364 -0
- package/src/components/track-changes/types.ts +373 -0
- package/src/components/track-changes/useTrackChanges.ts +214 -0
- 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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
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
|
|
263
|
+
const queryClient = useQueryClient();
|
|
192
264
|
|
|
193
265
|
const query = useInfiniteQuery<RevisionPage>({
|
|
194
|
-
queryKey: [...queryKey, { pageSize, windowMs }],
|
|
195
|
-
queryFn: ({ pageParam }) =>
|
|
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:
|
|
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:
|
|
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
|
+
}
|