@startsimpli/ui 0.4.136 → 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.
@@ -0,0 +1,356 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useConditionalSave — every write carries the version it loaded, and a refusal
5
+ * KEEPS THE REVIEWER'S TEXT (bd startsim-jkkn7.3; server bd startsim-3c2wc).
6
+ *
7
+ * The loss this closes, as a person experiences it: two reviewers open one
8
+ * draft, the second save carries the first reviewer's field as the second page
9
+ * loaded it, the rewrite is gone and the response is 200. The server now refuses
10
+ * that second save with a 412 — and this hook is the half that decides what
11
+ * happens to the three paragraphs the reviewer had just typed.
12
+ *
13
+ * ── THE ONE INVARIANT, AND IT IS THE WHOLE BEAD ─────────────────────────────
14
+ *
15
+ * A 412 NEVER ADVANCES THE HELD VERSION, AND NEVER RETRIES.
16
+ *
17
+ * The tempting shortcut is to read `current_version` off the refusal, adopt it,
18
+ * and re-send. It makes the error go away and every happy-path test still
19
+ * passes — and it reduces the guard to a decoration, because a blind retry at
20
+ * the current version is EXACTLY the overwrite the 412 just prevented, with one
21
+ * extra round-trip. So the held version advances from one place only: a write
22
+ * that SUCCEEDED. Adopting `current_version` is a deliberate act a person takes
23
+ * ({@link ConditionalSave.keepMine} / {@link ConditionalSave.acceptTheirs}), and
24
+ * `a refusal does not advance the held version` is a test, not a comment.
25
+ *
26
+ * ── WHAT "KEEP THE USER'S TEXT" MEANS MECHANICALLY ──────────────────────────
27
+ *
28
+ * Three things, because the promise is worth nothing as an intention:
29
+ *
30
+ * 1. THIS HOOK NEVER OWNS THE DRAFT and never clears, resets or reverts it. A
31
+ * refused save touches no local state but the conflict itself, so whatever is
32
+ * in the editor stays in the editor.
33
+ * 2. THE REFUSED PAYLOAD IS RETAINED, on {@link StaleSaveConflict.refusedBody}.
34
+ * This is the part that survives the ACCIDENTAL path, which is not the user
35
+ * clicking something rash — it is the host refetching the record to show
36
+ * "what changed" and its own load-into-editor effect overwriting the text
37
+ * with the server's copy. The bytes are still here when that happens, so the
38
+ * reviewer's work is recoverable rather than merely promised.
39
+ * 3. AUTOSAVE PAUSES AND THE CONFLICT SURFACES ONCE. The draft editor debounces
40
+ * at 1,200 ms, so a 412 that neither paused nor deduplicated would repaint a
41
+ * modal on every keystroke burst — worse than no dialog at all. While
42
+ * {@link ConditionalSave.paused} holds, `save` returns without sending.
43
+ *
44
+ * DISMISSING IS NOT RESOLVING. Escape, the backdrop and the close button all
45
+ * land on {@link ConditionalSave.dismissConflict}, which closes the dialog and
46
+ * leaves the conflict and the pause in place. The reviewer keeps their text and
47
+ * can still reopen; what they cannot do is wander back into autosaving over
48
+ * somebody else's change because they pressed Escape.
49
+ */
50
+ import { useCallback, useEffect, useRef, useState } from 'react';
51
+
52
+ import {
53
+ isVersion,
54
+ parseStaleSaveRefusal,
55
+ preconditionFor,
56
+ versionFromWriteOutcome,
57
+ newestRevision,
58
+ type PreconditionSpelling,
59
+ type ResponseHeaders,
60
+ } from './conditional-write';
61
+ import { normalizeRevision, type NormalizedRevision } from './revision-history';
62
+ import type { RevisionClient } from './types';
63
+
64
+ /** What the host sends. Merge `headers` into the request headers and `body` is
65
+ * the request body — the precondition is already in whichever one the chosen
66
+ * spelling uses. */
67
+ export interface ConditionalWriteAttempt {
68
+ body: Record<string, unknown>;
69
+ headers: Record<string, string>;
70
+ }
71
+
72
+ /** What the host hands back. A plain `fetch` adapter is about eight lines. */
73
+ export interface ConditionalWriteOutcome {
74
+ status?: number;
75
+ body?: unknown;
76
+ headers?: ResponseHeaders;
77
+ }
78
+
79
+ /**
80
+ * The one call this hook needs, injected exactly like {@link RevisionClient} —
81
+ * which is what keeps @startsimpli/api out of packages/ui.
82
+ *
83
+ * It may RESOLVE with a 412 outcome or THROW one; both are understood, because
84
+ * a plain `fetch` does the first and a wrapper does the second.
85
+ */
86
+ export interface ConditionalWriteClient {
87
+ write: (attempt: ConditionalWriteAttempt) => Promise<ConditionalWriteOutcome>;
88
+ }
89
+
90
+ /** Why the dialog cannot name a person. Each reads differently to a reviewer,
91
+ * and none of them may be rendered as "a machine did this". */
92
+ export type IdentityGap = 'forbidden' | 'empty' | 'error' | 'no-client';
93
+
94
+ export interface StaleSaveConflict {
95
+ /** The version the record is at NOW, straight off the refusal. */
96
+ currentVersion?: number;
97
+ /** The server's sentence. It names both versions and is safe to show. */
98
+ detail?: string;
99
+ /** The version the editor was holding when it was refused. */
100
+ sentVersion?: number;
101
+ /**
102
+ * THE REVIEWER'S TEXT, as the server refused it. Retained so it is
103
+ * recoverable even if the host reloaded the server's copy over the editor.
104
+ */
105
+ refusedBody: Record<string, unknown>;
106
+ /** The newest row of the trail — who changed it, when, and which fields.
107
+ * Absent whenever {@link identityGap} is set. */
108
+ changedBy?: NormalizedRevision;
109
+ /** Set when the trail could not say who. The dialog still works. */
110
+ identityGap?: IdentityGap;
111
+ }
112
+
113
+ export type ConditionalSaveResult =
114
+ | { status: 'saved'; version?: number; outcome: ConditionalWriteOutcome }
115
+ | { status: 'refused'; conflict: StaleSaveConflict }
116
+ /** A conflict is already open: nothing was sent. */
117
+ | { status: 'paused' }
118
+ | { status: 'failed'; error: unknown };
119
+
120
+ export interface UseConditionalSaveOptions {
121
+ client: ConditionalWriteClient;
122
+ /** The version the record was loaded at. Seeding this is what makes the FIRST
123
+ * save guarded; without it the first write is unguarded and the trail says
124
+ * so (`precondition: "none"`). */
125
+ version?: number;
126
+ /** Reads `/api/v1/entities/<id>/revisions/` for the human-readable half. The
127
+ * 412 carries no identity by design (bd startsim-j4kx6) — without this the
128
+ * dialog still opens and simply does not name anyone. */
129
+ revisions?: RevisionClient;
130
+ /** `header` (default) sends `If-Match` and leaves the body untouched. */
131
+ spelling?: PreconditionSpelling;
132
+ }
133
+
134
+ export interface ConditionalSave {
135
+ /** The version every write will assert. `undefined` means unguarded. */
136
+ version?: number;
137
+ /** Seed or re-seed it — on load, and after the host reloads the record. */
138
+ setVersion: (version: number | undefined) => void;
139
+ /** Whether the next save will actually assert a version. */
140
+ guarded: boolean;
141
+ save: (body: Record<string, unknown>) => Promise<ConditionalSaveResult>;
142
+ conflict: StaleSaveConflict | null;
143
+ /** An unresolved conflict: autosave must not fire. Stays true across a
144
+ * dismissal, because dismissing is not resolving. */
145
+ paused: boolean;
146
+ /** Whether the dialog should be on screen. */
147
+ dialogOpen: boolean;
148
+ /** Close the dialog. KEEPS the text, the conflict and the pause. */
149
+ dismissConflict: () => void;
150
+ reopenConflict: () => void;
151
+ /** Deliberately overwrite their change with the refused text, at the version
152
+ * the server named. The only path that adopts `current_version`. */
153
+ keepMine: () => Promise<ConditionalSaveResult>;
154
+ /** Give up the local edit: clear the conflict and adopt their version so the
155
+ * host can reload. Destructive to the reviewer's text BY DESIGN, which is
156
+ * why nothing but an explicit choice reaches it. */
157
+ acceptTheirs: () => void;
158
+ /** A `keepMine` is in flight. */
159
+ resolving: boolean;
160
+ /** The last non-412 failure, since `save` reports rather than throws. */
161
+ lastError: unknown;
162
+ }
163
+
164
+ export function useConditionalSave({
165
+ client,
166
+ version: initialVersion,
167
+ revisions,
168
+ spelling = 'header',
169
+ }: UseConditionalSaveOptions): ConditionalSave {
170
+ const [version, setVersionState] = useState<number | undefined>(initialVersion);
171
+ const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
172
+ const [dismissed, setDismissed] = useState(false);
173
+ const [resolving, setResolving] = useState(false);
174
+ const [lastError, setLastError] = useState<unknown>(null);
175
+
176
+ // Read inside `save` so a stale closure cannot send a version the hook has
177
+ // already moved past, and so `paused` is evaluated against the live conflict
178
+ // rather than the one captured when the debounce was scheduled.
179
+ const versionRef = useRef(version);
180
+ versionRef.current = version;
181
+ const conflictRef = useRef(conflict);
182
+ conflictRef.current = conflict;
183
+
184
+ // The identity fetch resolves after the conflict is already on screen, so it
185
+ // must not write into a hook that has unmounted or already moved on.
186
+ const alive = useRef(true);
187
+ useEffect(() => {
188
+ alive.current = true;
189
+ return () => {
190
+ alive.current = false;
191
+ };
192
+ }, []);
193
+
194
+ const setVersion = useCallback((next: number | undefined) => {
195
+ setVersionState(isVersion(next) ? next : undefined);
196
+ }, []);
197
+
198
+ // Re-seed when the host loads a different record. Guarded on the value so a
199
+ // save's own advance is not stomped by a re-render carrying the old prop.
200
+ const seeded = useRef(initialVersion);
201
+ useEffect(() => {
202
+ if (seeded.current !== initialVersion) {
203
+ seeded.current = initialVersion;
204
+ setVersionState(isVersion(initialVersion) ? initialVersion : undefined);
205
+ }
206
+ }, [initialVersion]);
207
+
208
+ /** Fill in "who changed it" after the dialog is already up. Never blocks the
209
+ * conflict from surfacing, and never throws into the save path. */
210
+ const resolveIdentity = useCallback(
211
+ async (key: StaleSaveConflict) => {
212
+ const patch = (gap: IdentityGap) => {
213
+ if (!alive.current) return;
214
+ setConflict((current) => (current === key ? { ...current, identityGap: gap } : current));
215
+ };
216
+ if (!revisions) return patch('no-client');
217
+ try {
218
+ const page = await revisions.list({ page: 1, pageSize: 1 });
219
+ const row = newestRevision(page?.results);
220
+ if (!alive.current) return;
221
+ if (!row) return patch('empty');
222
+ setConflict((current) =>
223
+ current === key ? { ...current, changedBy: normalizeRevision(row) } : current,
224
+ );
225
+ } catch (error) {
226
+ // A 404 on this nested route means the caller may not read the PARENT
227
+ // record, so history is withheld rather than absent — a different
228
+ // sentence, and never an empty trail.
229
+ patch(parseStatus(error) === 404 ? 'forbidden' : 'error');
230
+ }
231
+ },
232
+ [revisions],
233
+ );
234
+
235
+ const refuse = useCallback(
236
+ (
237
+ refusedBody: Record<string, unknown>,
238
+ sent: number | undefined,
239
+ refusal: { currentVersion?: number; detail?: string },
240
+ ): ConditionalSaveResult => {
241
+ const next: StaleSaveConflict = {
242
+ ...refusal,
243
+ ...(sent !== undefined ? { sentVersion: sent } : {}),
244
+ refusedBody,
245
+ };
246
+ if (alive.current) {
247
+ // NOTE WHAT IS NOT HERE: setVersionState. The refusal names the current
248
+ // version and this hook deliberately declines to take it.
249
+ setConflict(next);
250
+ setDismissed(false);
251
+ setLastError(null);
252
+ void resolveIdentity(next);
253
+ }
254
+ return { status: 'refused', conflict: next };
255
+ },
256
+ [resolveIdentity],
257
+ );
258
+
259
+ const attempt = useCallback(
260
+ async (
261
+ body: Record<string, unknown>,
262
+ assertVersion: number | undefined,
263
+ ): Promise<ConditionalSaveResult> => {
264
+ const parts = preconditionFor(assertVersion, spelling);
265
+ try {
266
+ const outcome = await client.write({
267
+ body: { ...body, ...parts.body },
268
+ headers: { ...parts.headers },
269
+ });
270
+ const refusal = parseStaleSaveRefusal(outcome);
271
+ if (refusal) return refuse(body, assertVersion, refusal);
272
+ const next = versionFromWriteOutcome(outcome ?? {});
273
+ if (alive.current) {
274
+ // THE ONLY PLACE THE HELD VERSION ADVANCES.
275
+ if (next !== undefined) setVersionState(next);
276
+ setConflict(null);
277
+ setDismissed(false);
278
+ setLastError(null);
279
+ }
280
+ return { status: 'saved', ...(next !== undefined ? { version: next } : {}), outcome };
281
+ } catch (error) {
282
+ const refusal = parseStaleSaveRefusal(error);
283
+ if (refusal) return refuse(body, assertVersion, refusal);
284
+ if (alive.current) setLastError(error);
285
+ return { status: 'failed', error };
286
+ }
287
+ },
288
+ [client, refuse, spelling],
289
+ );
290
+
291
+ const save = useCallback(
292
+ async (body: Record<string, unknown>): Promise<ConditionalSaveResult> => {
293
+ // Surfaced once, not retried. An unresolved conflict means the reviewer
294
+ // still has a decision to make and their keystrokes must not re-send.
295
+ if (conflictRef.current) return { status: 'paused' };
296
+ return attempt(body, versionRef.current);
297
+ },
298
+ [attempt],
299
+ );
300
+
301
+ const keepMine = useCallback(async (): Promise<ConditionalSaveResult> => {
302
+ const current = conflictRef.current;
303
+ if (!current) return { status: 'paused' };
304
+ // Adopting `current_version` is legitimate HERE and nowhere else: a person
305
+ // has been told what changed and has chosen to replace it.
306
+ const target = isVersion(current.currentVersion) ? current.currentVersion : undefined;
307
+ // The pause is NOT lifted here. `save` stays refused for the whole of this
308
+ // round-trip, so a debounce that fires mid-resolution cannot send the same
309
+ // body a second time and race the write this call is already making.
310
+ setResolving(true);
311
+ try {
312
+ return await attempt(current.refusedBody, target);
313
+ } finally {
314
+ if (alive.current) setResolving(false);
315
+ }
316
+ }, [attempt]);
317
+
318
+ const acceptTheirs = useCallback(() => {
319
+ const current = conflictRef.current;
320
+ setVersionState(isVersion(current?.currentVersion) ? current!.currentVersion : undefined);
321
+ setConflict(null);
322
+ setDismissed(false);
323
+ }, []);
324
+
325
+ const dismissConflict = useCallback(() => setDismissed(true), []);
326
+ const reopenConflict = useCallback(() => setDismissed(false), []);
327
+
328
+ return {
329
+ ...(version !== undefined ? { version } : {}),
330
+ setVersion,
331
+ guarded: isVersion(version),
332
+ save,
333
+ conflict,
334
+ paused: conflict !== null,
335
+ dialogOpen: conflict !== null && !dismissed,
336
+ dismissConflict,
337
+ reopenConflict,
338
+ keepMine,
339
+ acceptTheirs,
340
+ resolving,
341
+ lastError,
342
+ };
343
+ }
344
+
345
+ function parseStatus(error: unknown): number | undefined {
346
+ if (!error || typeof error !== 'object') return undefined;
347
+ const candidate = error as {
348
+ status?: unknown;
349
+ statusCode?: unknown;
350
+ response?: { status?: unknown };
351
+ };
352
+ for (const raw of [candidate.status, candidate.statusCode, candidate.response?.status]) {
353
+ if (typeof raw === 'number') return raw;
354
+ }
355
+ return undefined;
356
+ }