@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
|
@@ -40,30 +40,60 @@
|
|
|
40
40
|
* because this route serves every historical value a record ever held, so it
|
|
41
41
|
* is a richer leak than the row read — and because a `{}` rendered silently
|
|
42
42
|
* reads as data loss.
|
|
43
|
-
* 3.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
43
|
+
* 3. WHEN HISTORY IS DECLARED OFF FOR THIS KIND OF RECORD. Every write path
|
|
44
|
+
* records a revision now — `annotate`, `move-scope`, `DELETE` and the
|
|
45
|
+
* ingestion worker were the last four, closed by bd startsim-jkkn7.6 — so the
|
|
46
|
+
* old standing caveat that "some writes are not recorded yet" described a gap
|
|
47
|
+
* that no longer exists, and was removed (bd startsim-jkkn7.11). What CAN
|
|
48
|
+
* still leave a trail empty is a DECLARATION: `EntityTypeDef.config.history
|
|
49
|
+
* = {"enabled": false}` on the type (or the reserved `"*"` row). That is a
|
|
50
|
+
* decision somebody made, not a silent door, so it gets different words —
|
|
51
|
+
* "history is not recorded for this kind of record" — and it is said only
|
|
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
|
+
* 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.
|
|
51
69
|
*/
|
|
52
|
-
import { ReactNode, useMemo } from 'react';
|
|
53
|
-
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
54
|
-
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';
|
|
55
73
|
|
|
56
74
|
import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline';
|
|
57
75
|
import { ErrorState } from '../states';
|
|
58
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';
|
|
59
88
|
import {
|
|
60
89
|
COLLAPSE_WINDOW_MS,
|
|
61
90
|
foldRevisions,
|
|
91
|
+
normalizeRevision,
|
|
62
92
|
sittingHeadline,
|
|
63
93
|
sittingSummary,
|
|
64
94
|
type RevisionSitting,
|
|
65
95
|
} from './revision-history';
|
|
66
|
-
import type { EntityRevision, RevisionClient, RevisionPage } from './types';
|
|
96
|
+
import type { EntityRevision, RestoreRequest, RevisionClient, RevisionPage } from './types';
|
|
67
97
|
import {
|
|
68
98
|
RECORD_HISTORY_PANEL_DEFAULTS,
|
|
69
99
|
type RecordHistoryPanelClassNames,
|
|
@@ -77,6 +107,7 @@ export const REVISION_ENTRY_TYPES = {
|
|
|
77
107
|
renamed: 'revision_renamed',
|
|
78
108
|
attempt: 'revision_attempt',
|
|
79
109
|
machine: 'revision_machine',
|
|
110
|
+
restored: 'revision_restored',
|
|
80
111
|
} as const;
|
|
81
112
|
|
|
82
113
|
const DEFAULT_ICONS = {
|
|
@@ -85,10 +116,14 @@ const DEFAULT_ICONS = {
|
|
|
85
116
|
[REVISION_ENTRY_TYPES.renamed]: Tag,
|
|
86
117
|
[REVISION_ENTRY_TYPES.attempt]: ShieldAlert,
|
|
87
118
|
[REVISION_ENTRY_TYPES.machine]: Bot,
|
|
119
|
+
[REVISION_ENTRY_TYPES.restored]: Undo2,
|
|
88
120
|
};
|
|
89
121
|
|
|
90
|
-
|
|
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 {
|
|
91
125
|
if (sitting.isAttempt) return REVISION_ENTRY_TYPES.attempt;
|
|
126
|
+
if (sitting.restoredFrom !== undefined) return REVISION_ENTRY_TYPES.restored;
|
|
92
127
|
if (sitting.action === 'created') return REVISION_ENTRY_TYPES.created;
|
|
93
128
|
if (sitting.actorKind === 'machine') return REVISION_ENTRY_TYPES.machine;
|
|
94
129
|
if (Object.keys(sitting.changed).length === 0 && sitting.name) {
|
|
@@ -97,10 +132,12 @@ function entryType(sitting: RevisionSitting): string {
|
|
|
97
132
|
return REVISION_ENTRY_TYPES.updated;
|
|
98
133
|
}
|
|
99
134
|
|
|
135
|
+
export const REVISION_ENTRY_ICONS = DEFAULT_ICONS;
|
|
136
|
+
|
|
100
137
|
/** The version this sitting spans. Shown because `version` is the chain's own
|
|
101
138
|
* gapless sequence, which is the only thing a reader (or a later span-blame
|
|
102
139
|
* pass) can use to tell a complete trail from a lossy one. */
|
|
103
|
-
function versionNote(sitting: RevisionSitting): string {
|
|
140
|
+
export function versionNote(sitting: RevisionSitting): string {
|
|
104
141
|
return sitting.fromVersion === sitting.toVersion
|
|
105
142
|
? `v${sitting.toVersion}`
|
|
106
143
|
: `v${sitting.fromVersion}–v${sitting.toVersion}`;
|
|
@@ -140,9 +177,51 @@ export interface RecordHistoryPanelProps {
|
|
|
140
177
|
errorTitle?: string;
|
|
141
178
|
notFoundTitle?: string;
|
|
142
179
|
notFoundMessage?: string;
|
|
143
|
-
/**
|
|
144
|
-
* `
|
|
145
|
-
*
|
|
180
|
+
/** Whether this record's TYPE keeps a history, as resolved from
|
|
181
|
+
* `EntityTypeDef.config.history` (the type's own declaration, else the
|
|
182
|
+
* reserved `"*"` row, else on). `false` makes the panel say that history is
|
|
183
|
+
* not recorded for this kind of record, beside any rows written before it
|
|
184
|
+
* was switched off. Omitted or `true` says nothing — the panel never
|
|
185
|
+
* invents a caveat it cannot back. bd startsim-jkkn7.11. */
|
|
186
|
+
historyEnabled?: boolean;
|
|
187
|
+
/** The sentence shown when {@link historyEnabled} is `false`. */
|
|
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;
|
|
221
|
+
/** An extra caveat under the trail, for a host that knows something about
|
|
222
|
+
* this record's coverage the panel cannot. None by default: since bd
|
|
223
|
+
* startsim-jkkn7.6 every write path records, and a standing "some edits may
|
|
224
|
+
* be missing" would teach readers to discount a complete trail. */
|
|
146
225
|
incompleteNote?: ReactNode;
|
|
147
226
|
classNames?: RecordHistoryPanelClassNames;
|
|
148
227
|
}
|
|
@@ -166,24 +245,187 @@ export function RecordHistoryPanel({
|
|
|
166
245
|
notFoundTitle = 'History is not available to you',
|
|
167
246
|
notFoundMessage =
|
|
168
247
|
'This record, or your access to it, has changed. Reopen the record to see its history.',
|
|
169
|
-
|
|
170
|
-
|
|
248
|
+
historyEnabled,
|
|
249
|
+
declaredOffNote =
|
|
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.',
|
|
251
|
+
incompleteNote = null,
|
|
252
|
+
field,
|
|
253
|
+
onClearField,
|
|
254
|
+
onNarrowToField,
|
|
255
|
+
currentVersion,
|
|
256
|
+
canRestore,
|
|
257
|
+
beforeRestore,
|
|
258
|
+
onRestored,
|
|
259
|
+
recordLabel = 'this record',
|
|
171
260
|
classNames = {},
|
|
172
261
|
}: RecordHistoryPanelProps) {
|
|
173
262
|
const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
|
|
263
|
+
const queryClient = useQueryClient();
|
|
174
264
|
|
|
175
265
|
const query = useInfiniteQuery<RevisionPage>({
|
|
176
|
-
queryKey: [...queryKey, { pageSize, windowMs }],
|
|
177
|
-
queryFn: ({ pageParam }) =>
|
|
266
|
+
queryKey: [...queryKey, { pageSize, windowMs, field: field ?? null }],
|
|
267
|
+
queryFn: ({ pageParam }) =>
|
|
268
|
+
client.list({ page: pageParam as number, pageSize, ...(field ? { field } : {}) }),
|
|
178
269
|
initialPageParam: 1,
|
|
179
270
|
getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
|
|
180
271
|
});
|
|
181
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
|
+
|
|
182
278
|
const revisions = useMemo<EntityRevision[]>(
|
|
183
279
|
() => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
|
|
184
280
|
[query.data],
|
|
185
281
|
);
|
|
186
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
|
+
|
|
187
429
|
// THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
|
|
188
430
|
const sittings = useMemo(() => foldRevisions(revisions, windowMs), [revisions, windowMs]);
|
|
189
431
|
|
|
@@ -191,7 +433,7 @@ export function RecordHistoryPanel({
|
|
|
191
433
|
() =>
|
|
192
434
|
sittings.map((sitting) => ({
|
|
193
435
|
id: sitting.id,
|
|
194
|
-
type:
|
|
436
|
+
type: revisionEntryType(sitting),
|
|
195
437
|
title: sittingHeadline(sitting, fieldLabel),
|
|
196
438
|
occurredAt: sitting.at,
|
|
197
439
|
actor: { email: sitting.actorLabel || undefined, id: sitting.actorSub || undefined },
|
|
@@ -203,9 +445,26 @@ export function RecordHistoryPanel({
|
|
|
203
445
|
tone: sitting.isAttempt ? 'warn' : undefined,
|
|
204
446
|
subtitle: versionNote(sitting),
|
|
205
447
|
note: sitting.saves > 1 ? sittingSummary(sitting) : undefined,
|
|
206
|
-
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
|
+
),
|
|
207
466
|
})),
|
|
208
|
-
[sittings, fieldLabel],
|
|
467
|
+
[sittings, fieldLabel, restoreEnabled, latest, field, newestLoaded, openRestore, onNarrowToField],
|
|
209
468
|
);
|
|
210
469
|
|
|
211
470
|
// Sentence 1 — only when some loaded row really is unknown, so a tenant past
|
|
@@ -214,6 +473,13 @@ export function RecordHistoryPanel({
|
|
|
214
473
|
() => sittings.some((s) => s.actorKind === 'unknown'),
|
|
215
474
|
[sittings],
|
|
216
475
|
);
|
|
476
|
+
// A claimed person is undecorated exactly like an unknown row, so when both
|
|
477
|
+
// are loaded "the rest" would sweep in the one row whose kind WAS recorded
|
|
478
|
+
// (bd startsim-jkkn7.15). Each row's own title/sr-only text says which.
|
|
479
|
+
const anyClaimedPerson = useMemo(
|
|
480
|
+
() => sittings.some((s) => s.actorKind === 'person' && s.actorKindSource === 'claim'),
|
|
481
|
+
[sittings],
|
|
482
|
+
);
|
|
217
483
|
|
|
218
484
|
// Sentence 2 — the server's own roll-up over this page's rows, unioned
|
|
219
485
|
// across the pages loaded so far.
|
|
@@ -242,12 +508,45 @@ export function RecordHistoryPanel({
|
|
|
242
508
|
{headerAside}
|
|
243
509
|
</div>
|
|
244
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
|
+
|
|
245
535
|
{anyUnknownKind && (
|
|
246
536
|
<p className={cx.notice} data-testid="record-history-unknown-kind">
|
|
247
537
|
Entries marked <strong className="font-medium text-foreground">machine</strong> were
|
|
248
|
-
made by an automation.
|
|
249
|
-
|
|
250
|
-
|
|
538
|
+
made by an automation.{' '}
|
|
539
|
+
{anyClaimedPerson
|
|
540
|
+
? 'Some unmarked entries were recorded as made by a person; for the others, whether a person or a machine made the edit was not recorded'
|
|
541
|
+
: 'For the rest, whether a person or a machine made the edit was not recorded'}{' '}
|
|
542
|
+
— it is not available for edits made before this was switched on, or by anyone who has
|
|
543
|
+
not signed in since.{anyClaimedPerson && ' Hover a name to see which.'}
|
|
544
|
+
</p>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{declaredOff && !query.isError && (
|
|
548
|
+
<p className={cx.notice} data-testid="record-history-declared-off">
|
|
549
|
+
{declaredOffNote}
|
|
251
550
|
</p>
|
|
252
551
|
)}
|
|
253
552
|
|
|
@@ -277,7 +576,9 @@ export function RecordHistoryPanel({
|
|
|
277
576
|
hasMore={!!query.hasNextPage}
|
|
278
577
|
onLoadMore={loadMore}
|
|
279
578
|
emptyTitle={emptyTitle}
|
|
280
|
-
|
|
579
|
+
// Declared off: "edits will appear here" would be untrue, and the
|
|
580
|
+
// notice above already says why the trail is empty.
|
|
581
|
+
emptyDescription={declaredOff ? '' : emptyDescription}
|
|
281
582
|
activityIcons={DEFAULT_ICONS}
|
|
282
583
|
defaultIcon={Pencil}
|
|
283
584
|
loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
|
@@ -292,12 +593,63 @@ export function RecordHistoryPanel({
|
|
|
292
593
|
</p>
|
|
293
594
|
)}
|
|
294
595
|
|
|
295
|
-
{/*
|
|
596
|
+
{/* A host-supplied caveat only; none by default (see sentence 3). */}
|
|
296
597
|
{incompleteNote && !query.isError && (
|
|
297
598
|
<p className={cx.countLine} data-testid="record-history-incomplete">
|
|
298
599
|
{incompleteNote}
|
|
299
600
|
</p>
|
|
300
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
|
+
)}
|
|
301
653
|
</section>
|
|
302
654
|
);
|
|
303
655
|
}
|