@startsimpli/ui 0.4.140 → 0.4.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActivityTimeline.tsx +17 -12
  3. package/src/components/ActorIdentity.tsx +15 -2
  4. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  5. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  6. package/src/components/activity/types.ts +10 -5
  7. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  8. package/src/components/collection/InlineReviewActions.tsx +3 -2
  9. package/src/components/collection/ReviewDrawer.tsx +35 -7
  10. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  11. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  12. package/src/components/collection/data.ts +16 -1
  13. package/src/components/collection/index.ts +2 -1
  14. package/src/components/collection/scoped-client.ts +4 -1
  15. package/src/components/collection/types.ts +19 -1
  16. package/src/components/history/FieldAttribution.tsx +308 -0
  17. package/src/components/history/RecordActivityPanel.tsx +344 -0
  18. package/src/components/history/RecordHistoryPanel.tsx +381 -29
  19. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  20. package/src/components/history/RevisionFields.tsx +78 -6
  21. package/src/components/history/StaleSaveDialog.tsx +28 -7
  22. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  23. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  24. package/src/components/history/__tests__/record-history-panel.test.tsx +157 -9
  25. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  26. package/src/components/history/__tests__/restore.test.ts +209 -0
  27. package/src/components/history/index.ts +72 -1
  28. package/src/components/history/record-history-panel-default-class-names.ts +1 -1
  29. package/src/components/history/restore.ts +295 -0
  30. package/src/components/history/revision-feed.ts +105 -0
  31. package/src/components/history/revision-history.ts +22 -0
  32. package/src/components/history/types.ts +153 -1
@@ -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
+ })
@@ -217,19 +217,83 @@ describe('RecordHistoryPanel — attempts, truncation and completeness', () => {
217
217
  ).toBeInTheDocument()
218
218
  })
219
219
 
220
- it('NEVER claims the trail is complete', async () => {
221
- mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
222
- const note = await screen.findByTestId('record-history-incomplete')
223
- expect(note).toHaveTextContent(/not recorded yet/i)
224
- })
225
-
226
220
  it('an empty history says "no edits recorded", not "never changed"', async () => {
227
221
  mount(<RecordHistoryPanel client={clientFor(page([]))} />)
228
222
  expect(await screen.findByText('No edits recorded for this record')).toBeInTheDocument()
229
223
  expect(screen.queryByText(/never been changed|never changed/i)).not.toBeInTheDocument()
230
- // The caveat still stands on an empty panel — a quiet trail is the exact
231
- // case a reader would otherwise over-read.
232
- expect(screen.getByTestId('record-history-incomplete')).toBeInTheDocument()
224
+ })
225
+ })
226
+
227
+ describe('RecordHistoryPanel — what the trail does and does not cover (bd startsim-jkkn7.11/.12)', () => {
228
+ // bd startsim-jkkn7.6 closed all four silent write paths (annotate,
229
+ // move-scope, DELETE, the ingestion worker). A caveat that the trail misses
230
+ // them now describes a gap that does not exist, and teaches a reader to
231
+ // discount the one surface whose job is to be trusted.
232
+ it('no longer claims bulk imports, scope moves, annotations or deletions go unrecorded', async () => {
233
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
234
+ await screen.findByText('jurga@ogmc.example')
235
+ const panel = screen.getByTestId('record-history-panel')
236
+ expect(panel.textContent).not.toMatch(/bulk imports|scope moves|not recorded yet/i)
237
+ expect(screen.queryByTestId('record-history-incomplete')).not.toBeInTheDocument()
238
+ })
239
+
240
+ it('an empty history says "No edits recorded" and nothing more when history is on or unknown', async () => {
241
+ mount(<RecordHistoryPanel client={clientFor(page([]))} />)
242
+ await screen.findByText('No edits recorded for this record')
243
+ expect(screen.queryByTestId('record-history-incomplete')).not.toBeInTheDocument()
244
+ expect(screen.queryByTestId('record-history-declared-off')).not.toBeInTheDocument()
245
+ })
246
+
247
+ it('says history is not recorded for this kind of record when the type declares it off', async () => {
248
+ mount(<RecordHistoryPanel client={clientFor(page([]))} historyEnabled={false} />)
249
+ const note = await screen.findByTestId('record-history-declared-off')
250
+ expect(note).toHaveTextContent(/history is not recorded for this kind of record/i)
251
+ // A declaration, not a defect — it must not read like missing data.
252
+ expect(note.textContent).not.toMatch(/may be missing|error|fail|broken/i)
253
+ // And the empty state must not suggest edits will appear.
254
+ expect(screen.queryByText(/will appear here/i)).not.toBeInTheDocument()
255
+ })
256
+
257
+ it('keeps the declared-off disclosure next to rows recorded before it was switched off', async () => {
258
+ mount(
259
+ <RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} historyEnabled={false} />,
260
+ )
261
+ expect(await screen.findByText('jurga@ogmc.example')).toBeInTheDocument()
262
+ expect(screen.getByTestId('record-history-declared-off')).toHaveTextContent(
263
+ /history is not recorded for this kind of record/i,
264
+ )
265
+ })
266
+
267
+ it('says nothing about a declaration when the type keeps history', async () => {
268
+ mount(<RecordHistoryPanel client={clientFor(page([]))} historyEnabled />)
269
+ await screen.findByText('No edits recorded for this record')
270
+ expect(screen.queryByTestId('record-history-declared-off')).not.toBeInTheDocument()
271
+ })
272
+ })
273
+
274
+ describe('RevisionFields — the before/after split sizes on its container (bd startsim-jkkn7.12)', () => {
275
+ it('never decides the split from the VIEWPORT', async () => {
276
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
277
+ fireEvent.click(await screen.findByRole('button', { name: /blog/ }))
278
+ const before = screen.getByText('Before')
279
+ const split = before.parentElement!.parentElement!
280
+ // `sm:`/`md:`/`lg:` are viewport media queries: on a wide screen they force
281
+ // the two sides next to each other even inside a 370px rail.
282
+ expect(split.className).not.toMatch(/(^|\s)(sm|md|lg|xl):/)
283
+ // Stacked by default, side by side only when the CONTAINER has room.
284
+ expect(split.className).toMatch(/@sm:/)
285
+ expect(split.closest('.\\@container')).not.toBeNull()
286
+ })
287
+
288
+ it('gives the field list the full card width, not a column beside the timestamp', async () => {
289
+ // Measured in a 366px rail: the detail sat in the header's left column and
290
+ // shared the line with "2 minutes ago", losing ~100px it never needed.
291
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
292
+ const fields = await screen.findByTestId('revision-fields')
293
+ const row = fields.closest('[data-activity-id]')!
294
+ const time = within(row as HTMLElement).getByText(/ago|\d{1,2}:\d{2}/)
295
+ // The timestamp's row must not contain the field list.
296
+ expect(time.parentElement!.contains(fields)).toBe(false)
233
297
  })
234
298
  })
235
299
 
@@ -308,5 +372,89 @@ describe('ActorIdentity — the kind props are additive', () => {
308
372
  render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" />)
309
373
  const el = screen.getByTestId('actor-identity')
310
374
  expect(el).toHaveAttribute('data-actor-kind', 'unknown')
375
+ expect(el).toHaveAttribute(
376
+ 'title',
377
+ 'Whether a person or a machine made this change was not recorded.',
378
+ )
379
+ })
380
+ })
381
+
382
+ describe('ActorIdentity — every row with a kind can say it (bd startsim-jkkn7.15)', () => {
383
+ it('titles a claimed person, the other undecorated case', () => {
384
+ render(<ActorIdentity actor={{ email: 'malin@ogmc.example' }} kind="person" kindSource="claim" />)
385
+ expect(screen.getByTestId('actor-identity')).toHaveAttribute(
386
+ 'title',
387
+ 'A person made this change (declared by the sign-in service).',
388
+ )
389
+ })
390
+
391
+ it('titles a marked machine row on the wrapper too', () => {
392
+ render(<ActorIdentity actor={{ email: 'svc@x' }} kind="machine" kindSource="claim" />)
393
+ expect(screen.getByTestId('actor-identity')).toHaveAttribute(
394
+ 'title',
395
+ 'A machine made this change (declared by the sign-in service).',
396
+ )
397
+ })
398
+
399
+ it('keeps the kind out of the text, because the chip sits inside sentences', () => {
400
+ render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" showInitials={false} />)
401
+ expect(screen.getByTestId('actor-identity')).toHaveTextContent(/^a@b\.com$/)
402
+ })
403
+
404
+ it('adds no title when no kind is passed (older surfaces stay byte-identical)', () => {
405
+ render(<ActorIdentity actor={{ email: 'a@b.com' }} />)
406
+ expect(screen.getByTestId('actor-identity')).not.toHaveAttribute('title')
407
+ })
408
+
409
+ it('titles every row in the panel, including unknown and person/claim', async () => {
410
+ mount(
411
+ <RecordHistoryPanel
412
+ windowMs={0}
413
+ client={clientFor(
414
+ page([
415
+ rev({ version: 4, actor_label: 'u4@x', actor_sub: 'u4', actor_kind: 'unknown', actor_kind_source: 'inferred' }),
416
+ rev({ version: 3, actor_label: 'malin@x', actor_sub: 'u3', created_at: at(-60_000) }),
417
+ rev({ version: 2, actor_label: 'svc:a', actor_sub: 'svc:a', created_at: at(-120_000), actor_kind: 'machine', actor_kind_source: 'inferred' }),
418
+ rev({ version: 1, actor_label: 'svc:b', actor_sub: 'svc:b', created_at: at(-180_000), actor_kind: 'machine', actor_kind_source: 'claim' }),
419
+ ]),
420
+ )}
421
+ />,
422
+ )
423
+ await screen.findByText('malin@x')
424
+ const rows = screen.getAllByTestId('actor-identity')
425
+ expect(rows).toHaveLength(4)
426
+ for (const row of rows) expect(row.getAttribute('title')).toBeTruthy()
427
+ })
428
+ })
429
+
430
+ describe('RecordHistoryPanel — the unrecorded-kind sentence is scoped (bd startsim-jkkn7.15)', () => {
431
+ it('does not sweep a recorded person into "the rest"', async () => {
432
+ mount(
433
+ <RecordHistoryPanel
434
+ windowMs={0}
435
+ client={clientFor(
436
+ page([
437
+ rev({ version: 2, actor_label: 'u2@x', actor_sub: 'u2', actor_kind: 'unknown', actor_kind_source: 'inferred' }),
438
+ rev({ version: 1, actor_label: 'malin@x', actor_sub: 'u1', created_at: at(-60_000) }),
439
+ ]),
440
+ )}
441
+ />,
442
+ )
443
+ const note = await screen.findByTestId('record-history-unknown-kind')
444
+ expect(note.textContent).not.toMatch(/for the rest/i)
445
+ // It says that SOME unmarked entries were recorded as a person, and where
446
+ // to find out which.
447
+ expect(note.textContent).toMatch(/some unmarked entries/i)
448
+ expect(note.textContent).toMatch(/person/i)
449
+ })
450
+
451
+ it('keeps the plain wording when no loaded row is a recorded person', async () => {
452
+ mount(
453
+ <RecordHistoryPanel
454
+ client={clientFor(page([rev({ version: 1, actor_kind: 'unknown', actor_kind_source: 'inferred' })]))}
455
+ />,
456
+ )
457
+ const note = await screen.findByTestId('record-history-unknown-kind')
458
+ expect(note.textContent).toMatch(/for the rest/i)
311
459
  })
312
460
  })