@startsimpli/ui 0.4.141 → 0.4.143

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 (39) hide show
  1. package/package.json +38 -12
  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
  29. package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
  30. package/src/components/track-changes/__tests__/extension.test.ts +350 -0
  31. package/src/components/track-changes/__tests__/model.test.ts +461 -0
  32. package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
  33. package/src/components/track-changes/describe.ts +76 -0
  34. package/src/components/track-changes/extension.ts +724 -0
  35. package/src/components/track-changes/index.ts +74 -0
  36. package/src/components/track-changes/normalize.ts +364 -0
  37. package/src/components/track-changes/types.ts +373 -0
  38. package/src/components/track-changes/useTrackChanges.ts +214 -0
  39. package/src/components/track-changes/word-diff.ts +97 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * RED for bd startsim-zkyk4 — restore, as a person meets it in the history panel.
3
+ *
4
+ * The rules that matter:
5
+ * - offered only when the host supplies `restore`, and only where it would change
6
+ * something (never on the row that IS the current value);
7
+ * - nothing is written until a confirm that SHOWS what will change;
8
+ * - every restore carries a version (the route 428s without one), and it is the
9
+ * version the HOST holds, not one the panel guesses;
10
+ * - a 412 reuses StaleSaveDialog, and only an explicit choice adopts the version
11
+ * the refusal named;
12
+ * - after a restore the panel re-reads the trail and the host is told, so its own
13
+ * editor state can reload;
14
+ * - the restore revision reads "Restored … from vN".
15
+ */
16
+ import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
17
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
18
+
19
+ import { RecordHistoryPanel } from '../RecordHistoryPanel'
20
+ import type { EntityRevision, RevisionClient, RevisionPage } from '../types'
21
+
22
+ function mount(ui: React.ReactElement) {
23
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
24
+ return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
25
+ }
26
+
27
+ const T0 = Date.parse('2026-10-07T12:00:00.000Z')
28
+ const at = (ms: number) => new Date(T0 + ms).toISOString()
29
+
30
+ function rev(version: number, changed: Record<string, unknown>, over: Partial<EntityRevision> = {}): EntityRevision {
31
+ return {
32
+ id: `r${version}`,
33
+ version,
34
+ action: 'updated',
35
+ source: 'patch',
36
+ // An hour apart, so nothing folds and each version is its own row.
37
+ created_at: at(version * 3_600_000),
38
+ actor_sub: version % 2 ? 'u-jurga' : 'u-alex',
39
+ actor_label: version % 2 ? 'jurga@ogmc.example' : 'alex@ogmc.example',
40
+ actor_kind: 'person',
41
+ actor_kind_source: 'claim',
42
+ metadata: { changed: changed as never },
43
+ ...over,
44
+ }
45
+ }
46
+
47
+ const trail: EntityRevision[] = [
48
+ rev(3, { blog: { before: 'Jurga paragraph', after: 'Machine paragraph' } }, {
49
+ actor_sub: 'svc:n8n',
50
+ actor_label: 'svc:n8n',
51
+ actor_kind: 'machine',
52
+ }),
53
+ rev(2, { blog: { before: 'First', after: 'Jurga paragraph' } }),
54
+ rev(1, { blog: { after: 'First' } }, { action: 'created' }),
55
+ ]
56
+
57
+ function page(results: EntityRevision[], over: Partial<RevisionPage> = {}): RevisionPage {
58
+ return { count: results.length, next: null, previous: null, results, ...over }
59
+ }
60
+
61
+ function clientWith(over: Partial<RevisionClient> = {}, results = trail) {
62
+ return {
63
+ list: jest.fn().mockResolvedValue(page(results)),
64
+ restore: jest.fn().mockResolvedValue({
65
+ status: 200,
66
+ body: { id: 'e1', version: 4, restore: { restored_from: 2, fields: ['blog'], revision: 4 } },
67
+ }),
68
+ ...over,
69
+ }
70
+ }
71
+
72
+ /** The history row for one version, by its "vN" subtitle. */
73
+ async function rowFor(version: number): Promise<HTMLElement> {
74
+ const label = await screen.findByText(`v${version}`)
75
+ return label.closest('[data-activity-id]') as HTMLElement
76
+ }
77
+
78
+ async function openField(version: number, field = 'blog') {
79
+ const row = await rowFor(version)
80
+ fireEvent.click(within(row).getByRole('button', { name: new RegExp(field) }))
81
+ return row
82
+ }
83
+
84
+ describe('RecordHistoryPanel — restore is offered only where it means something', () => {
85
+ it('offers nothing when the host supplies no restore', async () => {
86
+ const { list } = clientWith()
87
+ mount(<RecordHistoryPanel client={{ list }} currentVersion={3} />)
88
+ await openField(2)
89
+ expect(screen.queryByRole('button', { name: /restore this value/i })).not.toBeInTheDocument()
90
+ expect(screen.queryByRole('button', { name: /restore this version/i })).not.toBeInTheDocument()
91
+ })
92
+
93
+ it('offers nothing when the host says the viewer cannot write', async () => {
94
+ mount(<RecordHistoryPanel client={clientWith()} currentVersion={3} canRestore={false} />)
95
+ await openField(2)
96
+ expect(screen.queryByRole('button', { name: /restore this value/i })).not.toBeInTheDocument()
97
+ })
98
+
99
+ it('offers "Restore this value" on an older field change, and NOT on the current one', async () => {
100
+ mount(<RecordHistoryPanel client={clientWith()} currentVersion={3} />)
101
+ const older = await openField(2)
102
+ expect(within(older).getByRole('button', { name: /restore this value/i })).toBeInTheDocument()
103
+ const newest = await openField(3)
104
+ expect(within(newest).queryByRole('button', { name: /restore this value/i })).not.toBeInTheDocument()
105
+ })
106
+
107
+ it('offers "Restore this version" on every row but the newest', async () => {
108
+ mount(<RecordHistoryPanel client={clientWith()} currentVersion={3} />)
109
+ const older = await rowFor(2)
110
+ expect(within(older).getByRole('button', { name: /restore this version/i })).toBeInTheDocument()
111
+ const newest = await rowFor(3)
112
+ expect(within(newest).queryByRole('button', { name: /restore this version/i })).not.toBeInTheDocument()
113
+ })
114
+
115
+ it('a narrowed (?field=) panel never offers a WHOLE-record restore — its trail is partial', async () => {
116
+ const client = clientWith()
117
+ mount(<RecordHistoryPanel client={client} currentVersion={3} field="blog" />)
118
+ const older = await openField(2)
119
+ expect(within(older).queryByRole('button', { name: /restore this version/i })).not.toBeInTheDocument()
120
+ expect(within(older).getByRole('button', { name: /restore this value/i })).toBeInTheDocument()
121
+ })
122
+ })
123
+
124
+ describe('RecordHistoryPanel — the confirm shows what will change, and writes nothing until confirmed', () => {
125
+ it('shows the field, from now to the restored value, with a line diff', async () => {
126
+ const client = clientWith()
127
+ mount(<RecordHistoryPanel client={client} currentVersion={3} />)
128
+ const row = await openField(2)
129
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
130
+ const dialog = await screen.findByTestId('restore-confirm-dialog')
131
+ expect(dialog).toHaveTextContent(/v2/)
132
+ expect(within(dialog).getByTestId('restore-preview-blog')).toHaveTextContent(/Machine paragraph/)
133
+ expect(within(dialog).getByTestId('restore-preview-blog')).toHaveTextContent(/Jurga paragraph/)
134
+ expect(client.restore).not.toHaveBeenCalled()
135
+ })
136
+
137
+ it('Cancel writes nothing', async () => {
138
+ const client = clientWith()
139
+ mount(<RecordHistoryPanel client={client} currentVersion={3} />)
140
+ const row = await openField(2)
141
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
142
+ fireEvent.click(await screen.findByTestId('restore-cancel'))
143
+ expect(screen.queryByTestId('restore-confirm-dialog')).not.toBeInTheDocument()
144
+ expect(client.restore).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('confirming sends revision, fields AND the host-held version', async () => {
148
+ const client = clientWith()
149
+ mount(<RecordHistoryPanel client={client} currentVersion={3} />)
150
+ const row = await openField(2)
151
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
152
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
153
+ await waitFor(() => expect(client.restore).toHaveBeenCalledTimes(1))
154
+ expect(client.restore).toHaveBeenCalledWith({ version: 2, fields: ['blog'], expectedVersion: 3 })
155
+ })
156
+
157
+ it('a whole-version restore omits fields', async () => {
158
+ const client = clientWith()
159
+ mount(<RecordHistoryPanel client={client} currentVersion={3} />)
160
+ const row = await rowFor(2)
161
+ fireEvent.click(within(row).getByRole('button', { name: /restore this version/i }))
162
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
163
+ await waitFor(() => expect(client.restore).toHaveBeenCalledWith({ version: 2, expectedVersion: 3 }))
164
+ })
165
+
166
+ it('reads the version from a FUNCTION at confirm time, so an autosave in between is honoured', async () => {
167
+ const client = clientWith()
168
+ let held = 3
169
+ mount(<RecordHistoryPanel client={client} currentVersion={() => held} />)
170
+ const row = await openField(2)
171
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
172
+ held = 7
173
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
174
+ await waitFor(() =>
175
+ expect(client.restore).toHaveBeenCalledWith(expect.objectContaining({ expectedVersion: 7 })),
176
+ )
177
+ })
178
+
179
+ it('runs the host beforeRestore hook FIRST (to stop pending autosaves)', async () => {
180
+ const order: string[] = []
181
+ const client = clientWith({
182
+ restore: jest.fn().mockImplementation(async () => {
183
+ order.push('restore')
184
+ return { status: 200, body: { version: 4, restore: { restored_from: 2, fields: ['blog'], revision: 4 } } }
185
+ }),
186
+ })
187
+ mount(
188
+ <RecordHistoryPanel
189
+ client={client}
190
+ currentVersion={3}
191
+ beforeRestore={async () => {
192
+ order.push('before')
193
+ }}
194
+ />,
195
+ )
196
+ const row = await openField(2)
197
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
198
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
199
+ await waitFor(() => expect(order).toEqual(['before', 'restore']))
200
+ })
201
+ })
202
+
203
+ describe('RecordHistoryPanel — the host hook around the send', () => {
204
+ it('reads the version AFTER beforeRestore, so a flushed save is not a 412 against itself', async () => {
205
+ const client = clientWith()
206
+ let held = 3
207
+ mount(
208
+ <RecordHistoryPanel
209
+ client={client}
210
+ currentVersion={() => held}
211
+ beforeRestore={async () => {
212
+ held = 4
213
+ }}
214
+ />,
215
+ )
216
+ const row = await openField(2)
217
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
218
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
219
+ await waitFor(() =>
220
+ expect(client.restore).toHaveBeenCalledWith(expect.objectContaining({ expectedVersion: 4 })),
221
+ )
222
+ })
223
+
224
+ it('a beforeRestore that throws ABORTS the restore and says why', async () => {
225
+ const client = clientWith()
226
+ mount(
227
+ <RecordHistoryPanel
228
+ client={client}
229
+ currentVersion={3}
230
+ beforeRestore={async () => {
231
+ throw new Error('Your edit could not be saved first.')
232
+ }}
233
+ />,
234
+ )
235
+ const row = await openField(2)
236
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
237
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
238
+ expect(await screen.findByTestId('restore-error')).toHaveTextContent(/could not be saved first/)
239
+ expect(client.restore).not.toHaveBeenCalled()
240
+ })
241
+
242
+ it('runs the returned cleanup once the restore settles, even on a refusal', async () => {
243
+ const cleanup = jest.fn()
244
+ const client = clientWith({ restore: jest.fn().mockResolvedValue({ status: 409, body: { detail: 'no' } }) })
245
+ mount(<RecordHistoryPanel client={client} currentVersion={3} beforeRestore={() => cleanup} />)
246
+ const row = await openField(2)
247
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
248
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
249
+ await screen.findByTestId('restore-error')
250
+ expect(cleanup).toHaveBeenCalledTimes(1)
251
+ })
252
+ })
253
+
254
+ describe('RecordHistoryPanel — after the restore', () => {
255
+ it('tells the host, then re-reads the trail', async () => {
256
+ const onRestored = jest.fn()
257
+ const client = clientWith()
258
+ mount(<RecordHistoryPanel client={client} currentVersion={3} onRestored={onRestored} />)
259
+ const row = await openField(2)
260
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
261
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
262
+ await waitFor(() => expect(onRestored).toHaveBeenCalledTimes(1))
263
+ expect(onRestored.mock.calls[0][0]).toMatchObject({ kind: 'restored', version: 4 })
264
+ await waitFor(() => expect(client.list.mock.calls.length).toBeGreaterThanOrEqual(2))
265
+ expect(screen.queryByTestId('restore-confirm-dialog')).not.toBeInTheDocument()
266
+ expect(await screen.findByTestId('restore-done')).toHaveTextContent(/v2/)
267
+ })
268
+
269
+ it('a refusal other than 412 stays in the dialog with the server sentence', async () => {
270
+ const client = clientWith({
271
+ restore: jest.fn().mockResolvedValue({ status: 409, body: { detail: 'the trail truncated blog' } }),
272
+ })
273
+ mount(<RecordHistoryPanel client={client} currentVersion={3} />)
274
+ const row = await openField(2)
275
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
276
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
277
+ expect(await screen.findByTestId('restore-error')).toHaveTextContent('the trail truncated blog')
278
+ expect(screen.getByTestId('restore-confirm-dialog')).toBeInTheDocument()
279
+ })
280
+ })
281
+
282
+ describe('RecordHistoryPanel — a stale restore reuses StaleSaveDialog', () => {
283
+ const stale = () =>
284
+ clientWith({
285
+ restore: jest
286
+ .fn()
287
+ .mockResolvedValueOnce({ status: 412, body: { current_version: 5, detail: 'stale' } })
288
+ .mockResolvedValueOnce({ status: 200, body: { version: 6, restore: { restored_from: 2, fields: ['blog'], revision: 6 } } }),
289
+ })
290
+
291
+ it('opens the stale dialog and does NOT retry on its own', async () => {
292
+ const client = stale()
293
+ mount(<RecordHistoryPanel client={client} currentVersion={3} />)
294
+ const row = await openField(2)
295
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
296
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
297
+ expect(await screen.findByTestId('stale-save-dialog')).toBeInTheDocument()
298
+ expect(client.restore).toHaveBeenCalledTimes(1)
299
+ // Not the editor's sentence — nothing was being typed.
300
+ expect(screen.getByTestId('stale-save-reassurance')).not.toHaveTextContent(/your edits/i)
301
+ })
302
+
303
+ it('"restore anyway" is the only path that adopts current_version', async () => {
304
+ const onRestored = jest.fn()
305
+ const client = stale()
306
+ mount(<RecordHistoryPanel client={client} currentVersion={3} onRestored={onRestored} />)
307
+ const row = await openField(2)
308
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
309
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
310
+ fireEvent.click(await screen.findByTestId('stale-save-keep-mine'))
311
+ await waitFor(() => expect(client.restore).toHaveBeenCalledTimes(2))
312
+ expect(client.restore.mock.calls[1][0]).toEqual({ version: 2, fields: ['blog'], expectedVersion: 5 })
313
+ await waitFor(() => expect(onRestored).toHaveBeenCalled())
314
+ })
315
+
316
+ it('"see what changed" closes it and re-reads the trail without writing', async () => {
317
+ const client = stale()
318
+ mount(<RecordHistoryPanel client={client} currentVersion={3} />)
319
+ const row = await openField(2)
320
+ fireEvent.click(within(row).getByRole('button', { name: /restore this value/i }))
321
+ fireEvent.click(await screen.findByTestId('restore-confirm'))
322
+ fireEvent.click(await screen.findByTestId('stale-save-review'))
323
+ await waitFor(() => expect(screen.queryByTestId('stale-save-dialog')).not.toBeInTheDocument())
324
+ expect(client.restore).toHaveBeenCalledTimes(1)
325
+ })
326
+ })
327
+
328
+ describe('RecordHistoryPanel — the restore row and the policy', () => {
329
+ it('a restore revision reads "Restored blog from v2"', async () => {
330
+ const restored = rev(4, { blog: { before: 'Machine paragraph', after: 'Jurga paragraph' } }, {
331
+ source: 'restore',
332
+ metadata: {
333
+ changed: { blog: { before: 'Machine paragraph', after: 'Jurga paragraph' } },
334
+ restored_from: 2,
335
+ restored_fields: ['blog'],
336
+ } as never,
337
+ })
338
+ mount(<RecordHistoryPanel client={clientWith({}, [restored, ...trail])} currentVersion={4} />)
339
+ expect(await screen.findByText(/Restored blog from v2/)).toBeInTheDocument()
340
+ })
341
+
342
+ it('a restore never folds into the edit before it, even by the same person a second later', async () => {
343
+ const restored = rev(4, { blog: { before: 'x', after: 'y' } }, {
344
+ source: 'restore',
345
+ created_at: at(3 * 3_600_000 + 1000),
346
+ actor_sub: 'svc:n8n',
347
+ actor_label: 'svc:n8n',
348
+ actor_kind: 'machine',
349
+ metadata: { changed: { blog: { before: 'x', after: 'y' } }, restored_from: 2 } as never,
350
+ })
351
+ mount(<RecordHistoryPanel client={clientWith({}, [restored, ...trail])} currentVersion={4} />)
352
+ expect(await screen.findByText('v4')).toBeInTheDocument()
353
+ expect(screen.getByText('v3')).toBeInTheDocument()
354
+ })
355
+
356
+ it('reads the history policy off the envelope when the host does not pass it', async () => {
357
+ const client = {
358
+ list: jest.fn().mockResolvedValue(page([], { history: { enabled: false, declared_by: 'type' } })),
359
+ }
360
+ mount(<RecordHistoryPanel client={client} />)
361
+ expect(await screen.findByTestId('record-history-declared-off')).toBeInTheDocument()
362
+ })
363
+
364
+ it('passes ?field= to the reader when narrowed, and offers a way back to everything', async () => {
365
+ const client = clientWith()
366
+ const onClearField = jest.fn()
367
+ mount(<RecordHistoryPanel client={client} field="blog" onClearField={onClearField} />)
368
+ await waitFor(() => expect(client.list).toHaveBeenCalled())
369
+ expect(client.list.mock.calls[0][0]).toMatchObject({ field: 'blog' })
370
+ fireEvent.click(await screen.findByTestId('record-history-clear-field'))
371
+ expect(onClearField).toHaveBeenCalled()
372
+ })
373
+ })
@@ -0,0 +1,209 @@
1
+ /**
2
+ * RED for bd startsim-zkyk4 — the pure half of "Restore this value / Restore this
3
+ * version": what a restore to revision N would change, read off the trail the
4
+ * panel already holds, and what the server said when asked.
5
+ *
6
+ * The server's definition is the contract (start-simpli-api
7
+ * tenant-starter/apps/api/revision_history.py `_state_at`): the record at N is
8
+ * rebuilt BACKWARDS from now by undoing every edit after N, newest first — a key
9
+ * whose `before` is absent was created after N and is removed. So the preview
10
+ * must agree with that rule exactly, or the confirm dialog promises one change
11
+ * and the server makes another.
12
+ */
13
+ import {
14
+ latestFieldVersions,
15
+ parseRestoreOutcome,
16
+ restorePreview,
17
+ } from '../restore'
18
+ import type { EntityRevision } from '../types'
19
+
20
+ const T0 = Date.parse('2026-10-07T12:00:00.000Z')
21
+ const at = (ms: number) => new Date(T0 + ms).toISOString()
22
+
23
+ function rev(version: number, changed: Record<string, unknown>, over: Partial<EntityRevision> = {}): EntityRevision {
24
+ return {
25
+ id: `r${version}`,
26
+ version,
27
+ action: version === 1 ? 'created' : 'updated',
28
+ source: 'patch',
29
+ created_at: at(version * 60_000),
30
+ actor_sub: 'u1',
31
+ actor_label: 'jurga@ogmc.example',
32
+ actor_kind: 'person',
33
+ actor_kind_source: 'claim',
34
+ metadata: { changed: changed as never },
35
+ ...over,
36
+ }
37
+ }
38
+
39
+ // Newest first, as the route orders them.
40
+ const trail: EntityRevision[] = [
41
+ rev(4, { blog: { before: 'B machine', after: 'B reviewer 2' } }),
42
+ rev(3, { blog: { before: 'B jurga', after: 'B machine' }, team_notes: { after: 'note' } }),
43
+ rev(2, { blog: { before: 'B draft', after: 'B jurga' }, title: { before: 'T1', after: 'T2' } }),
44
+ rev(1, { blog: { after: 'B draft' }, title: { after: 'T1' } }),
45
+ ]
46
+
47
+ describe('restorePreview — agrees with the server rebuild', () => {
48
+ it('a FIELD restore to v2 moves only that field, from now back to its value at v2', () => {
49
+ const p = restorePreview(trail, 2, { fields: ['blog'] })
50
+ expect(p.complete).toBe(true)
51
+ expect(p.changes).toHaveLength(1)
52
+ expect(p.changes[0]).toMatchObject({
53
+ field: 'blog',
54
+ current: { has: true, value: 'B reviewer 2' },
55
+ restored: { has: true, value: 'B jurga' },
56
+ })
57
+ expect(p.noop).toBe(false)
58
+ })
59
+
60
+ it('a WHOLE restore to v2 also REMOVES a key that was created after v2', () => {
61
+ const p = restorePreview(trail, 2)
62
+ const byField = Object.fromEntries(p.changes.map((c) => [c.field, c]))
63
+ expect(Object.keys(byField).sort()).toEqual(['blog', 'team_notes'])
64
+ expect(byField.team_notes).toMatchObject({
65
+ current: { has: true, value: 'note' },
66
+ restored: { has: false },
67
+ })
68
+ // title did not move after v2 — nothing to say about it.
69
+ expect(byField.title).toBeUndefined()
70
+ })
71
+
72
+ it('restoring the CURRENT version is a no-op, said as one', () => {
73
+ const p = restorePreview(trail, 4)
74
+ expect(p.noop).toBe(true)
75
+ expect(p.changes).toEqual([])
76
+ })
77
+
78
+ it('a field nobody touched after N is a no-op for that field', () => {
79
+ expect(restorePreview(trail, 2, { fields: ['title'] }).noop).toBe(true)
80
+ })
81
+
82
+ it('a field that ends up where it started is not a change', () => {
83
+ const roundTrip = [
84
+ rev(3, { blog: { before: 'x', after: 'a' } }),
85
+ rev(2, { blog: { before: 'a', after: 'x' } }),
86
+ rev(1, { blog: { after: 'a' } }),
87
+ ]
88
+ expect(restorePreview(roundTrip, 1).noop).toBe(true)
89
+ })
90
+
91
+ it('a truncated or omitted side after N makes the value UNKNOWABLE — the server 409s', () => {
92
+ const t = [
93
+ rev(3, { blog: { before: 'long…', truncated: true, after: 'short' } }),
94
+ rev(2, { blog: { before: 'a', after: 'long…' } }),
95
+ ]
96
+ const p = restorePreview(t, 2)
97
+ expect(p.unknowable).toEqual(['blog'])
98
+ const omitted = [
99
+ rev(3, { blog: {} }, { metadata: { changed: { blog: {} }, values_omitted: true } }),
100
+ rev(2, { blog: { before: 'a', after: 'b' } }),
101
+ ]
102
+ // The server cannot know the NAME either on an omitted row (the cap drops
103
+ // the name side with the values), so a whole restore over it is refused.
104
+ expect(restorePreview(omitted, 2).unknowable).toEqual(['blog', 'name'])
105
+ expect(restorePreview(omitted, 2, { fields: ['blog'] }).unknowable).toEqual(['blog'])
106
+ })
107
+
108
+ it('a WITHHELD field is left as it is by a whole restore, and named as such', () => {
109
+ const t = [
110
+ rev(3, { body: {} }, { redacted_fields: ['body'] }),
111
+ rev(2, { blog: { before: 'a', after: 'b' } }),
112
+ ]
113
+ const p = restorePreview(t, 2)
114
+ expect(p.withheld).toEqual(['body'])
115
+ expect(p.changes.map((c) => c.field)).not.toContain('body')
116
+ })
117
+
118
+ it('INCOMPLETE when a row between N and now is not loaded — never a confident preview', () => {
119
+ // v3 missing: the chain from 2 to 4 has a hole.
120
+ const holey = [trail[0]!, trail[2]!, trail[3]!]
121
+ const p = restorePreview(holey, 2)
122
+ expect(p.complete).toBe(false)
123
+ })
124
+
125
+ it('the name column moves on a whole restore only', () => {
126
+ const t = [
127
+ rev(3, {}, { metadata: { changed: {}, name: { before: 'Old', after: 'New' } } }),
128
+ rev(2, { blog: { before: 'a', after: 'b' } }),
129
+ ]
130
+ expect(restorePreview(t, 2).name).toMatchObject({
131
+ current: { has: true, value: 'New' },
132
+ restored: { has: true, value: 'Old' },
133
+ })
134
+ expect(restorePreview(t, 2, { fields: ['blog'] }).name).toBeUndefined()
135
+ })
136
+
137
+ it('an ATTEMPT row (held back, nothing moved) changes nothing', () => {
138
+ const t = [
139
+ rev(3, {}, { metadata: { changed: {}, preserved: ['blog'] } }),
140
+ rev(2, { blog: { before: 'a', after: 'b' } }),
141
+ ]
142
+ expect(restorePreview(t, 2).noop).toBe(true)
143
+ })
144
+ })
145
+
146
+ describe('latestFieldVersions — which fields have moved since a row', () => {
147
+ it('maps each field to the newest version that changed it', () => {
148
+ expect(latestFieldVersions(trail)).toEqual({ blog: 4, team_notes: 3, title: 2 })
149
+ })
150
+ })
151
+
152
+ describe('parseRestoreOutcome — every answer the route gives', () => {
153
+ const ok = {
154
+ status: 200,
155
+ body: {
156
+ id: 'e1',
157
+ version: 5,
158
+ data: { blog: 'B jurga' },
159
+ restore: { restored_from: 2, fields: ['blog'], revision: 5 },
160
+ },
161
+ }
162
+
163
+ it('200 → restored, with the new version and the moved fields', () => {
164
+ expect(parseRestoreOutcome(ok)).toMatchObject({
165
+ kind: 'restored',
166
+ version: 5,
167
+ summary: { restoredFrom: 2, fields: ['blog'], revision: 5 },
168
+ })
169
+ })
170
+
171
+ it('200 with revision null → restored, but NOTHING changed', () => {
172
+ const noop = { status: 200, body: { ...ok.body, version: 4, restore: { restored_from: 2, fields: [], revision: null } } }
173
+ expect(parseRestoreOutcome(noop)).toMatchObject({ kind: 'restored', summary: { revision: null } })
174
+ })
175
+
176
+ it('reads a camelised body too', () => {
177
+ const camel = { status: 200, body: { version: 5, restore: { restoredFrom: 2, fields: ['blog'], revision: 5 } } }
178
+ expect(parseRestoreOutcome(camel)).toMatchObject({ summary: { restoredFrom: 2 } })
179
+ })
180
+
181
+ it('412, resolved OR thrown → stale, carrying current_version', () => {
182
+ const refusal = { status: 412, body: { current_version: 6, detail: 'stale' } }
183
+ expect(parseRestoreOutcome(refusal)).toMatchObject({ kind: 'stale', currentVersion: 6 })
184
+ const thrown = Object.assign(new Error('412'), { status: 412, body: { current_version: 6 } })
185
+ expect(parseRestoreOutcome(thrown)).toMatchObject({ kind: 'stale', currentVersion: 6 })
186
+ })
187
+
188
+ it.each([
189
+ [428, /version/i],
190
+ [403, /access/i],
191
+ [404, /no longer|not available|no such/i],
192
+ [409, /.+/],
193
+ [400, /.+/],
194
+ ])('%s → refused, with a sentence a person can read', (status, sentence) => {
195
+ const out = parseRestoreOutcome({ status, body: {} })
196
+ expect(out.kind).toBe('refused')
197
+ if (out.kind === 'refused') {
198
+ expect(out.status).toBe(status)
199
+ expect(out.message).toMatch(sentence)
200
+ }
201
+ })
202
+
203
+ it('prefers the server detail when there is one (400 names the field)', () => {
204
+ const out = parseRestoreOutcome({ status: 400, body: { fields: ['blog is not declared on type draft'] } })
205
+ expect(out.kind === 'refused' && out.message).toMatch(/blog is not declared/)
206
+ const out2 = parseRestoreOutcome({ status: 409, body: { detail: 'history is off' } })
207
+ expect(out2.kind === 'refused' && out2.message).toBe('history is off')
208
+ })
209
+ })
@@ -22,7 +22,13 @@
22
22
  * `kind`/`kindSource` — rather than deciding locally, because "absent" read as
23
23
  * "a machine did this" is the single worst failure available here.
24
24
  */
25
- export { RecordHistoryPanel, REVISION_ENTRY_TYPES } from './RecordHistoryPanel';
25
+ export {
26
+ RecordHistoryPanel,
27
+ REVISION_ENTRY_ICONS,
28
+ REVISION_ENTRY_TYPES,
29
+ revisionEntryType,
30
+ versionNote,
31
+ } from './RecordHistoryPanel';
26
32
  export type { RecordHistoryPanelProps } from './RecordHistoryPanel';
27
33
 
28
34
  export { RevisionFields } from './RevisionFields';
@@ -56,6 +62,58 @@ export type {
56
62
 
57
63
  export { diffLines, unifiedTextDiff } from './text-diff';
58
64
 
65
+ /* -------------------------------------------------------------------------- */
66
+ /* RESTORE (bd startsim-zkyk4) — "Restore this value / this version" */
67
+ /* -------------------------------------------------------------------------- */
68
+
69
+ export { RestoreConfirmDialog } from './RestoreConfirmDialog';
70
+ export type { RestoreConfirmDialogProps } from './RestoreConfirmDialog';
71
+ export { latestFieldVersions, parseRestoreOutcome, restorePreview } from './restore';
72
+ export type {
73
+ ParsedRestoreOutcome,
74
+ RestoreFieldChange,
75
+ RestorePreview,
76
+ RestoreSide,
77
+ RestoreSummary,
78
+ } from './restore';
79
+
80
+ /* -------------------------------------------------------------------------- */
81
+ /* FIELD AUTHORS (bd startsim-vjhbw) — "edited by X · 2h ago" */
82
+ /* -------------------------------------------------------------------------- */
83
+
84
+ export {
85
+ FieldAttribution,
86
+ FieldAuthorsNotice,
87
+ fieldAuthorOf,
88
+ normalizeFieldAuthors,
89
+ useFieldAuthors,
90
+ } from './FieldAttribution';
91
+ export type {
92
+ FieldAttributionProps,
93
+ NormalizedFieldAuthor,
94
+ NormalizedFieldAuthors,
95
+ UseFieldAuthorsOptions,
96
+ } from './FieldAttribution';
97
+
98
+ /* -------------------------------------------------------------------------- */
99
+ /* ACROSS RECORDS (bd startsim-ucg5u) — the edit feed */
100
+ /* -------------------------------------------------------------------------- */
101
+
102
+ export { RecordActivityPanel } from './RecordActivityPanel';
103
+ export type { RecordActivityOption, RecordActivityPanelProps } from './RecordActivityPanel';
104
+ export {
105
+ REVISION_FEED_ACTOR_KINDS,
106
+ REVISION_FEED_FILTER_KEYS,
107
+ foldFeed,
108
+ localDateInput,
109
+ localDayEnd,
110
+ localDayStart,
111
+ revisionFeedFiltersFrom,
112
+ revisionFeedParams,
113
+ startOfThisWeek,
114
+ } from './revision-feed';
115
+ export type { FeedSitting } from './revision-feed';
116
+
59
117
  /* -------------------------------------------------------------------------- */
60
118
  /* THE CONDITIONAL WRITE (bd startsim-jkkn7.3) */
61
119
  /* */
@@ -115,8 +173,21 @@ export type {
115
173
  ActorKindSource,
116
174
  EntityRevision,
117
175
  RevisionAction,
176
+ FieldAuthor,
177
+ FieldAuthorsClient,
178
+ FieldAuthorsResponse,
179
+ FieldAuthorsTrailStart,
180
+ RestoreOutcome,
181
+ RestoreRequest,
118
182
  RevisionClient,
183
+ RevisionFeedClient,
184
+ RevisionFeedEntity,
185
+ RevisionFeedFilters,
186
+ RevisionFeedPage,
187
+ RevisionFeedQuery,
188
+ RevisionFeedRow,
119
189
  RevisionFieldChange,
190
+ RevisionHistoryPolicy,
120
191
  RevisionMetadata,
121
192
  RevisionPage,
122
193
  RevisionPrecondition,