@startsimpli/ui 0.4.135 → 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 +4 -3
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.136",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./blog": "./src/components/blog/index.ts",
|
|
21
21
|
"./blog/resolve": "./src/components/blog/resolveArticle.ts",
|
|
22
22
|
"./activity": "./src/components/activity/index.ts",
|
|
23
|
+
"./history": "./src/components/history/index.ts",
|
|
23
24
|
"./collection": "./src/components/collection/index.ts",
|
|
24
25
|
"./scope": "./src/components/scope/index.ts",
|
|
25
26
|
"./facet-tree": "./src/components/facet-tree/index.ts",
|
|
@@ -123,8 +124,8 @@
|
|
|
123
124
|
"tailwindcss": "^3.4.19",
|
|
124
125
|
"tsup": "^8.5.1",
|
|
125
126
|
"typescript": "^6.0.3",
|
|
126
|
-
"@startsimpli/
|
|
127
|
-
"@startsimpli/
|
|
127
|
+
"@startsimpli/realtime": "0.0.2",
|
|
128
|
+
"@startsimpli/i18n": "0.1.3"
|
|
128
129
|
},
|
|
129
130
|
"keywords": [
|
|
130
131
|
"ui",
|
|
@@ -17,7 +17,7 @@ import { formatDistanceToNow } from 'date-fns';
|
|
|
17
17
|
import { Clock, User } from 'lucide-react';
|
|
18
18
|
import { EmptyState } from './states';
|
|
19
19
|
import { ActorIdentity, actorInitials, actorLabel } from './ActorIdentity';
|
|
20
|
-
import type { ActorIdentityValue } from './ActorIdentity';
|
|
20
|
+
import type { ActorIdentityValue, ActorKind, ActorKindSource } from './ActorIdentity';
|
|
21
21
|
|
|
22
22
|
/** Severity of an entry, for the marker. Presentation only — never a gate. */
|
|
23
23
|
export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
|
|
@@ -37,6 +37,12 @@ export interface ActivityTimelineItem {
|
|
|
37
37
|
// --- event-stream shape -------------------------------------------------
|
|
38
38
|
/** Identity of the person behind the event (preferred over `createdBy`). */
|
|
39
39
|
actor?: ActivityTimelineActor;
|
|
40
|
+
/** Whether a person or a MACHINE did it, where the stream records it
|
|
41
|
+
* (bd startsim-g4rwo). Forwarded straight to `ActorIdentity`, so a stream
|
|
42
|
+
* that does not record it renders exactly as before — and so a stream that
|
|
43
|
+
* does cannot show a robot's write as a colleague's. */
|
|
44
|
+
actorKind?: ActorKind;
|
|
45
|
+
actorKindSource?: ActorKindSource;
|
|
40
46
|
/** Colours the marker; `activityColors[type]` still wins when supplied. */
|
|
41
47
|
tone?: ActivityTone;
|
|
42
48
|
/** Muted inline qualifier after the title, e.g. "from provisioning". */
|
|
@@ -192,9 +198,16 @@ function TimelineEntry({
|
|
|
192
198
|
<p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
|
|
193
199
|
)}
|
|
194
200
|
{activity.detail && (
|
|
195
|
-
|
|
201
|
+
// A DIV, NOT A P. `detail` is typed ReactNode and the record
|
|
202
|
+
// history panel passes a field LIST into it — and a <ul> or a
|
|
203
|
+
// <div> inside a <p> is invalid HTML, which React resolves by
|
|
204
|
+
// reparenting during hydration. That produces a hydration
|
|
205
|
+
// mismatch on every row, and neither tsc nor jsdom reports it:
|
|
206
|
+
// only a real browser does (bd startsim-g4rwo). The classes are
|
|
207
|
+
// unchanged, so every existing caller renders identically.
|
|
208
|
+
<div className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
|
|
196
209
|
{activity.detail}
|
|
197
|
-
</
|
|
210
|
+
</div>
|
|
198
211
|
)}
|
|
199
212
|
</div>
|
|
200
213
|
<div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
|
|
@@ -203,7 +216,12 @@ function TimelineEntry({
|
|
|
203
216
|
</div>
|
|
204
217
|
|
|
205
218
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
206
|
-
<ActorIdentity
|
|
219
|
+
<ActorIdentity
|
|
220
|
+
actor={activity.actor}
|
|
221
|
+
showInitials={!markerIsAvatar}
|
|
222
|
+
kind={activity.actorKind}
|
|
223
|
+
kindSource={activity.actorKindSource}
|
|
224
|
+
/>
|
|
207
225
|
|
|
208
226
|
{!who && activity.createdBy && (
|
|
209
227
|
<span className="flex items-center gap-1">
|
|
@@ -16,7 +16,33 @@
|
|
|
16
16
|
* the address echoed back. Every surface that attributes something to a person
|
|
17
17
|
* renders this component rather than its own arrangement of the same two
|
|
18
18
|
* fields (rule #9).
|
|
19
|
+
*
|
|
20
|
+
* AND SINCE bd startsim-g4rwo, ONE WAY TO SAY "A MACHINE DID THIS" — the same
|
|
21
|
+
* component, because the question a reader asks is the same question and the
|
|
22
|
+
* answer has to be comparable. Pass `kind`/`kindSource`; see {@link kindMarker}
|
|
23
|
+
* for which states are decorated and the inversion that decides it. Both props
|
|
24
|
+
* are optional and omitting them renders exactly what this component rendered
|
|
25
|
+
* before they existed, which is what lets the surfaces already using it stay
|
|
26
|
+
* untouched.
|
|
27
|
+
*/
|
|
28
|
+
import { Bot } from 'lucide-react';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* WHAT the actor is — and the third value is NOT a defect (bd startsim-g4rwo,
|
|
32
|
+
* claim bd startsim-jkkn7.2).
|
|
33
|
+
*
|
|
34
|
+
* `unknown` means CENTRAL DID NOT SAY. The claim is minted at LOGIN and
|
|
35
|
+
* inherited across refreshes, so for up to the 14-day refresh expiry after
|
|
36
|
+
* rollout a GENUINE HUMAN EDIT carries no kind at all. Note precisely what is
|
|
37
|
+
* missing: the KIND, never the actor — the name and email are still there — so
|
|
38
|
+
* an unknown row can still say who, and only has to stay quiet about what.
|
|
19
39
|
*/
|
|
40
|
+
export type ActorKind = 'person' | 'machine' | 'unknown';
|
|
41
|
+
|
|
42
|
+
/** Whether {@link ActorKind} came from a verified claim or from an inference
|
|
43
|
+
* ladder over naming conventions. `inferred` is a GUESS, and a surface that
|
|
44
|
+
* says so is what makes a later cleanup possible instead of a second guess. */
|
|
45
|
+
export type ActorKindSource = 'claim' | 'inferred';
|
|
20
46
|
|
|
21
47
|
export interface ActorIdentityValue {
|
|
22
48
|
id?: string;
|
|
@@ -54,33 +80,147 @@ export function actorHandle(actor?: ActorIdentityValue): string {
|
|
|
54
80
|
return email && email !== actorLabel(actor) ? email : '';
|
|
55
81
|
}
|
|
56
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Whether this kind earns a visible marker, and what it should say.
|
|
85
|
+
*
|
|
86
|
+
* ── THE RULE, AND IT IS AN INVERSION OF THE OBVIOUS DESIGN ──────────────────
|
|
87
|
+
*
|
|
88
|
+
* The obvious design is three badges, one per state. It FAILS the requirement
|
|
89
|
+
* even though it renders all three states, because during the rollout window
|
|
90
|
+
* `unknown` is the COMMON case for real human edits — so a badge on every
|
|
91
|
+
* unknown row makes the whole panel look broken for two weeks on every tenant,
|
|
92
|
+
* and a reader who has been trained that the badges mean trouble stops reading
|
|
93
|
+
* the one badge that matters.
|
|
94
|
+
*
|
|
95
|
+
* So ABSENCE OF A MARKER IS THE CORRECT RENDERING FOR THE CALM CASES, and only
|
|
96
|
+
* the cases that genuinely carry information are decorated:
|
|
97
|
+
*
|
|
98
|
+
* - `machine` → ALWAYS marked, with an icon, and visually distinct
|
|
99
|
+
* from any person. This is the single most valuable
|
|
100
|
+
* thing in the epic: the repeated live failure was a
|
|
101
|
+
* machine overwriting a human, and 41 machine-written
|
|
102
|
+
* notes that could not be cleaned up afterwards
|
|
103
|
+
* "precisely because the data cannot say retrospectively
|
|
104
|
+
* which were human".
|
|
105
|
+
* - `person`/`claim` → NOTHING. A colleague edited the record; that is the
|
|
106
|
+
* unremarkable default and decorating it adds noise to
|
|
107
|
+
* the good case.
|
|
108
|
+
* - `person`/`inferred`→ marked, lightly, because the tenant GUESSED and the
|
|
109
|
+
* guess should be visible to whoever might act on it.
|
|
110
|
+
* - `unknown` → NOTHING on the row. The kind is unrecorded, which is
|
|
111
|
+
* not a fact about this edit worth shouting once per
|
|
112
|
+
* row; `RecordHistoryPanel` says it ONCE for the whole
|
|
113
|
+
* panel instead, and only when some loaded row is
|
|
114
|
+
* actually unknown.
|
|
115
|
+
*
|
|
116
|
+
* The truth is never *lost* in the quiet cases — it goes into the element's
|
|
117
|
+
* accessible title, so hover and assistive tech still report it.
|
|
118
|
+
*/
|
|
119
|
+
function kindMarker(
|
|
120
|
+
kind: ActorKind | undefined,
|
|
121
|
+
source: ActorKindSource | undefined,
|
|
122
|
+
): { label: string; icon: 'bot' | null; inferred: boolean } | null {
|
|
123
|
+
if (kind === 'machine') {
|
|
124
|
+
return { label: 'machine', icon: 'bot', inferred: source === 'inferred' };
|
|
125
|
+
}
|
|
126
|
+
if (kind === 'person' && source === 'inferred') {
|
|
127
|
+
return { label: 'person, inferred', icon: null, inferred: true };
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The full truth about the kind, for `title`/screen readers — including in the
|
|
133
|
+
* cases that render no marker, so "quiet" never means "undiscoverable". */
|
|
134
|
+
export function actorKindTitle(
|
|
135
|
+
kind: ActorKind | undefined,
|
|
136
|
+
source: ActorKindSource | undefined,
|
|
137
|
+
): string {
|
|
138
|
+
if (kind === 'machine') {
|
|
139
|
+
return source === 'claim'
|
|
140
|
+
? 'A machine made this change (declared by the sign-in service).'
|
|
141
|
+
: 'A machine made this change (inferred from the account, not declared).';
|
|
142
|
+
}
|
|
143
|
+
if (kind === 'person') {
|
|
144
|
+
return source === 'claim'
|
|
145
|
+
? 'A person made this change (declared by the sign-in service).'
|
|
146
|
+
: 'A person made this change (inferred from the account, not declared).';
|
|
147
|
+
}
|
|
148
|
+
return 'Whether a person or a machine made this change was not recorded.';
|
|
149
|
+
}
|
|
150
|
+
|
|
57
151
|
export interface ActorIdentityProps {
|
|
58
152
|
actor?: ActorIdentityValue;
|
|
59
153
|
/** Show the initials avatar. Off for a compact one-line attribution, or where
|
|
60
154
|
* the surrounding row already paints the same initials. */
|
|
61
155
|
showInitials?: boolean;
|
|
156
|
+
/** Person, machine, or not recorded. Omitted on every surface that predates
|
|
157
|
+
* the revision trail, which is why it is optional and why omitting it
|
|
158
|
+
* renders byte-identically to before. */
|
|
159
|
+
kind?: ActorKind;
|
|
160
|
+
/** Whether {@link kind} was claimed or guessed. */
|
|
161
|
+
kindSource?: ActorKindSource;
|
|
62
162
|
className?: string;
|
|
63
163
|
initialsClassName?: string;
|
|
64
164
|
handleClassName?: string;
|
|
165
|
+
kindClassName?: string;
|
|
65
166
|
}
|
|
66
167
|
|
|
67
168
|
export function ActorIdentity({
|
|
68
169
|
actor,
|
|
69
170
|
showInitials = true,
|
|
171
|
+
kind,
|
|
172
|
+
kindSource,
|
|
70
173
|
className = 'flex flex-wrap items-center gap-x-1.5',
|
|
71
174
|
initialsClassName = 'flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground',
|
|
72
175
|
handleClassName = 'text-xs text-muted-foreground/80',
|
|
176
|
+
kindClassName,
|
|
73
177
|
}: ActorIdentityProps) {
|
|
74
178
|
const label = actorLabel(actor);
|
|
75
|
-
|
|
76
|
-
|
|
179
|
+
const marker = kindMarker(kind, kindSource);
|
|
180
|
+
// An empty label used to be the signal to render nothing at all, rather than
|
|
181
|
+
// a dangling "by" — and it still is, UNLESS there is a kind worth marking. A
|
|
182
|
+
// service token can carry no email whatsoever, and dropping that row's actor
|
|
183
|
+
// entirely would hide the one fact the actor axis exists to record: that a
|
|
184
|
+
// machine, not a colleague, wrote here.
|
|
185
|
+
if (!label && !marker) return null;
|
|
186
|
+
const initials = showInitials && label ? actorInitials(actor) : '';
|
|
77
187
|
const handle = actorHandle(actor);
|
|
78
188
|
|
|
79
189
|
return (
|
|
80
|
-
<span
|
|
190
|
+
<span
|
|
191
|
+
data-testid="actor-identity"
|
|
192
|
+
data-actor-kind={kind ?? undefined}
|
|
193
|
+
data-actor-kind-source={kindSource ?? undefined}
|
|
194
|
+
className={className}
|
|
195
|
+
>
|
|
81
196
|
{initials && <span className={initialsClassName}>{initials}</span>}
|
|
82
|
-
<span>{label}</span>
|
|
197
|
+
{label && <span>{label}</span>}
|
|
83
198
|
{handle && <span className={handleClassName}>{handle}</span>}
|
|
199
|
+
{marker && (
|
|
200
|
+
<span
|
|
201
|
+
data-testid="actor-kind"
|
|
202
|
+
// NOT a warning colour. This is a statement of fact about who typed,
|
|
203
|
+
// and the dark-tenant palette reserves red for failure; a machine
|
|
204
|
+
// edit is routine and must not read as an incident. The icon, not a
|
|
205
|
+
// colour, is what makes it scannable — and `inferred` is drawn with a
|
|
206
|
+
// DASHED border and no fill, so "we guessed" is lighter on the page
|
|
207
|
+
// than "we were told" without needing a second colour.
|
|
208
|
+
className={
|
|
209
|
+
kindClassName ??
|
|
210
|
+
[
|
|
211
|
+
'inline-flex items-center gap-1 rounded border px-1.5 py-px',
|
|
212
|
+
'text-[10px] font-medium uppercase tracking-wide',
|
|
213
|
+
marker.inferred
|
|
214
|
+
? 'border-dashed border-border text-muted-foreground'
|
|
215
|
+
: 'border-border bg-secondary text-secondary-foreground',
|
|
216
|
+
].join(' ')
|
|
217
|
+
}
|
|
218
|
+
title={actorKindTitle(kind, kindSource)}
|
|
219
|
+
>
|
|
220
|
+
{marker.icon === 'bot' && <Bot className="h-3 w-3" aria-hidden="true" />}
|
|
221
|
+
{marker.label}
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
84
224
|
</span>
|
|
85
225
|
);
|
|
86
226
|
}
|
|
@@ -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
|
+
}
|