@startsimpli/ui 0.4.135 → 0.4.137
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/StaleSaveDialog.tsx +277 -0
- package/src/components/history/__tests__/conditional-write.test.ts +252 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/stale-save-dialog.test.tsx +224 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -0
- package/src/components/history/__tests__/use-conditional-save.test.tsx +387 -0
- package/src/components/history/conditional-write.ts +340 -0
- package/src/components/history/index.ts +125 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/stale-save-dialog-default-class-names.ts +45 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/history/useConditionalSave.ts +356 -0
- package/src/components/index.ts +11 -1
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conflict state machine (bd startsim-jkkn7.3).
|
|
3
|
+
*
|
|
4
|
+
* The first describe block is the bead. Everything else is support.
|
|
5
|
+
*/
|
|
6
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
useConditionalSave,
|
|
10
|
+
type ConditionalWriteAttempt,
|
|
11
|
+
type ConditionalWriteOutcome,
|
|
12
|
+
} from '../useConditionalSave';
|
|
13
|
+
import type { RevisionClient } from '../types';
|
|
14
|
+
|
|
15
|
+
const REFUSAL = {
|
|
16
|
+
status: 412,
|
|
17
|
+
body: {
|
|
18
|
+
detail: 'the record changed since it was loaded: version 2 was sent and the record is now at version 3.',
|
|
19
|
+
current_version: 3,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function writer(outcomes: ConditionalWriteOutcome[]) {
|
|
24
|
+
const sent: ConditionalWriteAttempt[] = [];
|
|
25
|
+
const write = jest.fn(async (attempt: ConditionalWriteAttempt) => {
|
|
26
|
+
sent.push(attempt);
|
|
27
|
+
return outcomes[Math.min(sent.length - 1, outcomes.length - 1)]!;
|
|
28
|
+
});
|
|
29
|
+
return { client: { write }, sent, write };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const trail = (rows: unknown[]): RevisionClient => ({
|
|
33
|
+
list: jest.fn(async () => ({ results: rows as never[] })),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const JURGA = {
|
|
37
|
+
id: 'r3',
|
|
38
|
+
version: 3,
|
|
39
|
+
action: 'updated',
|
|
40
|
+
created_at: '2026-10-06T12:00:00Z',
|
|
41
|
+
actor_label: 'jurga@ogmc.example',
|
|
42
|
+
actor_kind: 'person',
|
|
43
|
+
actor_kind_source: 'claim',
|
|
44
|
+
metadata: { changed: { blog: { before: 'old', after: 'new' }, judge_verdict: { after: 'pass' } } },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('a refusal does not advance the held version', () => {
|
|
48
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
49
|
+
THIS IS THE BEAD. The tempting shortcut is to read `current_version` off
|
|
50
|
+
the refusal, adopt it, and re-send: the error goes away and every
|
|
51
|
+
happy-path test still passes. It also reduces the guard to a decoration,
|
|
52
|
+
because a blind retry at the current version IS the overwrite the 412 just
|
|
53
|
+
prevented, with one extra round-trip.
|
|
54
|
+
───────────────────────────────────────────────────────────────────────── */
|
|
55
|
+
it('keeps the loaded version after a 412 and never retries', async () => {
|
|
56
|
+
const { client, sent } = writer([REFUSAL]);
|
|
57
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
58
|
+
|
|
59
|
+
await act(async () => {
|
|
60
|
+
await result.current.save({ data: { blog: 'three paragraphs' } });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.current.version).toBe(2);
|
|
64
|
+
expect(result.current.conflict?.currentVersion).toBe(3);
|
|
65
|
+
// One attempt. No retry at version 3.
|
|
66
|
+
expect(sent).toHaveLength(1);
|
|
67
|
+
expect(sent[0]!.headers).toEqual({ 'If-Match': '"2"' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps the reviewer text on the conflict so it survives a reload', async () => {
|
|
71
|
+
// The accidental path is not a rash click — it is the host refetching the
|
|
72
|
+
// record to show "what changed" and its load-into-editor effect overwriting
|
|
73
|
+
// the text. The bytes are still here when that happens.
|
|
74
|
+
const { client } = writer([REFUSAL]);
|
|
75
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
76
|
+
const typed = { data: { blog: 'three paragraphs the reviewer typed' } };
|
|
77
|
+
|
|
78
|
+
await act(async () => {
|
|
79
|
+
await result.current.save(typed);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.current.conflict?.refusedBody).toEqual(typed);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('pauses autosave and sends nothing more while the conflict is open', async () => {
|
|
86
|
+
// The editor debounces at 1,200 ms. A 412 that neither paused nor
|
|
87
|
+
// deduplicated would repaint a modal on every keystroke burst.
|
|
88
|
+
const { client, sent } = writer([REFUSAL]);
|
|
89
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
90
|
+
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
93
|
+
});
|
|
94
|
+
expect(result.current.paused).toBe(true);
|
|
95
|
+
|
|
96
|
+
for (const keystroke of ['ab', 'abc', 'abcd']) {
|
|
97
|
+
await act(async () => {
|
|
98
|
+
const outcome = await result.current.save({ data: { blog: keystroke } });
|
|
99
|
+
expect(outcome.status).toBe('paused');
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
expect(sent).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('dismissing closes the dialog but does NOT resolve or resume', async () => {
|
|
106
|
+
// Escape, the backdrop and the close button are not a fourth option.
|
|
107
|
+
const { client, sent } = writer([REFUSAL]);
|
|
108
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
109
|
+
|
|
110
|
+
await act(async () => {
|
|
111
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
112
|
+
});
|
|
113
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
114
|
+
|
|
115
|
+
act(() => result.current.dismissConflict());
|
|
116
|
+
|
|
117
|
+
expect(result.current.dialogOpen).toBe(false);
|
|
118
|
+
expect(result.current.paused).toBe(true);
|
|
119
|
+
expect(result.current.conflict).not.toBeNull();
|
|
120
|
+
expect(result.current.version).toBe(2);
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
expect((await result.current.save({ data: { blog: 'b' } })).status).toBe('paused');
|
|
124
|
+
});
|
|
125
|
+
expect(sent).toHaveLength(1);
|
|
126
|
+
|
|
127
|
+
act(() => result.current.reopenConflict());
|
|
128
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('the version is sent on every write', () => {
|
|
133
|
+
it('advances the held version from the write response body', async () => {
|
|
134
|
+
// This is what stops a 1,200 ms autosave needing a GET between saves.
|
|
135
|
+
const { client, sent } = writer([
|
|
136
|
+
{ status: 200, body: { id: 'e1', version: 3 } },
|
|
137
|
+
{ status: 200, body: { id: 'e1', version: 4 } },
|
|
138
|
+
]);
|
|
139
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
140
|
+
|
|
141
|
+
await act(async () => {
|
|
142
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
143
|
+
});
|
|
144
|
+
expect(result.current.version).toBe(3);
|
|
145
|
+
|
|
146
|
+
await act(async () => {
|
|
147
|
+
await result.current.save({ data: { blog: 'b' } });
|
|
148
|
+
});
|
|
149
|
+
expect(result.current.version).toBe(4);
|
|
150
|
+
|
|
151
|
+
expect(sent.map((s) => s.headers['If-Match'])).toEqual(['"2"', '"3"']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('falls back to the ETag when the response body carries no version', async () => {
|
|
155
|
+
const { client, sent } = writer([
|
|
156
|
+
{ status: 200, body: {}, headers: new Headers({ ETag: '"8"' }) },
|
|
157
|
+
{ status: 200, body: {} },
|
|
158
|
+
]);
|
|
159
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
160
|
+
|
|
161
|
+
await act(async () => {
|
|
162
|
+
await result.current.save({ data: {} });
|
|
163
|
+
});
|
|
164
|
+
expect(result.current.version).toBe(8);
|
|
165
|
+
|
|
166
|
+
await act(async () => {
|
|
167
|
+
await result.current.save({ data: {} });
|
|
168
|
+
});
|
|
169
|
+
expect(sent[1]!.headers).toEqual({ 'If-Match': '"8"' });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('saves unguarded rather than not at all when no version is known', async () => {
|
|
173
|
+
// Every caller in production today sends none; failing closed here would be
|
|
174
|
+
// a write outage on seven live tenants.
|
|
175
|
+
const { client, sent } = writer([{ status: 200, body: { version: 1 } }]);
|
|
176
|
+
const { result } = renderHook(() => useConditionalSave({ client }));
|
|
177
|
+
|
|
178
|
+
expect(result.current.guarded).toBe(false);
|
|
179
|
+
await act(async () => {
|
|
180
|
+
expect((await result.current.save({ data: { blog: 'a' } })).status).toBe('saved');
|
|
181
|
+
});
|
|
182
|
+
expect(sent[0]!.headers).toEqual({});
|
|
183
|
+
expect(sent[0]!.body).toEqual({ data: { blog: 'a' } });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('uses the body spelling when headers cannot be set', async () => {
|
|
187
|
+
const { client, sent } = writer([{ status: 200, body: { version: 3 } }]);
|
|
188
|
+
const { result } = renderHook(() =>
|
|
189
|
+
useConditionalSave({ client, version: 2, spelling: 'body' }),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await act(async () => {
|
|
193
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
194
|
+
});
|
|
195
|
+
expect(sent[0]!.body).toEqual({ data: { blog: 'a' }, expected_version: 2 });
|
|
196
|
+
expect(sent[0]!.headers).toEqual({});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('understands a client that THROWS the 412 instead of resolving it', async () => {
|
|
200
|
+
const write = jest.fn(async () => {
|
|
201
|
+
throw { response: { status: 412, data: { current_version: 9 } } };
|
|
202
|
+
});
|
|
203
|
+
const { result } = renderHook(() => useConditionalSave({ client: { write }, version: 2 }));
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
expect((await result.current.save({ data: {} })).status).toBe('refused');
|
|
207
|
+
});
|
|
208
|
+
expect(result.current.conflict?.currentVersion).toBe(9);
|
|
209
|
+
expect(result.current.version).toBe(2);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('reports a non-412 failure rather than throwing into an autosave', async () => {
|
|
213
|
+
const boom = new Error('gateway');
|
|
214
|
+
const write = jest.fn(async () => {
|
|
215
|
+
throw boom;
|
|
216
|
+
});
|
|
217
|
+
const { result } = renderHook(() => useConditionalSave({ client: { write }, version: 2 }));
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
expect(await result.current.save({ data: {} })).toEqual({ status: 'failed', error: boom });
|
|
221
|
+
});
|
|
222
|
+
// A transport failure is not a conflict: nothing is paused and nothing pops.
|
|
223
|
+
expect(result.current.conflict).toBeNull();
|
|
224
|
+
expect(result.current.paused).toBe(false);
|
|
225
|
+
expect(result.current.lastError).toBe(boom);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('who changed it comes from the trail, not from the 412', () => {
|
|
230
|
+
it('names the actor from the newest revision row', async () => {
|
|
231
|
+
const { client } = writer([REFUSAL]);
|
|
232
|
+
const { result } = renderHook(() =>
|
|
233
|
+
useConditionalSave({ client, version: 2, revisions: trail([JURGA]) }),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await act(async () => {
|
|
237
|
+
await result.current.save({ data: {} });
|
|
238
|
+
});
|
|
239
|
+
await waitFor(() => expect(result.current.conflict?.changedBy).toBeDefined());
|
|
240
|
+
|
|
241
|
+
expect(result.current.conflict?.changedBy?.actorLabel).toBe('jurga@ogmc.example');
|
|
242
|
+
expect(result.current.conflict?.changedBy?.actorKind).toBe('person');
|
|
243
|
+
// Field names are the tenant's declared attribute names and are never recased.
|
|
244
|
+
expect(Object.keys(result.current.conflict!.changedBy!.changed).sort()).toEqual([
|
|
245
|
+
'blog',
|
|
246
|
+
'judge_verdict',
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('reads an unrecorded kind as unknown and never as a machine', async () => {
|
|
251
|
+
const { client } = writer([REFUSAL]);
|
|
252
|
+
const { result } = renderHook(() =>
|
|
253
|
+
useConditionalSave({
|
|
254
|
+
client,
|
|
255
|
+
version: 2,
|
|
256
|
+
revisions: trail([{ ...JURGA, actor_kind: undefined, actor_kind_source: undefined }]),
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await result.current.save({ data: {} });
|
|
262
|
+
});
|
|
263
|
+
await waitFor(() => expect(result.current.conflict?.changedBy).toBeDefined());
|
|
264
|
+
expect(result.current.conflict?.changedBy?.actorKind).toBe('unknown');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('opens the dialog anyway when the trail 404s, which means forbidden', async () => {
|
|
268
|
+
// The route is nested: a 404 says the PARENT record is unreadable, so
|
|
269
|
+
// history is withheld rather than absent.
|
|
270
|
+
const { client } = writer([REFUSAL]);
|
|
271
|
+
const revisions: RevisionClient = {
|
|
272
|
+
list: jest.fn(async () => {
|
|
273
|
+
throw { status: 404 };
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2, revisions }));
|
|
277
|
+
|
|
278
|
+
await act(async () => {
|
|
279
|
+
await result.current.save({ data: {} });
|
|
280
|
+
});
|
|
281
|
+
await waitFor(() => expect(result.current.conflict?.identityGap).toBe('forbidden'));
|
|
282
|
+
// Useful on current_version alone.
|
|
283
|
+
expect(result.current.conflict?.currentVersion).toBe(3);
|
|
284
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('opens the dialog with no trail client at all', async () => {
|
|
288
|
+
const { client } = writer([REFUSAL]);
|
|
289
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
290
|
+
|
|
291
|
+
await act(async () => {
|
|
292
|
+
await result.current.save({ data: {} });
|
|
293
|
+
});
|
|
294
|
+
await waitFor(() => expect(result.current.conflict?.identityGap).toBe('no-client'));
|
|
295
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('reports an empty trail as empty rather than as nobody', async () => {
|
|
299
|
+
const { client } = writer([REFUSAL]);
|
|
300
|
+
const { result } = renderHook(() =>
|
|
301
|
+
useConditionalSave({ client, version: 2, revisions: trail([]) }),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
await act(async () => {
|
|
305
|
+
await result.current.save({ data: {} });
|
|
306
|
+
});
|
|
307
|
+
await waitFor(() => expect(result.current.conflict?.identityGap).toBe('empty'));
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('resolving is a deliberate act', () => {
|
|
312
|
+
it('keepMine re-sends the refused text at the version the server named', async () => {
|
|
313
|
+
const { client, sent } = writer([REFUSAL, { status: 200, body: { version: 4 } }]);
|
|
314
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
315
|
+
const typed = { data: { blog: 'three paragraphs' } };
|
|
316
|
+
|
|
317
|
+
await act(async () => {
|
|
318
|
+
await result.current.save(typed);
|
|
319
|
+
});
|
|
320
|
+
await act(async () => {
|
|
321
|
+
expect((await result.current.keepMine()).status).toBe('saved');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(sent).toHaveLength(2);
|
|
325
|
+
// Adopting current_version is legitimate HERE and nowhere else.
|
|
326
|
+
expect(sent[1]!.headers).toEqual({ 'If-Match': '"3"' });
|
|
327
|
+
expect(sent[1]!.body).toEqual(typed);
|
|
328
|
+
expect(result.current.version).toBe(4);
|
|
329
|
+
expect(result.current.conflict).toBeNull();
|
|
330
|
+
expect(result.current.paused).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('raises a FRESH conflict when keepMine is itself refused', async () => {
|
|
334
|
+
// A third writer moved the record again while the dialog was open.
|
|
335
|
+
const { client } = writer([
|
|
336
|
+
REFUSAL,
|
|
337
|
+
{ status: 412, body: { detail: 'again', current_version: 5 } },
|
|
338
|
+
]);
|
|
339
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
340
|
+
|
|
341
|
+
await act(async () => {
|
|
342
|
+
await result.current.save({ data: { blog: 'mine' } });
|
|
343
|
+
});
|
|
344
|
+
await act(async () => {
|
|
345
|
+
expect((await result.current.keepMine()).status).toBe('refused');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(result.current.conflict?.currentVersion).toBe(5);
|
|
349
|
+
expect(result.current.conflict?.refusedBody).toEqual({ data: { blog: 'mine' } });
|
|
350
|
+
expect(result.current.paused).toBe(true);
|
|
351
|
+
expect(result.current.version).toBe(2);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('acceptTheirs clears the conflict and adopts their version for the reload', async () => {
|
|
355
|
+
const { client } = writer([REFUSAL]);
|
|
356
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
357
|
+
|
|
358
|
+
await act(async () => {
|
|
359
|
+
await result.current.save({ data: {} });
|
|
360
|
+
});
|
|
361
|
+
act(() => result.current.acceptTheirs());
|
|
362
|
+
|
|
363
|
+
expect(result.current.conflict).toBeNull();
|
|
364
|
+
expect(result.current.paused).toBe(false);
|
|
365
|
+
expect(result.current.version).toBe(3);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('re-seeds when the host loads a different record', async () => {
|
|
369
|
+
const { client } = writer([{ status: 200, body: { version: 2 } }]);
|
|
370
|
+
const { result, rerender } = renderHook(
|
|
371
|
+
({ version }: { version?: number }) => useConditionalSave({ client, version }),
|
|
372
|
+
{ initialProps: { version: undefined as number | undefined } },
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(result.current.version).toBeUndefined();
|
|
376
|
+
rerender({ version: 7 });
|
|
377
|
+
expect(result.current.version).toBe(7);
|
|
378
|
+
|
|
379
|
+
// A save's own advance is not stomped by a re-render carrying the old prop.
|
|
380
|
+
await act(async () => {
|
|
381
|
+
await result.current.save({ data: {} });
|
|
382
|
+
});
|
|
383
|
+
expect(result.current.version).toBe(2);
|
|
384
|
+
rerender({ version: 7 });
|
|
385
|
+
expect(result.current.version).toBe(2);
|
|
386
|
+
});
|
|
387
|
+
});
|