@startsimpli/ui 0.4.134 → 0.4.136
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 +5 -4
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/collection/InlineReviewActions.tsx +31 -16
- package/src/components/collection/ReviewDrawer.tsx +15 -2
- package/src/components/collection/__tests__/review.test.ts +149 -0
- package/src/components/collection/index.ts +2 -0
- package/src/components/collection/review.ts +88 -0
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -0
- package/src/components/history/index.ts +62 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/index.ts +11 -1
|
@@ -387,6 +387,94 @@ export function applyDecisionToData(
|
|
|
387
387
|
return declaredBlob(next, declaredNames);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/** True when a status name reads like the END of a pipeline (published/written/sent). */
|
|
391
|
+
export function isTerminalStatus(status: string): boolean {
|
|
392
|
+
return TERMINAL_RE.test(status);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Which review decision a record's STATE currently reflects — the key of the
|
|
397
|
+
* button that should render as active, or null when nobody has decided yet.
|
|
398
|
+
*
|
|
399
|
+
* STATUS IS THE DECISION; THE VERDICT IS NOT (bd startsim-m7fdm.12). This used
|
|
400
|
+
* to resolve by verdict first wherever a type declared one, and on OGMC's topics
|
|
401
|
+
* that made the machine's opinion render as the human's: the n8n re-rank agent
|
|
402
|
+
* writes `team_verdict` WITHOUT writing status — deliberately, because
|
|
403
|
+
* startsim-ozpjw.8 stopped an LLM being able to push a record to `ready` on its
|
|
404
|
+
* own say-so — so 20 of 171 live topics sat at `suggested` while the table drew
|
|
405
|
+
* a darkened green tick over them. A reviewer reads that as "I approved this".
|
|
406
|
+
* Nothing had been approved, and a topic at `suggested` is invisible to the
|
|
407
|
+
* auto-write poll, so those twenty looked done and would never be written.
|
|
408
|
+
*
|
|
409
|
+
* The rule, therefore: WHERE A TYPE HAS A STATUS PIPELINE, ONLY STATUS DECIDES
|
|
410
|
+
* what renders as in force. Status is the one field a human's click writes that
|
|
411
|
+
* the pipeline agents do not, so it is the only honest record of a human
|
|
412
|
+
* decision — and that invariant is what guarantees no machine-set verdict can
|
|
413
|
+
* light any button. `team_verdict` keeps being written (it is the re-rank
|
|
414
|
+
* agent's input) and keeps being READABLE as its own column; it just stops
|
|
415
|
+
* impersonating an approval.
|
|
416
|
+
*
|
|
417
|
+
* Three cases, resolved in this order so the answer never depends on the order a
|
|
418
|
+
* caller happens to hold its decisions in:
|
|
419
|
+
*
|
|
420
|
+
* 1. EXACT, past the entry stage — the record's status is where some decision
|
|
421
|
+
* puts it, and that status exists only because a decision wrote it.
|
|
422
|
+
* 2. BACK AT THE ENTRY STAGE — a "Needs work" writes status back to the stage
|
|
423
|
+
* every record starts on, so it leaves nothing status can tell apart from
|
|
424
|
+
* "untouched". On a verdict-bearing type that is rendered as NO decision:
|
|
425
|
+
* the agent writes the `edit` verdict too ("RESOLVED from edit -> good"), so
|
|
426
|
+
* trusting it here would just move the same lie from the green tick to the
|
|
427
|
+
* amber one. On a type with no verdict vocabulary at all (news_item, which
|
|
428
|
+
* passes `verdicts: []`) the status match is all there is, and stands.
|
|
429
|
+
* 3. FORWARD, PAST APPROVAL, TO A TERMINAL STAGE — `ready → written`. The
|
|
430
|
+
* approval is still the decision in force; a written topic rendering as
|
|
431
|
+
* undecided would be the same lie pointing the other way, and with the State
|
|
432
|
+
* column off by default (bd startsim-m7fdm.11) the tick is the only state a
|
|
433
|
+
* row shows. Only TERMINAL stages count: news_item declares `ignored` and
|
|
434
|
+
* `preview_only` after `acceptable`, and neither is a consequence of
|
|
435
|
+
* accepting anything.
|
|
436
|
+
*
|
|
437
|
+
* A type with no status enum at all keeps resolving by verdict — there, nothing
|
|
438
|
+
* but the buttons themselves writes one, so the verdict IS the decision.
|
|
439
|
+
*/
|
|
440
|
+
export function activeDecisionKey(
|
|
441
|
+
data: Record<string, unknown> | undefined,
|
|
442
|
+
r: ResolvedReview,
|
|
443
|
+
decisions: ReviewDecision[] = reviewDecisions(r),
|
|
444
|
+
): string | null {
|
|
445
|
+
const choices = r.statusChoices;
|
|
446
|
+
|
|
447
|
+
// No status pipeline → the verdict is the only place a decision is recorded.
|
|
448
|
+
if (choices.length === 0) {
|
|
449
|
+
const v = fieldStr(data, r.verdictAttr);
|
|
450
|
+
return v ? decisions.find((d) => d.verdict && d.verdict === v)?.key ?? null : null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const status = fieldStr(data, r.statusName);
|
|
454
|
+
const entry = choices[0];
|
|
455
|
+
|
|
456
|
+
// (1) a status past the entry stage is there because a decision wrote it.
|
|
457
|
+
const exact = decisions.find((d) => !!d.status && d.status !== entry && d.status === status);
|
|
458
|
+
if (exact) return exact.key;
|
|
459
|
+
|
|
460
|
+
// (2) back at the entry stage — indistinguishable from untouched, unless the
|
|
461
|
+
// type carries no verdict vocabulary and the status match is all there is.
|
|
462
|
+
const sentBack = decisions.find((d) => !!d.status && d.status === entry);
|
|
463
|
+
if (sentBack && status === entry) return sentBack.verdict ? null : sentBack.key;
|
|
464
|
+
|
|
465
|
+
// (3) forward, past approval, to a terminal stage: still approved.
|
|
466
|
+
const approve = r.transitions.approve
|
|
467
|
+
? decisions.find((d) => d.status === r.transitions.approve)
|
|
468
|
+
: undefined;
|
|
469
|
+
if (approve) {
|
|
470
|
+
const at = choices.indexOf(status);
|
|
471
|
+
const approveAt = choices.indexOf(r.transitions.approve as string);
|
|
472
|
+
const rejectAt = r.transitions.reject ? choices.indexOf(r.transitions.reject) : -1;
|
|
473
|
+
if (at > approveAt && at !== rejectAt && isTerminalStatus(status)) return approve.key;
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
390
478
|
// ---- small read helpers used by the component (kept here so they're tested) ----
|
|
391
479
|
|
|
392
480
|
/** String read of an attr off a record's data blob ('' when missing). */
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RecordHistoryPanel — who changed which field, from what, to what, and whether
|
|
5
|
+
* a machine did it (bd startsim-g4rwo; trail bd startsim-o1qib).
|
|
6
|
+
*
|
|
7
|
+
* The customer's ask, in their words: "similar to google sheets we need to be
|
|
8
|
+
* able to see who editted what piece of the text and when". This is that
|
|
9
|
+
* surface. It reads `/api/v1/entities/<id>/revisions/` through an injected
|
|
10
|
+
* client, folds the autosave noise in the browser, and renders each sitting
|
|
11
|
+
* through the shared `ActivityTimeline` — the same primitive, and the same
|
|
12
|
+
* conventions, as the control plane's foundry activity stream, rather than a
|
|
13
|
+
* second timeline (rule #9).
|
|
14
|
+
*
|
|
15
|
+
* CLIENT-INJECTED, exactly like `ActivityFeedPanel`: the app passes
|
|
16
|
+
* `{ list: (p) => api.entities.revisions(id, p) }`, which is what keeps
|
|
17
|
+
* @startsimpli/api out of packages/ui.
|
|
18
|
+
*
|
|
19
|
+
* SERVER-SIDE PAGINATION (rule #8) via `useInfiniteQuery`, and the fold runs
|
|
20
|
+
* over the ACCUMULATED pages rather than per page — otherwise one sitting that
|
|
21
|
+
* straddles a page boundary renders as "3 saves" followed by "2 saves", which
|
|
22
|
+
* is wrong and silently so.
|
|
23
|
+
*
|
|
24
|
+
* ── THE THREE SENTENCES THIS PANEL OWES THE READER ──────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* Each exists because a silence in its place reads as a defect, and each is
|
|
27
|
+
* said ONCE for the panel rather than once per row — which is the whole trick
|
|
28
|
+
* to rendering `unknown` without making the panel look broken:
|
|
29
|
+
*
|
|
30
|
+
* 1. WHAT AN UNMARKED ROW MEANS. For up to the 14-day refresh expiry after the
|
|
31
|
+
* actor_kind rollout, a genuine human edit carries no kind at all (the claim
|
|
32
|
+
* is minted at LOGIN and inherited across refreshes). So `unknown` is the
|
|
33
|
+
* COMMON case for real people, and a per-row badge would make every tenant's
|
|
34
|
+
* panel look broken for two weeks while training readers to ignore the one
|
|
35
|
+
* badge that matters. Instead a machine is ALWAYS marked, an unmarked row is
|
|
36
|
+
* left calm, and this panel explains the convention once — and only when
|
|
37
|
+
* some loaded row is actually unknown.
|
|
38
|
+
* 2. THAT SOMETHING IS BEING WITHHELD. The envelope's `redacted_fields` rolls
|
|
39
|
+
* up every field the per-field read gate held back on this page. Named here
|
|
40
|
+
* because this route serves every historical value a record ever held, so it
|
|
41
|
+
* is a richer leak than the row read — and because a `{}` rendered silently
|
|
42
|
+
* reads as data loss.
|
|
43
|
+
* 3. THAT THE TRAIL IS NOT THE WHOLE STORY. Four write paths record NO revision
|
|
44
|
+
* today — `annotate`, `move-scope`, `DELETE` and the ingestion worker
|
|
45
|
+
* (bd startsim-jkkn7.6). The version sequence is still provably gapless
|
|
46
|
+
* (a write that records nothing allocates no number), so there is no hole to
|
|
47
|
+
* see — which is exactly why it has to be SAID. A reader must not conclude
|
|
48
|
+
* from a quiet trail that no machine touched this record. The panel
|
|
49
|
+
* therefore never claims completeness, and its empty state says "no edits
|
|
50
|
+
* recorded" and not "never changed".
|
|
51
|
+
*/
|
|
52
|
+
import { ReactNode, useMemo } from 'react';
|
|
53
|
+
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
54
|
+
import { Bot, FilePlus2, Pencil, ShieldAlert, Tag } from 'lucide-react';
|
|
55
|
+
|
|
56
|
+
import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline';
|
|
57
|
+
import { ErrorState } from '../states';
|
|
58
|
+
import { RevisionFields } from './RevisionFields';
|
|
59
|
+
import {
|
|
60
|
+
COLLAPSE_WINDOW_MS,
|
|
61
|
+
foldRevisions,
|
|
62
|
+
sittingHeadline,
|
|
63
|
+
sittingSummary,
|
|
64
|
+
type RevisionSitting,
|
|
65
|
+
} from './revision-history';
|
|
66
|
+
import type { EntityRevision, RevisionClient, RevisionPage } from './types';
|
|
67
|
+
import {
|
|
68
|
+
RECORD_HISTORY_PANEL_DEFAULTS,
|
|
69
|
+
type RecordHistoryPanelClassNames,
|
|
70
|
+
} from './record-history-panel-default-class-names';
|
|
71
|
+
|
|
72
|
+
/** Timeline entry `type` per sitting, so a host can re-icon or re-colour one
|
|
73
|
+
* class of row without knowing anything else about this module. */
|
|
74
|
+
export const REVISION_ENTRY_TYPES = {
|
|
75
|
+
created: 'revision_created',
|
|
76
|
+
updated: 'revision_updated',
|
|
77
|
+
renamed: 'revision_renamed',
|
|
78
|
+
attempt: 'revision_attempt',
|
|
79
|
+
machine: 'revision_machine',
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
const DEFAULT_ICONS = {
|
|
83
|
+
[REVISION_ENTRY_TYPES.created]: FilePlus2,
|
|
84
|
+
[REVISION_ENTRY_TYPES.updated]: Pencil,
|
|
85
|
+
[REVISION_ENTRY_TYPES.renamed]: Tag,
|
|
86
|
+
[REVISION_ENTRY_TYPES.attempt]: ShieldAlert,
|
|
87
|
+
[REVISION_ENTRY_TYPES.machine]: Bot,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function entryType(sitting: RevisionSitting): string {
|
|
91
|
+
if (sitting.isAttempt) return REVISION_ENTRY_TYPES.attempt;
|
|
92
|
+
if (sitting.action === 'created') return REVISION_ENTRY_TYPES.created;
|
|
93
|
+
if (sitting.actorKind === 'machine') return REVISION_ENTRY_TYPES.machine;
|
|
94
|
+
if (Object.keys(sitting.changed).length === 0 && sitting.name) {
|
|
95
|
+
return REVISION_ENTRY_TYPES.renamed;
|
|
96
|
+
}
|
|
97
|
+
return REVISION_ENTRY_TYPES.updated;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The version this sitting spans. Shown because `version` is the chain's own
|
|
101
|
+
* gapless sequence, which is the only thing a reader (or a later span-blame
|
|
102
|
+
* pass) can use to tell a complete trail from a lossy one. */
|
|
103
|
+
function versionNote(sitting: RevisionSitting): string {
|
|
104
|
+
return sitting.fromVersion === sitting.toVersion
|
|
105
|
+
? `v${sitting.toVersion}`
|
|
106
|
+
: `v${sitting.fromVersion}–v${sitting.toVersion}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A 404 here means the caller may not read the PARENT record: the route is
|
|
110
|
+
* nested and `get_object()` resolves the parent through the gated queryset, so
|
|
111
|
+
* an unreadable row is simply absent. That is a DIFFERENT fact from "this
|
|
112
|
+
* record has no history" and must never be rendered as an empty trail. */
|
|
113
|
+
function isNotFound(error: unknown): boolean {
|
|
114
|
+
const status = (error as { status?: number; response?: { status?: number } } | null)?.status
|
|
115
|
+
?? (error as { response?: { status?: number } } | null)?.response?.status;
|
|
116
|
+
if (status === 404) return true;
|
|
117
|
+
const message = (error as Error | null)?.message ?? '';
|
|
118
|
+
return /\b404\b|not found/i.test(message);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface RecordHistoryPanelProps {
|
|
122
|
+
/** The injected, authed reader for ONE record's revisions. */
|
|
123
|
+
client: RevisionClient;
|
|
124
|
+
/** Cache namespace, e.g. `['entity', id, 'revisions']`. */
|
|
125
|
+
queryKey?: readonly unknown[];
|
|
126
|
+
pageSize?: number;
|
|
127
|
+
/** How close two writes by the same actor count as one sitting. 0 renders a
|
|
128
|
+
* row per save. See {@link COLLAPSE_WINDOW_MS}. */
|
|
129
|
+
windowMs?: number;
|
|
130
|
+
title?: ReactNode;
|
|
131
|
+
description?: ReactNode;
|
|
132
|
+
headerAside?: ReactNode;
|
|
133
|
+
/** Map a wire field name to a reader-facing label. */
|
|
134
|
+
fieldLabel?: (field: string) => string;
|
|
135
|
+
/** Group entries under day headings. On by default — a record's history is
|
|
136
|
+
* read by date far more often than as a flat list. */
|
|
137
|
+
groupByDay?: boolean;
|
|
138
|
+
emptyTitle?: string;
|
|
139
|
+
emptyDescription?: string;
|
|
140
|
+
errorTitle?: string;
|
|
141
|
+
notFoundTitle?: string;
|
|
142
|
+
notFoundMessage?: string;
|
|
143
|
+
/** The standing caveat that the trail has known silent write paths. Pass
|
|
144
|
+
* `null` only if the surrounding page already says it — not to make the
|
|
145
|
+
* panel look more complete than it is. */
|
|
146
|
+
incompleteNote?: ReactNode;
|
|
147
|
+
classNames?: RecordHistoryPanelClassNames;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function RecordHistoryPanel({
|
|
151
|
+
client,
|
|
152
|
+
queryKey = ['record-history'],
|
|
153
|
+
pageSize = 50,
|
|
154
|
+
windowMs = COLLAPSE_WINDOW_MS,
|
|
155
|
+
title = 'History',
|
|
156
|
+
// The customer's question, not a feature name.
|
|
157
|
+
description = 'Who changed which field, from what to what, newest first.',
|
|
158
|
+
headerAside,
|
|
159
|
+
fieldLabel = (field) => field,
|
|
160
|
+
groupByDay = true,
|
|
161
|
+
emptyTitle = 'No edits recorded for this record',
|
|
162
|
+
// NOT "this record has never changed" — see sentence 3 in the header.
|
|
163
|
+
emptyDescription =
|
|
164
|
+
'Edits made through the app will appear here, newest first.',
|
|
165
|
+
errorTitle = 'Could not load history',
|
|
166
|
+
notFoundTitle = 'History is not available to you',
|
|
167
|
+
notFoundMessage =
|
|
168
|
+
'This record, or your access to it, has changed. Reopen the record to see its history.',
|
|
169
|
+
incompleteNote =
|
|
170
|
+
'Some writes are not recorded yet — bulk imports, scope moves, annotations and deletions leave no entry — so a quiet history is not proof that nothing else touched this record.',
|
|
171
|
+
classNames = {},
|
|
172
|
+
}: RecordHistoryPanelProps) {
|
|
173
|
+
const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
|
|
174
|
+
|
|
175
|
+
const query = useInfiniteQuery<RevisionPage>({
|
|
176
|
+
queryKey: [...queryKey, { pageSize, windowMs }],
|
|
177
|
+
queryFn: ({ pageParam }) => client.list({ page: pageParam as number, pageSize }),
|
|
178
|
+
initialPageParam: 1,
|
|
179
|
+
getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const revisions = useMemo<EntityRevision[]>(
|
|
183
|
+
() => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
|
|
184
|
+
[query.data],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
|
|
188
|
+
const sittings = useMemo(() => foldRevisions(revisions, windowMs), [revisions, windowMs]);
|
|
189
|
+
|
|
190
|
+
const items = useMemo<ActivityTimelineItem[]>(
|
|
191
|
+
() =>
|
|
192
|
+
sittings.map((sitting) => ({
|
|
193
|
+
id: sitting.id,
|
|
194
|
+
type: entryType(sitting),
|
|
195
|
+
title: sittingHeadline(sitting, fieldLabel),
|
|
196
|
+
occurredAt: sitting.at,
|
|
197
|
+
actor: { email: sitting.actorLabel || undefined, id: sitting.actorSub || undefined },
|
|
198
|
+
actorKind: sitting.actorKind,
|
|
199
|
+
actorKindSource: sitting.actorKindSource,
|
|
200
|
+
// An attempt is the one row that is NOT a change to the record, so it
|
|
201
|
+
// is the one row that earns a tone. `warn` and not `error`: nothing
|
|
202
|
+
// failed — a guard did its job.
|
|
203
|
+
tone: sitting.isAttempt ? 'warn' : undefined,
|
|
204
|
+
subtitle: versionNote(sitting),
|
|
205
|
+
note: sitting.saves > 1 ? sittingSummary(sitting) : undefined,
|
|
206
|
+
detail: <RevisionFields sitting={sitting} fieldLabel={fieldLabel} />,
|
|
207
|
+
})),
|
|
208
|
+
[sittings, fieldLabel],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Sentence 1 — only when some loaded row really is unknown, so a tenant past
|
|
212
|
+
// the healing window never sees it.
|
|
213
|
+
const anyUnknownKind = useMemo(
|
|
214
|
+
() => sittings.some((s) => s.actorKind === 'unknown'),
|
|
215
|
+
[sittings],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Sentence 2 — the server's own roll-up over this page's rows, unioned
|
|
219
|
+
// across the pages loaded so far.
|
|
220
|
+
const withheld = useMemo(() => {
|
|
221
|
+
const names = new Set<string>();
|
|
222
|
+
for (const page of query.data?.pages ?? []) {
|
|
223
|
+
for (const name of page?.redacted_fields ?? page?.redactedFields ?? []) names.add(name);
|
|
224
|
+
}
|
|
225
|
+
return [...names].sort();
|
|
226
|
+
}, [query.data]);
|
|
227
|
+
|
|
228
|
+
const total = query.data?.pages?.[0]?.count ?? revisions.length;
|
|
229
|
+
const notFound = query.isError && isNotFound(query.error);
|
|
230
|
+
|
|
231
|
+
const loadMore = () => {
|
|
232
|
+
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<section className={cx.root} data-testid="record-history-panel">
|
|
237
|
+
<div className={cx.header}>
|
|
238
|
+
<div className={cx.heading}>
|
|
239
|
+
<h2 className={cx.title}>{title}</h2>
|
|
240
|
+
{description && <p className={cx.description}>{description}</p>}
|
|
241
|
+
</div>
|
|
242
|
+
{headerAside}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{anyUnknownKind && (
|
|
246
|
+
<p className={cx.notice} data-testid="record-history-unknown-kind">
|
|
247
|
+
Entries marked <strong className="font-medium text-foreground">machine</strong> were
|
|
248
|
+
made by an automation. For the rest, whether a person or a machine made the edit was
|
|
249
|
+
not recorded — it is not available for edits made before this was switched on, or by
|
|
250
|
+
anyone who has not signed in since.
|
|
251
|
+
</p>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{withheld.length > 0 && (
|
|
255
|
+
<p className={cx.notice} data-testid="record-history-redacted">
|
|
256
|
+
Some values are not shown to you:{' '}
|
|
257
|
+
{withheld.map((f) => fieldLabel(f)).join(', ')}. The edits are recorded and listed —
|
|
258
|
+
your access does not include reading those fields on this record.
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
<div className={cx.body}>
|
|
263
|
+
{notFound ? (
|
|
264
|
+
<ErrorState title={notFoundTitle} message={notFoundMessage} />
|
|
265
|
+
) : query.isError ? (
|
|
266
|
+
<ErrorState
|
|
267
|
+
title={errorTitle}
|
|
268
|
+
message={(query.error as Error)?.message || 'Please try again.'}
|
|
269
|
+
onRetry={() => query.refetch()}
|
|
270
|
+
/>
|
|
271
|
+
) : (
|
|
272
|
+
<ActivityTimeline
|
|
273
|
+
activities={items}
|
|
274
|
+
loading={query.isLoading}
|
|
275
|
+
groupByDay={groupByDay}
|
|
276
|
+
autoLoadMore
|
|
277
|
+
hasMore={!!query.hasNextPage}
|
|
278
|
+
onLoadMore={loadMore}
|
|
279
|
+
emptyTitle={emptyTitle}
|
|
280
|
+
emptyDescription={emptyDescription}
|
|
281
|
+
activityIcons={DEFAULT_ICONS}
|
|
282
|
+
defaultIcon={Pencil}
|
|
283
|
+
loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
|
284
|
+
/>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{revisions.length > 0 && total > revisions.length && (
|
|
289
|
+
<p className={cx.countLine}>
|
|
290
|
+
Showing {sittings.length} {sittings.length === 1 ? 'entry' : 'entries'} from{' '}
|
|
291
|
+
{revisions.length} of {total} saves.
|
|
292
|
+
</p>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* Sentence 3 — always, when there is anything to qualify. */}
|
|
296
|
+
{incompleteNote && !query.isError && (
|
|
297
|
+
<p className={cx.countLine} data-testid="record-history-incomplete">
|
|
298
|
+
{incompleteNote}
|
|
299
|
+
</p>
|
|
300
|
+
)}
|
|
301
|
+
</section>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What moved inside one sitting, field by field (bd startsim-g4rwo).
|
|
5
|
+
*
|
|
6
|
+
* ── THE THREE WAYS A FIELD CAN BE NAMED WITHOUT ITS VALUES ──────────────────
|
|
7
|
+
*
|
|
8
|
+
* All three arrive as `{}` and they mean different things, so rendering them
|
|
9
|
+
* the same way — or worse, rendering `{}` as "changed to nothing" — turns a
|
|
10
|
+
* permission boundary and a storage cap into apparent data loss. Each gets its
|
|
11
|
+
* own sentence:
|
|
12
|
+
*
|
|
13
|
+
* - WITHHELD (`redacted_fields` names it): you are not allowed to read this
|
|
14
|
+
* field, so you may not read its history either. The FACT of the edit is
|
|
15
|
+
* real and is shown; the values are not. This route serves every historical
|
|
16
|
+
* value a record ever held, which makes it a RICHER leak than the row read —
|
|
17
|
+
* so the gate is honoured here and SAID OUT LOUD, because silence reads as
|
|
18
|
+
* data loss and would send somebody looking for a bug.
|
|
19
|
+
* - OMITTED (`metadata.values_omitted`): the whole revision was over the byte
|
|
20
|
+
* cap, so every field is named and no values were stored. Nobody is being
|
|
21
|
+
* kept out; the values were never written.
|
|
22
|
+
* - TRUNCATED (`truncated: true` on the entry): the value was too large to
|
|
23
|
+
* store. The critical consequence is that A MISSING SIDE NOW SAYS NOTHING —
|
|
24
|
+
* everywhere else a missing `after` means the write DELETED the key — so the
|
|
25
|
+
* created/deleted inference is suppressed and the diff is REFUSED with its
|
|
26
|
+
* reason, rather than showing a deletion that never happened.
|
|
27
|
+
*/
|
|
28
|
+
import { useState } from 'react';
|
|
29
|
+
import { ChevronRight, EyeOff, Scissors } from 'lucide-react';
|
|
30
|
+
|
|
31
|
+
import { cn } from '../../lib/utils';
|
|
32
|
+
import { DiffViewer } from '../diff';
|
|
33
|
+
import {
|
|
34
|
+
canDiffFieldChange,
|
|
35
|
+
classifyFieldChange,
|
|
36
|
+
type FoldedFieldChange,
|
|
37
|
+
type RevisionSitting,
|
|
38
|
+
} from './revision-history';
|
|
39
|
+
import { unifiedTextDiff } from './text-diff';
|
|
40
|
+
|
|
41
|
+
/** A stored value as one line of text, for the side-by-side summary. Objects
|
|
42
|
+
* are JSON so a reader can at least see the shape; nothing is invented. */
|
|
43
|
+
function preview(value: unknown): string {
|
|
44
|
+
if (typeof value === 'string') return value;
|
|
45
|
+
if (value === null) return 'null';
|
|
46
|
+
if (value === undefined) return '';
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
} catch {
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
|
|
55
|
+
|
|
56
|
+
/** One side of a change. ABSENCE IS RENDERED AS ABSENCE — an em dash with
|
|
57
|
+
* screen-reader text — never as an empty box that looks like "" was stored. */
|
|
58
|
+
function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="min-w-0 flex-1">
|
|
61
|
+
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
62
|
+
{label}
|
|
63
|
+
</div>
|
|
64
|
+
{has ? (
|
|
65
|
+
<pre className={cn('rounded border border-border bg-muted/40 p-2 font-mono text-xs', CLAMP)}>
|
|
66
|
+
{preview(value)}
|
|
67
|
+
</pre>
|
|
68
|
+
) : (
|
|
69
|
+
<p className="rounded border border-dashed border-border p-2 text-xs text-muted-foreground">
|
|
70
|
+
<span aria-hidden="true">—</span>
|
|
71
|
+
<span className="sr-only">not set</span>
|
|
72
|
+
<span className="ms-1">not set</span>
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function FieldBody({ field, change }: { field: string; change: FoldedFieldChange }) {
|
|
80
|
+
const kind = classifyFieldChange(change);
|
|
81
|
+
|
|
82
|
+
if (kind === 'withheld') {
|
|
83
|
+
return (
|
|
84
|
+
<p className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
85
|
+
<EyeOff className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
86
|
+
<span>
|
|
87
|
+
This field was changed, but <strong className="font-medium text-foreground">its
|
|
88
|
+
values are not shown to you</strong> — your access does not include reading{' '}
|
|
89
|
+
<code className="rounded bg-muted px-1">{field}</code> on this record. The edit itself
|
|
90
|
+
is real and is recorded.
|
|
91
|
+
</span>
|
|
92
|
+
</p>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (kind === 'omitted') {
|
|
97
|
+
return (
|
|
98
|
+
<p className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
99
|
+
<Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
100
|
+
<span>
|
|
101
|
+
This field changed, but this revision was too large to store its values. The field
|
|
102
|
+
names were kept; the before and after were not written.
|
|
103
|
+
</span>
|
|
104
|
+
</p>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-2">
|
|
110
|
+
{kind === 'truncated' && (
|
|
111
|
+
<p className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
112
|
+
<Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
113
|
+
<span>
|
|
114
|
+
This value was too large to store in full, so what is shown may be a prefix and{' '}
|
|
115
|
+
<strong className="font-medium text-foreground">a missing side means nothing
|
|
116
|
+
here</strong>. No comparison is offered, because a comparison against a shortened
|
|
117
|
+
value would show a deletion that never happened.
|
|
118
|
+
</span>
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
<div className="flex flex-wrap gap-3 sm:flex-nowrap">
|
|
122
|
+
<Side label="Before" has={change.hasBefore} value={change.before} />
|
|
123
|
+
<Side label="After" has={change.hasAfter} value={change.after} />
|
|
124
|
+
</div>
|
|
125
|
+
{canDiffFieldChange(change) && (
|
|
126
|
+
<DiffViewer
|
|
127
|
+
diff={unifiedTextDiff(String(change.before), String(change.after), field)}
|
|
128
|
+
emptyLabel="No line-level change"
|
|
129
|
+
className="max-h-72 overflow-auto rounded border border-border"
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The one-word verdict next to a field name. Neutral tokens throughout: a
|
|
137
|
+
* deletion is a thing a reviewer did on purpose far more often than it is an
|
|
138
|
+
* accident, so it is not painted as an error. */
|
|
139
|
+
const KIND_LABEL: Record<ReturnType<typeof classifyFieldChange>, string> = {
|
|
140
|
+
withheld: 'not shown to you',
|
|
141
|
+
omitted: 'values not stored',
|
|
142
|
+
truncated: 'too large to store',
|
|
143
|
+
created: 'set',
|
|
144
|
+
deleted: 'cleared',
|
|
145
|
+
updated: 'changed',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export interface RevisionFieldsProps {
|
|
149
|
+
sitting: RevisionSitting;
|
|
150
|
+
/** Map a wire field name to something a reader recognises. Identity by
|
|
151
|
+
* default — see `types.ts` on why this module never re-cases a name. */
|
|
152
|
+
fieldLabel?: (field: string) => string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionFieldsProps) {
|
|
156
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
157
|
+
const entries: Array<[string, FoldedFieldChange]> = [
|
|
158
|
+
...Object.keys(sitting.changed)
|
|
159
|
+
.sort()
|
|
160
|
+
.map((field) => [field, sitting.changed[field]!] as [string, FoldedFieldChange]),
|
|
161
|
+
// The name COLUMN, kept namespaced away from the data keys because a tenant
|
|
162
|
+
// may declare a data key literally called "name".
|
|
163
|
+
...(sitting.name ? ([['name (title)', sitting.name]] as Array<[string, FoldedFieldChange]>) : []),
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div className="space-y-2" data-testid="revision-fields">
|
|
168
|
+
{/* AN ATTEMPT IS NOT A CHANGE. A machine tried to write and
|
|
169
|
+
`human_edits.guard_machine_write` stopped it, so the record did not
|
|
170
|
+
move — and the whole value of this row is that the attempt is on the
|
|
171
|
+
record. It is listed separately from `changed` for that reason. */}
|
|
172
|
+
{sitting.preserved.length > 0 && (
|
|
173
|
+
<ul className="space-y-1" data-testid="revision-preserved">
|
|
174
|
+
{sitting.preserved.map((field) => (
|
|
175
|
+
<li key={field} className="text-xs text-muted-foreground">
|
|
176
|
+
<code className="rounded bg-muted px-1 text-foreground">{fieldLabel(field)}</code>{' '}
|
|
177
|
+
was not written — a person's edit was kept instead.
|
|
178
|
+
</li>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{sitting.regenerate.length > 0 && (
|
|
184
|
+
<p className="text-xs text-muted-foreground" data-testid="revision-regenerate">
|
|
185
|
+
Claimed permission to re-author{' '}
|
|
186
|
+
{sitting.regenerate.map((f) => fieldLabel(f)).join(', ')}.
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{entries.length > 0 && (
|
|
191
|
+
<ul className="divide-y divide-border rounded border border-border">
|
|
192
|
+
{entries.map(([field, change]) => {
|
|
193
|
+
const kind = classifyFieldChange(change);
|
|
194
|
+
const isOpen = open === field;
|
|
195
|
+
return (
|
|
196
|
+
<li key={field}>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
aria-expanded={isOpen}
|
|
200
|
+
onClick={() => setOpen(isOpen ? null : field)}
|
|
201
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent"
|
|
202
|
+
>
|
|
203
|
+
<ChevronRight
|
|
204
|
+
className={cn('h-3.5 w-3.5 shrink-0 transition-transform', isOpen && 'rotate-90')}
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
/>
|
|
207
|
+
<code className="min-w-0 truncate font-mono text-foreground">
|
|
208
|
+
{fieldLabel(field)}
|
|
209
|
+
</code>
|
|
210
|
+
<span className="ms-auto shrink-0 text-muted-foreground">{KIND_LABEL[kind]}</span>
|
|
211
|
+
</button>
|
|
212
|
+
{isOpen && (
|
|
213
|
+
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
|
214
|
+
<FieldBody field={field} change={change} />
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</li>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
</ul>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|