@startsimpli/ui 0.4.141 → 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 +3 -3
- 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
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* reason, rather than showing a deletion that never happened.
|
|
27
27
|
*/
|
|
28
28
|
import { useState } from 'react';
|
|
29
|
-
import { ChevronRight, EyeOff, Scissors } from 'lucide-react';
|
|
29
|
+
import { ChevronRight, EyeOff, History, Scissors, Undo2 } from 'lucide-react';
|
|
30
30
|
|
|
31
31
|
import { cn } from '../../lib/utils';
|
|
32
32
|
import { DiffViewer } from '../diff';
|
|
@@ -53,6 +53,9 @@ function preview(value: unknown): string {
|
|
|
53
53
|
|
|
54
54
|
const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
|
|
55
55
|
|
|
56
|
+
const RESTORE_BUTTON =
|
|
57
|
+
'inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground transition-colors hover:bg-accent';
|
|
58
|
+
|
|
56
59
|
/** One side of a change. ABSENCE IS RENDERED AS ABSENCE — an em dash with
|
|
57
60
|
* screen-reader text — never as an empty box that looks like "" was stored. */
|
|
58
61
|
function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
|
|
@@ -156,9 +159,26 @@ export interface RevisionFieldsProps {
|
|
|
156
159
|
/** Map a wire field name to something a reader recognises. Identity by
|
|
157
160
|
* default — see `types.ts` on why this module never re-cases a name. */
|
|
158
161
|
fieldLabel?: (field: string) => string;
|
|
162
|
+
/** Offer "Restore this value" on this field (bd startsim-zkyk4). The panel
|
|
163
|
+
* answers false wherever a restore would change nothing — on the row whose
|
|
164
|
+
* value IS the current one — or would be refused. */
|
|
165
|
+
canRestoreField?: (field: string) => boolean;
|
|
166
|
+
onRestoreField?: (field: string) => void;
|
|
167
|
+
/** Offer "Restore this version" — the whole record as it stood after this
|
|
168
|
+
* sitting. Omitted where it would be a no-op or the trail is partial. */
|
|
169
|
+
onRestoreVersion?: () => void;
|
|
170
|
+
/** "Show only this field's history" — the panel's ?field= narrowing. */
|
|
171
|
+
onNarrowToField?: (field: string) => void;
|
|
159
172
|
}
|
|
160
173
|
|
|
161
|
-
export function RevisionFields({
|
|
174
|
+
export function RevisionFields({
|
|
175
|
+
sitting,
|
|
176
|
+
fieldLabel = (f) => f,
|
|
177
|
+
canRestoreField,
|
|
178
|
+
onRestoreField,
|
|
179
|
+
onRestoreVersion,
|
|
180
|
+
onNarrowToField,
|
|
181
|
+
}: RevisionFieldsProps) {
|
|
162
182
|
const [open, setOpen] = useState<string | null>(null);
|
|
163
183
|
const entries: Array<[string, FoldedFieldChange]> = [
|
|
164
184
|
...Object.keys(sitting.changed)
|
|
@@ -216,8 +236,40 @@ export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionField
|
|
|
216
236
|
<span className="ms-auto shrink-0 text-muted-foreground">{KIND_LABEL[kind]}</span>
|
|
217
237
|
</button>
|
|
218
238
|
{isOpen && (
|
|
219
|
-
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
|
239
|
+
<div className="space-y-2 border-t border-border bg-muted/20 px-3 py-2">
|
|
220
240
|
<FieldBody field={field} change={change} />
|
|
241
|
+
{(onNarrowToField ||
|
|
242
|
+
(onRestoreField &&
|
|
243
|
+
field !== 'name (title)' &&
|
|
244
|
+
kind !== 'withheld' &&
|
|
245
|
+
(canRestoreField?.(field) ?? true))) && (
|
|
246
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
247
|
+
{onRestoreField &&
|
|
248
|
+
field !== 'name (title)' &&
|
|
249
|
+
kind !== 'withheld' &&
|
|
250
|
+
(canRestoreField?.(field) ?? true) && (
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
className={RESTORE_BUTTON}
|
|
254
|
+
data-testid={`revision-restore-field-${field}`}
|
|
255
|
+
onClick={() => onRestoreField(field)}
|
|
256
|
+
>
|
|
257
|
+
<Undo2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
258
|
+
Restore this value
|
|
259
|
+
</button>
|
|
260
|
+
)}
|
|
261
|
+
{onNarrowToField && field !== 'name (title)' && (
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
265
|
+
onClick={() => onNarrowToField(field)}
|
|
266
|
+
>
|
|
267
|
+
<History className="h-3.5 w-3.5" aria-hidden="true" />
|
|
268
|
+
Only this field's history
|
|
269
|
+
</button>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
221
273
|
</div>
|
|
222
274
|
)}
|
|
223
275
|
</li>
|
|
@@ -225,6 +277,20 @@ export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionField
|
|
|
225
277
|
})}
|
|
226
278
|
</ul>
|
|
227
279
|
)}
|
|
280
|
+
|
|
281
|
+
{onRestoreVersion && (
|
|
282
|
+
<div>
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
className={RESTORE_BUTTON}
|
|
286
|
+
data-testid="revision-restore-version"
|
|
287
|
+
onClick={onRestoreVersion}
|
|
288
|
+
>
|
|
289
|
+
<Undo2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
290
|
+
Restore this version
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
228
294
|
</div>
|
|
229
295
|
);
|
|
230
296
|
}
|
|
@@ -77,6 +77,21 @@ export interface StaleSaveDialogProps {
|
|
|
77
77
|
fieldLabel?: (field: string) => string;
|
|
78
78
|
/** Extra content, below the fields and above the footer. */
|
|
79
79
|
children?: ReactNode;
|
|
80
|
+
/**
|
|
81
|
+
* COPY FOR A CONFLICT THAT IS NOT AN EDITOR'S (bd startsim-zkyk4). The defaults
|
|
82
|
+
* are the draft editor's words — "your edits are still here" — which are true
|
|
83
|
+
* there and false for a refused RESTORE, where nothing was typed. A host that
|
|
84
|
+
* reuses this dialog for another kind of write says what that write was; the
|
|
85
|
+
* structure, the safe default focus and the one path that adopts the server's
|
|
86
|
+
* version are unchanged.
|
|
87
|
+
*/
|
|
88
|
+
reassurance?: ReactNode;
|
|
89
|
+
/** "while you were editing" — the clause after "<actor> changed <record>". */
|
|
90
|
+
whilePhrase?: string;
|
|
91
|
+
dismissLabel?: string;
|
|
92
|
+
reviewLabel?: string;
|
|
93
|
+
keepMineLabel?: string;
|
|
94
|
+
acceptTheirsLabel?: string;
|
|
80
95
|
classNames?: StaleSaveDialogClassNames;
|
|
81
96
|
}
|
|
82
97
|
|
|
@@ -119,6 +134,12 @@ export function StaleSaveDialog({
|
|
|
119
134
|
fieldLabel = (field) => field,
|
|
120
135
|
children,
|
|
121
136
|
classNames,
|
|
137
|
+
reassurance = 'Your edits are still here and have not been saved.',
|
|
138
|
+
whilePhrase = 'while you were editing',
|
|
139
|
+
dismissLabel = 'Keep editing',
|
|
140
|
+
reviewLabel = 'See what changed',
|
|
141
|
+
keepMineLabel = 'Save mine, replacing their change',
|
|
142
|
+
acceptTheirsLabel = 'Discard my edits and load theirs',
|
|
122
143
|
}: StaleSaveDialogProps) {
|
|
123
144
|
const css = { ...STALE_SAVE_DIALOG_DEFAULTS, ...classNames };
|
|
124
145
|
const focusTarget = useRef<HTMLButtonElement | null>(null);
|
|
@@ -175,18 +196,18 @@ export function StaleSaveDialog({
|
|
|
175
196
|
{...(actor?.actorKind ? { kind: actor.actorKind } : {})}
|
|
176
197
|
{...(actor?.actorKindSource ? { kindSource: actor.actorKindSource } : {})}
|
|
177
198
|
/>
|
|
178
|
-
<span>changed {recordLabel}
|
|
199
|
+
<span>changed {recordLabel} {whilePhrase}.</span>
|
|
179
200
|
</>
|
|
180
201
|
) : (
|
|
181
202
|
/* NEVER "someone", and never a guess at a machine. The record
|
|
182
203
|
changed; who is a separate question this dialog answers only
|
|
183
204
|
when the trail actually said. */
|
|
184
|
-
<span>{`${recordLabel.charAt(0).toUpperCase()}${recordLabel.slice(1)} changed
|
|
205
|
+
<span>{`${recordLabel.charAt(0).toUpperCase()}${recordLabel.slice(1)} changed ${whilePhrase}.`}</span>
|
|
185
206
|
)}
|
|
186
207
|
</h2>
|
|
187
208
|
{/* THE LOAD-BEARING SENTENCE, and it comes first for that reason. */}
|
|
188
209
|
<p className={css.reassurance} data-testid="stale-save-reassurance">
|
|
189
|
-
|
|
210
|
+
{reassurance}
|
|
190
211
|
</p>
|
|
191
212
|
</div>
|
|
192
213
|
|
|
@@ -234,7 +255,7 @@ export function StaleSaveDialog({
|
|
|
234
255
|
disabled={busy}
|
|
235
256
|
onClick={onAcceptTheirs}
|
|
236
257
|
>
|
|
237
|
-
|
|
258
|
+
{acceptTheirsLabel}
|
|
238
259
|
</button>
|
|
239
260
|
)}
|
|
240
261
|
<button
|
|
@@ -246,7 +267,7 @@ export function StaleSaveDialog({
|
|
|
246
267
|
data-testid="stale-save-dismiss"
|
|
247
268
|
onClick={onDismiss}
|
|
248
269
|
>
|
|
249
|
-
|
|
270
|
+
{dismissLabel}
|
|
250
271
|
</button>
|
|
251
272
|
{onReviewChange && (
|
|
252
273
|
<button
|
|
@@ -256,7 +277,7 @@ export function StaleSaveDialog({
|
|
|
256
277
|
data-testid="stale-save-review"
|
|
257
278
|
onClick={onReviewChange}
|
|
258
279
|
>
|
|
259
|
-
|
|
280
|
+
{reviewLabel}
|
|
260
281
|
</button>
|
|
261
282
|
)}
|
|
262
283
|
{onKeepMine && (
|
|
@@ -267,7 +288,7 @@ export function StaleSaveDialog({
|
|
|
267
288
|
disabled={busy}
|
|
268
289
|
onClick={onKeepMine}
|
|
269
290
|
>
|
|
270
|
-
{busy ? 'Saving…' :
|
|
291
|
+
{busy ? 'Saving…' : keepMineLabel}
|
|
271
292
|
</button>
|
|
272
293
|
)}
|
|
273
294
|
</div>
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RED for bd startsim-vjhbw — "edited by X · 2h ago" beside a field, over
|
|
3
|
+
* `GET /entities/<id>/field-authors/` (server bd startsim-6sso4).
|
|
4
|
+
*
|
|
5
|
+
* The three readings that must never be confused:
|
|
6
|
+
* - an ENTRY: somebody (or something) last wrote this field — say who, mark a
|
|
7
|
+
* machine, and open that field's history on click;
|
|
8
|
+
* - NO ENTRY: not edited SINCE THE TRAIL BEGAN. Live rows predate the trail, so
|
|
9
|
+
* this is never "never edited";
|
|
10
|
+
* - HISTORY OFF (`recorded: false`, `fields: null`): we do not know, and we say
|
|
11
|
+
* that — NOT "nobody edited".
|
|
12
|
+
*/
|
|
13
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
14
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
FieldAttribution,
|
|
18
|
+
FieldAuthorsNotice,
|
|
19
|
+
fieldAuthorOf,
|
|
20
|
+
normalizeFieldAuthors,
|
|
21
|
+
useFieldAuthors,
|
|
22
|
+
} from '../index'
|
|
23
|
+
import type { FieldAuthorsResponse } from '../types'
|
|
24
|
+
|
|
25
|
+
const NOW = Date.parse('2026-10-07T12:00:00.000Z')
|
|
26
|
+
|
|
27
|
+
const recorded: FieldAuthorsResponse = {
|
|
28
|
+
entity_id: 'e1',
|
|
29
|
+
version: 7,
|
|
30
|
+
history: { enabled: true, declared_by: 'default' },
|
|
31
|
+
recorded: true,
|
|
32
|
+
trail_starts: { version: 3, at: '2026-10-01T09:00:00.000Z', from_creation: false },
|
|
33
|
+
fields: {
|
|
34
|
+
blog: {
|
|
35
|
+
version: 7,
|
|
36
|
+
at: new Date(NOW - 2 * 3_600_000).toISOString(),
|
|
37
|
+
actor_sub: 'u-jurga',
|
|
38
|
+
actor_label: 'jurga@ogmc.example',
|
|
39
|
+
actor_kind: 'person',
|
|
40
|
+
actor_kind_source: 'claim',
|
|
41
|
+
source: 'patch',
|
|
42
|
+
},
|
|
43
|
+
team_verdict: {
|
|
44
|
+
version: 6,
|
|
45
|
+
at: new Date(NOW - 3_600_000).toISOString(),
|
|
46
|
+
actor_sub: 'svc:n8n-ogmc',
|
|
47
|
+
actor_label: 'svc:n8n-ogmc',
|
|
48
|
+
actor_kind: 'machine',
|
|
49
|
+
actor_kind_source: 'claim',
|
|
50
|
+
source: 'upsert',
|
|
51
|
+
},
|
|
52
|
+
title: {
|
|
53
|
+
version: 5,
|
|
54
|
+
at: new Date(NOW - 60_000).toISOString(),
|
|
55
|
+
actor_sub: 'u-alex',
|
|
56
|
+
actor_label: 'alex@ogmc.example',
|
|
57
|
+
actor_kind: 'person',
|
|
58
|
+
actor_kind_source: 'claim',
|
|
59
|
+
source: 'restore',
|
|
60
|
+
restored_from: 2,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
name: null,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const off: FieldAuthorsResponse = {
|
|
67
|
+
entity_id: 'e1',
|
|
68
|
+
version: 4,
|
|
69
|
+
history: { enabled: false, declared_by: 'type' },
|
|
70
|
+
recorded: false,
|
|
71
|
+
trail_starts: null,
|
|
72
|
+
fields: null,
|
|
73
|
+
name: null,
|
|
74
|
+
detail: 'History is switched off for records of this type, so field authors are not recorded.',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
beforeAll(() => {
|
|
78
|
+
jest.useFakeTimers({ now: NOW, doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'queueMicrotask', 'nextTick', 'setImmediate', 'clearImmediate'] })
|
|
79
|
+
})
|
|
80
|
+
afterAll(() => jest.useRealTimers())
|
|
81
|
+
|
|
82
|
+
describe('normalizeFieldAuthors — either spelling, failing closed on kind', () => {
|
|
83
|
+
it('reads snake and camel alike', () => {
|
|
84
|
+
const camel = {
|
|
85
|
+
entityId: 'e1',
|
|
86
|
+
version: 2,
|
|
87
|
+
recorded: true,
|
|
88
|
+
trailStarts: { version: 1, at: '2026-10-01T00:00:00Z', fromCreation: true },
|
|
89
|
+
fields: { blog: { version: 2, at: '2026-10-02T00:00:00Z', actorSub: 'u', actorLabel: 'a@b.c', actorKind: 'person', actorKindSource: 'claim', source: 'patch' } },
|
|
90
|
+
}
|
|
91
|
+
const n = normalizeFieldAuthors(camel as never)
|
|
92
|
+
expect(n.recorded).toBe(true)
|
|
93
|
+
expect(n.trailStarts).toMatchObject({ version: 1, fromCreation: true })
|
|
94
|
+
expect(fieldAuthorOf(n, 'blog')).toMatchObject({ actorLabel: 'a@b.c', actorKind: 'person' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('an unrecognised kind reads unknown — never person', () => {
|
|
98
|
+
const n = normalizeFieldAuthors({
|
|
99
|
+
...recorded,
|
|
100
|
+
fields: { blog: { ...recorded.fields!.blog!, actor_kind: 'robot' as never } },
|
|
101
|
+
})
|
|
102
|
+
expect(fieldAuthorOf(n, 'blog')?.actorKind).toBe('unknown')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('history off is NOT the same as an empty field map', () => {
|
|
106
|
+
const n = normalizeFieldAuthors(off)
|
|
107
|
+
expect(n.recorded).toBe(false)
|
|
108
|
+
expect(fieldAuthorOf(n, 'blog')).toBeUndefined()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('FieldAttribution — an entry', () => {
|
|
113
|
+
it('names the person and how long ago', () => {
|
|
114
|
+
render(<FieldAttribution authors={recorded} field="blog" />)
|
|
115
|
+
const el = screen.getByTestId('field-attribution-blog')
|
|
116
|
+
expect(el).toHaveTextContent(/jurga@ogmc\.example/)
|
|
117
|
+
expect(el).toHaveTextContent(/2 hours ago/)
|
|
118
|
+
expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('ALWAYS marks a machine, through ActorIdentity', () => {
|
|
122
|
+
render(<FieldAttribution authors={recorded} field="team_verdict" />)
|
|
123
|
+
expect(screen.getByTestId('actor-kind')).toHaveTextContent(/machine/)
|
|
124
|
+
// ActorIdentity's accessible title, not a second vocabulary.
|
|
125
|
+
expect(screen.getByTestId('actor-identity')).toHaveAttribute('title', expect.stringMatching(/machine made this change/i))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('says a restore was a restore, and from which version', () => {
|
|
129
|
+
render(<FieldAttribution authors={recorded} field="title" />)
|
|
130
|
+
expect(screen.getByTestId('field-attribution-title')).toHaveTextContent(/restored.*v2/i)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('clicking opens that field’s history', () => {
|
|
134
|
+
const onOpenHistory = jest.fn()
|
|
135
|
+
render(<FieldAttribution authors={recorded} field="blog" onOpenHistory={onOpenHistory} />)
|
|
136
|
+
fireEvent.click(screen.getByRole('button', { name: /history of blog/i }))
|
|
137
|
+
expect(onOpenHistory).toHaveBeenCalledWith('blog')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('is plain text, not a button, when the host offers no history', () => {
|
|
141
|
+
render(<FieldAttribution authors={recorded} field="blog" />)
|
|
142
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('links to the actor’s recent edits when the host gives a URL for them', () => {
|
|
146
|
+
render(<FieldAttribution authors={recorded} field="blog" actorHref={(sub) => `/activity?actor=${sub}`} />)
|
|
147
|
+
expect(screen.getByRole('link', { name: /their recent edits/i })).toHaveAttribute('href', '/activity?actor=u-jurga')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('FieldAttribution — no entry', () => {
|
|
152
|
+
it('says "not edited since history began", with the date — never "never edited"', () => {
|
|
153
|
+
render(<FieldAttribution authors={recorded} field="angle" />)
|
|
154
|
+
const el = screen.getByTestId('field-attribution-angle')
|
|
155
|
+
expect(el).toHaveTextContent(/not edited since/i)
|
|
156
|
+
expect(el.textContent).not.toMatch(/never/i)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('says "since it was created" when the trail began at creation', () => {
|
|
160
|
+
render(
|
|
161
|
+
<FieldAttribution
|
|
162
|
+
authors={{ ...recorded, trail_starts: { version: 1, at: '2026-10-01T09:00:00Z', from_creation: true } }}
|
|
163
|
+
field="angle"
|
|
164
|
+
/>,
|
|
165
|
+
)
|
|
166
|
+
expect(screen.getByTestId('field-attribution-angle')).toHaveTextContent(/not edited since it was created/i)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('FieldAttribution — history off', () => {
|
|
171
|
+
it('says it is not recorded, and does not claim nobody edited it', () => {
|
|
172
|
+
render(<FieldAttribution authors={off} field="blog" />)
|
|
173
|
+
const el = screen.getByTestId('field-attribution-blog')
|
|
174
|
+
expect(el).toHaveTextContent(/not recorded/i)
|
|
175
|
+
expect(el.textContent).not.toMatch(/not edited|nobody|never/i)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('FieldAuthorsNotice repeats the server sentence once', () => {
|
|
179
|
+
render(<FieldAuthorsNotice authors={off} />)
|
|
180
|
+
expect(screen.getByTestId('field-authors-notice')).toHaveTextContent(/switched off/)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('FieldAuthorsNotice says nothing when history is recorded', () => {
|
|
184
|
+
const { container } = render(<FieldAuthorsNotice authors={recorded} />)
|
|
185
|
+
expect(container).toBeEmptyDOMElement()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('FieldAttribution — loading', () => {
|
|
190
|
+
it('renders nothing until the authors arrive', () => {
|
|
191
|
+
const { container } = render(<FieldAttribution authors={undefined} field="blog" />)
|
|
192
|
+
expect(container).toBeEmptyDOMElement()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('useFieldAuthors — injected client, cached per key', () => {
|
|
197
|
+
function Probe({ get }: { get: () => Promise<FieldAuthorsResponse> }) {
|
|
198
|
+
const q = useFieldAuthors({ client: { get }, queryKey: ['entity', 'e1', 'field-authors', 7] })
|
|
199
|
+
return <FieldAttribution authors={q.data} field="blog" />
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it('fetches once and renders through FieldAttribution', async () => {
|
|
203
|
+
const get = jest.fn().mockResolvedValue(recorded)
|
|
204
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
205
|
+
render(
|
|
206
|
+
<QueryClientProvider client={qc}>
|
|
207
|
+
<Probe get={get} />
|
|
208
|
+
</QueryClientProvider>,
|
|
209
|
+
)
|
|
210
|
+
await waitFor(() => expect(screen.getByTestId('field-attribution-blog')).toHaveTextContent(/jurga/))
|
|
211
|
+
expect(get).toHaveBeenCalledTimes(1)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RED for bd startsim-ucg5u — everything one person (or one agent) changed,
|
|
3
|
+
* across records, over `GET /api/v1/revisions/` (server bd startsim-ivr3n),
|
|
4
|
+
* composed on the shared ActivityFeedPanel rather than a second feed.
|
|
5
|
+
*
|
|
6
|
+
* Contract traps this pins:
|
|
7
|
+
* - cursor paging, no count, never `?page=`;
|
|
8
|
+
* - a blank actor/scope is a 400, so empty filters are OMITTED, not sent blank;
|
|
9
|
+
* - filters are the host's (the URL), the panel only reports changes;
|
|
10
|
+
* - the person filter's value is the actor's SUB, the label is their address;
|
|
11
|
+
* - adjacent saves fold only within ONE record — two records never merge.
|
|
12
|
+
*/
|
|
13
|
+
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
|
14
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
15
|
+
|
|
16
|
+
import { RecordActivityPanel, foldFeed, revisionFeedParams } from '../index'
|
|
17
|
+
import type { RevisionFeedRow } from '../types'
|
|
18
|
+
|
|
19
|
+
function mount(ui: React.ReactElement) {
|
|
20
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
|
|
21
|
+
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const T0 = Date.parse('2026-10-07T12:00:00.000Z')
|
|
25
|
+
const at = (ms: number) => new Date(T0 + ms).toISOString()
|
|
26
|
+
|
|
27
|
+
function row(id: string, entity: { id: string; type: string; name: string }, over: Partial<RevisionFeedRow> = {}): RevisionFeedRow {
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
version: 2,
|
|
31
|
+
action: 'updated',
|
|
32
|
+
source: 'patch',
|
|
33
|
+
created_at: at(0),
|
|
34
|
+
actor_sub: 'u-jurga',
|
|
35
|
+
actor_label: 'jurga@ogmc.example',
|
|
36
|
+
actor_kind: 'person',
|
|
37
|
+
actor_kind_source: 'claim',
|
|
38
|
+
metadata: { changed: { blog: { before: 'old', after: 'new' } } },
|
|
39
|
+
entity,
|
|
40
|
+
...over,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const draftA = { id: 'd-a', type: 'draft', name: 'Gulf logistics brief' }
|
|
45
|
+
const draftB = { id: 'd-b', type: 'draft', name: 'Riyadh expansion' }
|
|
46
|
+
|
|
47
|
+
describe('revisionFeedParams — only what the route accepts, never blank', () => {
|
|
48
|
+
it('omits blank values and maps nothing it does not know', () => {
|
|
49
|
+
expect(
|
|
50
|
+
revisionFeedParams({ actor: '', actor_kind: 'machine', type: 'draft', scope: ' ', since: '2026-10-05T00:00:00.000Z' }),
|
|
51
|
+
).toEqual({ actor_kind: 'machine', type: 'draft', since: '2026-10-05T00:00:00.000Z' })
|
|
52
|
+
expect(revisionFeedParams({ q: 'x', page: '2' } as never)).toEqual({})
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('foldFeed — sittings never cross records', () => {
|
|
57
|
+
it('folds adjacent saves on the SAME record by the same actor', () => {
|
|
58
|
+
const rows = [
|
|
59
|
+
row('3', draftA, { version: 3, created_at: at(60_000) }),
|
|
60
|
+
row('2', draftA, { version: 2, created_at: at(0) }),
|
|
61
|
+
]
|
|
62
|
+
const out = foldFeed(rows)
|
|
63
|
+
expect(out).toHaveLength(1)
|
|
64
|
+
expect(out[0]!.sitting.saves).toBe(2)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('never folds two different records together, however close', () => {
|
|
68
|
+
const rows = [
|
|
69
|
+
row('3', draftA, { version: 3, created_at: at(1000) }),
|
|
70
|
+
row('2', draftB, { version: 2, created_at: at(0) }),
|
|
71
|
+
]
|
|
72
|
+
expect(foldFeed(rows)).toHaveLength(2)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('RecordActivityPanel', () => {
|
|
77
|
+
it('reads by cursor, with only the non-blank filters, and links each row to its record', async () => {
|
|
78
|
+
const list = jest.fn().mockResolvedValue({ next: null, next_cursor: null, results: [row('1', draftA)] })
|
|
79
|
+
mount(
|
|
80
|
+
<RecordActivityPanel
|
|
81
|
+
client={{ list }}
|
|
82
|
+
filters={{ actor: 'u-jurga', actor_kind: '' }}
|
|
83
|
+
onFiltersChange={jest.fn()}
|
|
84
|
+
recordHref={(e) => `/draft/${e.id}`}
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
const link = await screen.findByRole('link', { name: /Gulf logistics brief/ })
|
|
88
|
+
expect(link).toHaveAttribute('href', '/draft/d-a')
|
|
89
|
+
const sent = list.mock.calls[0][0]
|
|
90
|
+
expect(sent).toMatchObject({ actor: 'u-jurga' })
|
|
91
|
+
expect(sent).not.toHaveProperty('actor_kind')
|
|
92
|
+
expect(sent).not.toHaveProperty('page')
|
|
93
|
+
expect(screen.queryByText(/showing .* of/i)).not.toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('marks a machine row, and leaves a claimed person calm', async () => {
|
|
97
|
+
const list = jest.fn().mockResolvedValue({
|
|
98
|
+
next: null,
|
|
99
|
+
results: [
|
|
100
|
+
row('2', draftA, { actor_sub: 'svc:n8n-ogmc', actor_label: 'svc:n8n-ogmc', actor_kind: 'machine', created_at: at(3_600_000) }),
|
|
101
|
+
row('1', draftB),
|
|
102
|
+
],
|
|
103
|
+
})
|
|
104
|
+
mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={jest.fn()} />)
|
|
105
|
+
const marks = await screen.findAllByTestId('actor-kind')
|
|
106
|
+
expect(marks).toHaveLength(1)
|
|
107
|
+
expect(marks[0]).toHaveTextContent(/machine/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('names the fields changed, and the inline diff opens on expand', async () => {
|
|
111
|
+
const list = jest.fn().mockResolvedValue({ next: null, results: [row('1', draftA)] })
|
|
112
|
+
mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={jest.fn()} />)
|
|
113
|
+
expect(await screen.findByText(/Edited blog/)).toBeInTheDocument()
|
|
114
|
+
fireEvent.click(screen.getByRole('button', { name: /blog/ }))
|
|
115
|
+
expect(await screen.findByText('Before')).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('the person filter offers subs as values and addresses as labels', async () => {
|
|
119
|
+
const onFiltersChange = jest.fn()
|
|
120
|
+
const list = jest.fn().mockResolvedValue({ next: null, results: [row('1', draftA)] })
|
|
121
|
+
mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={onFiltersChange} />)
|
|
122
|
+
await screen.findByText(/Edited blog/)
|
|
123
|
+
const person = screen.getByLabelText(/person/i) as HTMLSelectElement
|
|
124
|
+
const opt = within(person).getByRole('option', { name: 'jurga@ogmc.example' }) as HTMLOptionElement
|
|
125
|
+
expect(opt.value).toBe('u-jurga')
|
|
126
|
+
fireEvent.change(person, { target: { value: 'u-jurga' } })
|
|
127
|
+
expect(onFiltersChange).toHaveBeenCalledWith({ actor: 'u-jurga' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('person vs machine is a filter of its own', async () => {
|
|
131
|
+
const onFiltersChange = jest.fn()
|
|
132
|
+
const list = jest.fn().mockResolvedValue({ next: null, results: [] })
|
|
133
|
+
mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={onFiltersChange} />)
|
|
134
|
+
fireEvent.change(await screen.findByLabelText(/made by/i), { target: { value: 'machine' } })
|
|
135
|
+
expect(onFiltersChange).toHaveBeenCalledWith({ actor_kind: 'machine' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('record type comes from the host options', async () => {
|
|
139
|
+
const onFiltersChange = jest.fn()
|
|
140
|
+
const list = jest.fn().mockResolvedValue({ next: null, results: [] })
|
|
141
|
+
mount(
|
|
142
|
+
<RecordActivityPanel
|
|
143
|
+
client={{ list }}
|
|
144
|
+
filters={{}}
|
|
145
|
+
onFiltersChange={onFiltersChange}
|
|
146
|
+
typeOptions={[{ value: 'topic', label: 'Topics' }, { value: 'draft', label: 'Drafts' }]}
|
|
147
|
+
/>,
|
|
148
|
+
)
|
|
149
|
+
fireEvent.change(await screen.findByLabelText(/record type/i), { target: { value: 'draft' } })
|
|
150
|
+
expect(onFiltersChange).toHaveBeenCalledWith({ type: 'draft' })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('"this week" sets since to the start of the week', async () => {
|
|
154
|
+
const onFiltersChange = jest.fn()
|
|
155
|
+
const list = jest.fn().mockResolvedValue({ next: null, results: [] })
|
|
156
|
+
mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={onFiltersChange} />)
|
|
157
|
+
fireEvent.click(await screen.findByRole('button', { name: /this week/i }))
|
|
158
|
+
const arg = onFiltersChange.mock.calls[0][0]
|
|
159
|
+
// A local Monday midnight, as an instant.
|
|
160
|
+
expect(new Date(arg.since).getDay()).toBe(1)
|
|
161
|
+
expect(new Date(arg.since).getHours()).toBe(0)
|
|
162
|
+
expect(new Date(arg.since).getTime()).toBeLessThanOrEqual(Date.now())
|
|
163
|
+
expect(Date.now() - new Date(arg.since).getTime()).toBeLessThan(8 * 86_400_000)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('"only their edits" narrows to that actor', async () => {
|
|
167
|
+
const onFiltersChange = jest.fn()
|
|
168
|
+
const list = jest.fn().mockResolvedValue({ next: null, results: [row('1', draftA)] })
|
|
169
|
+
mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={onFiltersChange} />)
|
|
170
|
+
fireEvent.click(await screen.findByRole('button', { name: /only jurga@ogmc\.example/i }))
|
|
171
|
+
expect(onFiltersChange).toHaveBeenCalledWith({ actor: 'u-jurga' })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('an empty filtered feed says it is filtered, not that nothing happened', async () => {
|
|
175
|
+
const list = jest.fn().mockResolvedValue({ next: null, results: [] })
|
|
176
|
+
mount(<RecordActivityPanel client={{ list }} filters={{ actor_kind: 'machine' }} onFiltersChange={jest.fn()} />)
|
|
177
|
+
expect(await screen.findByText(/no edits match/i)).toBeInTheDocument()
|
|
178
|
+
})
|
|
179
|
+
})
|