@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.
Files changed (28) hide show
  1. package/package.json +3 -3
  2. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  3. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  4. package/src/components/activity/types.ts +10 -5
  5. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  6. package/src/components/collection/InlineReviewActions.tsx +3 -2
  7. package/src/components/collection/ReviewDrawer.tsx +35 -7
  8. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  9. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  10. package/src/components/collection/data.ts +16 -1
  11. package/src/components/collection/index.ts +2 -1
  12. package/src/components/collection/scoped-client.ts +4 -1
  13. package/src/components/collection/types.ts +19 -1
  14. package/src/components/history/FieldAttribution.tsx +308 -0
  15. package/src/components/history/RecordActivityPanel.tsx +344 -0
  16. package/src/components/history/RecordHistoryPanel.tsx +331 -15
  17. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  18. package/src/components/history/RevisionFields.tsx +69 -3
  19. package/src/components/history/StaleSaveDialog.tsx +28 -7
  20. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  21. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  22. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  23. package/src/components/history/__tests__/restore.test.ts +209 -0
  24. package/src/components/history/index.ts +72 -1
  25. package/src/components/history/restore.ts +295 -0
  26. package/src/components/history/revision-feed.ts +105 -0
  27. package/src/components/history/revision-history.ts +22 -0
  28. 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({ sitting, fieldLabel = (f) => f }: RevisionFieldsProps) {
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&apos;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} while you were editing.</span>
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 while you were editing.`}</span>
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
- Your edits are still here and have not been saved.
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
- Discard my edits and load theirs
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
- Keep editing
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
- See what changed
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…' : 'Save mine, replacing their change'}
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
+ })