@startsimpli/ui 0.4.136 → 0.4.138
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 -4
- package/src/components/history/StaleSaveDialog.tsx +277 -0
- package/src/components/history/__tests__/conditional-write.test.ts +252 -0
- package/src/components/history/__tests__/stale-save-dialog.test.tsx +224 -0
- package/src/components/history/__tests__/use-conditional-save.test.tsx +447 -0
- package/src/components/history/conditional-write.ts +340 -0
- package/src/components/history/index.ts +64 -1
- package/src/components/history/stale-save-dialog-default-class-names.ts +45 -0
- package/src/components/history/useConditionalSave.ts +372 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record-version precondition, client side — "send the version on every
|
|
3
|
+
* write" (bd startsim-jkkn7.3; server bd startsim-3c2wc; design bd
|
|
4
|
+
* startsim-j4kx6 §6).
|
|
5
|
+
*
|
|
6
|
+
* Pure and React-free on purpose, like `revision-history.ts` beside it: the
|
|
7
|
+
* rules below are the half that decides whether a save is guarded at all, and
|
|
8
|
+
* they are worth testing without a DOM. {@link useConditionalSave} is the
|
|
9
|
+
* stateful half and {@link StaleSaveDialog} is what a person sees.
|
|
10
|
+
*
|
|
11
|
+
* ── THE POSTURE, AND WHY A MISSING VERSION MUST STILL SAVE ──────────────────
|
|
12
|
+
*
|
|
13
|
+
* The server is ENFORCED WHEN PRESENT, OPTIONAL WHEN ABSENT, and that is not a
|
|
14
|
+
* tolerance to be tidied up later — it is the only posture that could be rolled
|
|
15
|
+
* to seven live tenants in one merge, because every caller in production sends
|
|
16
|
+
* no version. So every function here fails toward "send nothing and let the
|
|
17
|
+
* write through", NEVER toward "refuse to save because we could not work out a
|
|
18
|
+
* version". A conflict guard that can make a save impossible is a worse bug
|
|
19
|
+
* than the overwrite it prevents.
|
|
20
|
+
*
|
|
21
|
+
* ── WHERE THE VERSION COMES FROM, AND THE ONE THE CONTRACT GETS WRONG ───────
|
|
22
|
+
*
|
|
23
|
+
* Three exposures exist server-side: the read-only `version` field, the `ETag`
|
|
24
|
+
* on the detail read, and the `ETag` on the WRITE response — the last being what
|
|
25
|
+
* lets a 1,200 ms autosave guard itself without a GET between every save.
|
|
26
|
+
*
|
|
27
|
+
* BUT THE WRITE RESPONSE'S HEADER IS NOT RELIABLY READABLE IN A BROWSER, and
|
|
28
|
+
* this module is the place that has to know it. `ETag` is not a CORS-safelisted
|
|
29
|
+
* response header, `tenant-starter/config/settings/base.py` sets
|
|
30
|
+
* `CORS_ALLOWED_ORIGINS` and `CORS_ALLOW_CREDENTIALS` and NO
|
|
31
|
+
* `CORS_EXPOSE_HEADERS`, and django-cors-headers defaults that to `[]`. So a
|
|
32
|
+
* fork served from its own origin reads the header fine and any cross-origin
|
|
33
|
+
* caller reads `null` — silently, and only on the write path, which is the one
|
|
34
|
+
* place a missing validator turns the guard off.
|
|
35
|
+
*
|
|
36
|
+
* The fix costs nothing, because the server stamps the header FROM the body:
|
|
37
|
+
* `EntityViewSet._offer_etag` reads `response.data["version"]`, so ETag-present
|
|
38
|
+
* and body-`version`-present are the same condition and the two can never
|
|
39
|
+
* disagree. {@link versionFromWriteOutcome} therefore reads the BODY FIRST and
|
|
40
|
+
* falls back to the header. Body-first is strictly more robust, and the
|
|
41
|
+
* server's own docstring says a client "may read either".
|
|
42
|
+
*/
|
|
43
|
+
import type { EntityRevision } from './types';
|
|
44
|
+
|
|
45
|
+
/** The header spelling. Whatever a read emitted as `ETag`, sent back. */
|
|
46
|
+
export const PRECONDITION_HEADER = 'If-Match';
|
|
47
|
+
|
|
48
|
+
/** The body spelling, for callers behind a proxy that strips `If-Match`. It is
|
|
49
|
+
* a TOP-LEVEL field and must never be folded into `data` — inside the blob it
|
|
50
|
+
* stops being a precondition and starts being stored on the record. */
|
|
51
|
+
export const PRECONDITION_BODY_FIELD = 'expected_version';
|
|
52
|
+
|
|
53
|
+
/** The refusal. 412 and not 409: the write is well-formed and would be legal,
|
|
54
|
+
* and the only thing wrong with it is the validator the caller supplied. */
|
|
55
|
+
export const STALE_SAVE_STATUS = 412;
|
|
56
|
+
|
|
57
|
+
/** Which spelling to send. `header` by default — it leaves the request body
|
|
58
|
+
* completely untouched, so there is no path by which a precondition can end up
|
|
59
|
+
* written onto the record. */
|
|
60
|
+
export type PreconditionSpelling = 'header' | 'body';
|
|
61
|
+
|
|
62
|
+
/** Anything we can read a response header out of: a real `Headers`, or the
|
|
63
|
+
* plain object an API wrapper hands back. */
|
|
64
|
+
export interface HeaderReader {
|
|
65
|
+
get(name: string): string | null | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ResponseHeaders =
|
|
69
|
+
| HeaderReader
|
|
70
|
+
| Record<string, string | null | undefined>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An integer version, and ONLY an integer.
|
|
74
|
+
*
|
|
75
|
+
* `version` IS LEGITIMATELY 0 — every record that predates the revision trail
|
|
76
|
+
* reads 0, and 0 is a version the server accepts back — so `if (version)` is a
|
|
77
|
+
* bug that silently unguards exactly the oldest records. Booleans are excluded
|
|
78
|
+
* explicitly because `Number.isInteger` already rejects them but a reader
|
|
79
|
+
* should not have to check.
|
|
80
|
+
*/
|
|
81
|
+
export function isVersion(value: unknown): value is number {
|
|
82
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Coerce what a JSON client might hand us. A numeric STRING is accepted
|
|
86
|
+
* because clients stringify integers routinely and the server honours one; a
|
|
87
|
+
* bool is not, because `expected_version: true` is not a version anybody
|
|
88
|
+
* meant. */
|
|
89
|
+
export function toVersion(value: unknown): number | undefined {
|
|
90
|
+
if (isVersion(value)) return value;
|
|
91
|
+
if (typeof value === 'string') {
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
if (/^-?\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The strong validator for a row at `version` — quoted, no `W/`, matching
|
|
99
|
+
* `apps/api/preconditions.etag`. RFC 9110 §13.1.1 requires STRONG comparison
|
|
100
|
+
* for `If-Match`, so a weak tag could not legally do this job. */
|
|
101
|
+
export function etagFor(version: number): string {
|
|
102
|
+
return `"${version}"`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function headerValue(headers: ResponseHeaders | undefined, name: string): string | undefined {
|
|
106
|
+
if (!headers) return undefined;
|
|
107
|
+
const reader = headers as HeaderReader;
|
|
108
|
+
if (typeof reader.get === 'function') {
|
|
109
|
+
const found = reader.get(name);
|
|
110
|
+
return typeof found === 'string' ? found : undefined;
|
|
111
|
+
}
|
|
112
|
+
// A plain record, whose casing we do not control.
|
|
113
|
+
const lower = name.toLowerCase();
|
|
114
|
+
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
|
115
|
+
if (key.toLowerCase() === lower && typeof value === 'string') return value;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The version an `ETag` names, or `undefined` when it names none.
|
|
122
|
+
*
|
|
123
|
+
* `*` RETURNS UNDEFINED RATHER THAN A MATCH, which mirrors the server: it
|
|
124
|
+
* asserts only that a current representation exists, so it is no guard against
|
|
125
|
+
* a lost update at all, and sending it back would record `precondition: none`
|
|
126
|
+
* on the trail for a write the client believed it had checked. A list is parsed
|
|
127
|
+
* (RFC 9110 allows `"1", "3"`) and a `W/` prefix is tolerated, because tolerating
|
|
128
|
+
* what we will never emit costs one line and a mystery 412 costs an afternoon.
|
|
129
|
+
*/
|
|
130
|
+
export function versionFromEtag(raw: unknown): number | undefined {
|
|
131
|
+
if (typeof raw !== 'string' || !raw.trim()) return undefined;
|
|
132
|
+
for (const part of raw.split(',')) {
|
|
133
|
+
let tag = part.trim();
|
|
134
|
+
if (tag.slice(0, 2).toUpperCase() === 'W/') tag = tag.slice(2).trim();
|
|
135
|
+
tag = tag.replace(/^"|"$/g, '').trim();
|
|
136
|
+
if (!tag || tag === '*') continue;
|
|
137
|
+
const version = toVersion(tag);
|
|
138
|
+
if (version !== undefined) return version;
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The version on a record payload — the read-only `version` field, which no
|
|
144
|
+
* case transform renames. */
|
|
145
|
+
export function versionFromRecord(record: unknown): number | undefined {
|
|
146
|
+
if (!record || typeof record !== 'object') return undefined;
|
|
147
|
+
return toVersion((record as { version?: unknown }).version);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The version a WRITE just produced — the one that makes the next autosave free.
|
|
152
|
+
*
|
|
153
|
+
* Body first, header second. See this module's header for why: the body field is
|
|
154
|
+
* always readable and the header is not, and the server derives one from the
|
|
155
|
+
* other so they cannot disagree.
|
|
156
|
+
*/
|
|
157
|
+
export function versionFromWriteOutcome(outcome: {
|
|
158
|
+
body?: unknown;
|
|
159
|
+
headers?: ResponseHeaders;
|
|
160
|
+
}): number | undefined {
|
|
161
|
+
const fromBody = versionFromRecord(outcome?.body);
|
|
162
|
+
if (fromBody !== undefined) return fromBody;
|
|
163
|
+
return versionFromEtag(headerValue(outcome?.headers, 'ETag'));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What a write should carry. Exactly one of the two is ever populated. */
|
|
167
|
+
export interface PreconditionParts {
|
|
168
|
+
headers: Record<string, string>;
|
|
169
|
+
/** Merge at the TOP LEVEL of the request body, never inside `data`. */
|
|
170
|
+
body: Record<string, number>;
|
|
171
|
+
/** Whether this actually asserts a version. False when no version was known —
|
|
172
|
+
* the write still goes, unguarded and visibly so (`precondition: "none"`). */
|
|
173
|
+
guards: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The precondition for a held version, in one spelling.
|
|
178
|
+
*
|
|
179
|
+
* NEVER BOTH SPELLINGS AT ONCE, and this is the subtle one. `preconditions.
|
|
180
|
+
* enforce` checks BOTH sources rather than the first it finds, and refuses if
|
|
181
|
+
* EITHER disagrees — deliberately, since a caller holding two beliefs about the
|
|
182
|
+
* row can be right about at most one. So a request carrying a stale `If-Match`
|
|
183
|
+
* beside a fresh `expected_version` refuses ITSELF, with a 412 that looks for
|
|
184
|
+
* all the world like a real conflict. One spelling per call.
|
|
185
|
+
*/
|
|
186
|
+
export function preconditionFor(
|
|
187
|
+
version: number | undefined,
|
|
188
|
+
spelling: PreconditionSpelling = 'header',
|
|
189
|
+
): PreconditionParts {
|
|
190
|
+
if (!isVersion(version)) return { headers: {}, body: {}, guards: false };
|
|
191
|
+
return spelling === 'body'
|
|
192
|
+
? { headers: {}, body: { [PRECONDITION_BODY_FIELD]: version }, guards: true }
|
|
193
|
+
: { headers: { [PRECONDITION_HEADER]: etagFor(version) }, body: {}, guards: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What the server said when it refused. Both keys are optional: the dialog has
|
|
197
|
+
* to open and be useful on `current_version` alone, and on neither. */
|
|
198
|
+
export interface StaleSaveRefusal {
|
|
199
|
+
/** The version the record is at NOW. Retrying with exactly this value lands —
|
|
200
|
+
* which is why it must survive as an INTEGER and not as `"3"`. */
|
|
201
|
+
currentVersion?: number;
|
|
202
|
+
/** The server's sentence, which names both versions and is safe to show. */
|
|
203
|
+
detail?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function statusOf(source: unknown): number | undefined {
|
|
207
|
+
if (!source || typeof source !== 'object') return undefined;
|
|
208
|
+
const candidate = source as {
|
|
209
|
+
status?: unknown;
|
|
210
|
+
statusCode?: unknown;
|
|
211
|
+
response?: { status?: unknown };
|
|
212
|
+
};
|
|
213
|
+
for (const raw of [candidate.status, candidate.statusCode, candidate.response?.status]) {
|
|
214
|
+
const value = toVersion(raw);
|
|
215
|
+
if (value !== undefined) return value;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function bodyOf(source: unknown): unknown {
|
|
221
|
+
if (!source || typeof source !== 'object') return undefined;
|
|
222
|
+
const candidate = source as {
|
|
223
|
+
body?: unknown;
|
|
224
|
+
data?: unknown;
|
|
225
|
+
response?: { data?: unknown; body?: unknown };
|
|
226
|
+
};
|
|
227
|
+
return (
|
|
228
|
+
candidate.body ??
|
|
229
|
+
candidate.data ??
|
|
230
|
+
candidate.response?.data ??
|
|
231
|
+
candidate.response?.body
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read a 412 out of an outcome OR a thrown error, or `undefined` when it is not
|
|
237
|
+
* one.
|
|
238
|
+
*
|
|
239
|
+
* BOTH SHAPES, because the two client styles in this monorepo disagree about
|
|
240
|
+
* which a failure is: a plain `fetch` resolves with `{ status: 412 }` and a
|
|
241
|
+
* wrapper throws. A guard that only understood one would let the other fall
|
|
242
|
+
* through to a generic "save failed" — losing the one piece of data the whole
|
|
243
|
+
* recovery depends on.
|
|
244
|
+
*
|
|
245
|
+
* `currentVersion` IS READ IN BOTH SPELLINGS. The shared API client camelCases
|
|
246
|
+
* every response key recursively and has no preserve list, so it renames
|
|
247
|
+
* `current_version` to `currentVersion` on the ERROR body too — same trap this
|
|
248
|
+
* module's neighbours carry, and the error path is where it would be found last.
|
|
249
|
+
*/
|
|
250
|
+
export function parseStaleSaveRefusal(source: unknown): StaleSaveRefusal | undefined {
|
|
251
|
+
if (statusOf(source) !== STALE_SAVE_STATUS) return undefined;
|
|
252
|
+
const body = bodyOf(source);
|
|
253
|
+
const record = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
|
254
|
+
const currentVersion = toVersion(record.current_version ?? record.currentVersion);
|
|
255
|
+
const detail = typeof record.detail === 'string' ? record.detail : undefined;
|
|
256
|
+
return {
|
|
257
|
+
...(currentVersion !== undefined ? { currentVersion } : {}),
|
|
258
|
+
...(detail ? { detail } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** What `/api/v1/capabilities/` says about the guard. */
|
|
263
|
+
export interface ConditionalWriteCapability {
|
|
264
|
+
/** The build enforces a precondition it is handed. */
|
|
265
|
+
conditionalWrites: boolean;
|
|
266
|
+
/** Whether a write MUST carry one. False everywhere today. */
|
|
267
|
+
preconditionRequired: boolean;
|
|
268
|
+
/** The guard does nothing on a type whose history has been silenced, and a
|
|
269
|
+
* client cannot tell which types those are — so UI copy must not promise
|
|
270
|
+
* protection per record. */
|
|
271
|
+
inertWithoutTypeHistory: boolean;
|
|
272
|
+
header: string;
|
|
273
|
+
bodyField: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The honest default for a build that has not answered: NOT GUARDED.
|
|
278
|
+
*
|
|
279
|
+
* A fork built before bd startsim-3c2wc accepts `expected_version` and silently
|
|
280
|
+
* ignores it, answering 200 either way — so a client cannot learn this from a
|
|
281
|
+
* write and must not assume it.
|
|
282
|
+
*/
|
|
283
|
+
export const CONDITIONAL_WRITES_UNKNOWN: ConditionalWriteCapability = {
|
|
284
|
+
conditionalWrites: false,
|
|
285
|
+
preconditionRequired: false,
|
|
286
|
+
inertWithoutTypeHistory: false,
|
|
287
|
+
header: PRECONDITION_HEADER,
|
|
288
|
+
bodyField: PRECONDITION_BODY_FIELD,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Read the capabilities document, defensively and in either spelling.
|
|
293
|
+
*
|
|
294
|
+
* WHAT THIS IS FOR, AND WHAT IT IS NOT FOR. It decides what the UI may CLAIM,
|
|
295
|
+
* not whether to send a precondition. Sending one to an older build is inert,
|
|
296
|
+
* not harmful — unknown body fields are ignored and an unknown header is
|
|
297
|
+
* ignored — so gating the send on this would buy nothing and would turn a
|
|
298
|
+
* failed capability fetch into an unguarded save. Send always; promise only
|
|
299
|
+
* when this says `conditionalWrites`.
|
|
300
|
+
*/
|
|
301
|
+
export function readConditionalWriteCapability(doc: unknown): ConditionalWriteCapability {
|
|
302
|
+
if (!doc || typeof doc !== 'object') return CONDITIONAL_WRITES_UNKNOWN;
|
|
303
|
+
const root = doc as Record<string, unknown>;
|
|
304
|
+
const entities = root.entities && typeof root.entities === 'object'
|
|
305
|
+
? (root.entities as Record<string, unknown>)
|
|
306
|
+
: root;
|
|
307
|
+
const precondition =
|
|
308
|
+
entities.precondition && typeof entities.precondition === 'object'
|
|
309
|
+
? (entities.precondition as Record<string, unknown>)
|
|
310
|
+
: {};
|
|
311
|
+
const flag = (...keys: string[]) =>
|
|
312
|
+
keys.some((key) => entities[key] === true || precondition[key] === true);
|
|
313
|
+
const text = (fallback: string, ...keys: string[]) => {
|
|
314
|
+
for (const key of keys) {
|
|
315
|
+
const value = precondition[key];
|
|
316
|
+
if (typeof value === 'string' && value) return value;
|
|
317
|
+
}
|
|
318
|
+
return fallback;
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
conditionalWrites: flag('conditional_writes', 'conditionalWrites'),
|
|
322
|
+
preconditionRequired: flag('precondition_required', 'preconditionRequired'),
|
|
323
|
+
inertWithoutTypeHistory: flag('inert_without_type_history', 'inertWithoutTypeHistory'),
|
|
324
|
+
header: text(PRECONDITION_HEADER, 'header'),
|
|
325
|
+
bodyField: text(PRECONDITION_BODY_FIELD, 'body_field', 'bodyField'),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Row 0 of `/api/v1/entities/<id>/revisions/` is the NEWEST — the route orders
|
|
330
|
+
* `-version, -created_at` — so the newest row is who to name in the dialog.
|
|
331
|
+
* Asserted here rather than assumed at the call site, because an ordering flip
|
|
332
|
+
* would make the dialog confidently name whoever CREATED the record. */
|
|
333
|
+
export function newestRevision(rows: EntityRevision[] | undefined): EntityRevision | undefined {
|
|
334
|
+
if (!rows || rows.length === 0) return undefined;
|
|
335
|
+
return rows.reduce((newest, row) => {
|
|
336
|
+
const a = isVersion(row?.version) ? row.version : -1;
|
|
337
|
+
const b = isVersion(newest?.version) ? newest.version : -1;
|
|
338
|
+
return a > b ? row : newest;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @startsimpli/ui/history — the
|
|
2
|
+
* @startsimpli/ui/history — the client half of record versions: the shared
|
|
3
|
+
* history panel (bd startsim-g4rwo) AND the conditional-write guard that keeps
|
|
4
|
+
* a reviewer's text when a stale save is refused (bd startsim-jkkn7.3).
|
|
3
5
|
*
|
|
4
6
|
* "Who edited what piece of the text and when", over the append-only revision
|
|
5
7
|
* trail (bd startsim-o1qib). A page-level composer plus the pure helpers it
|
|
@@ -7,6 +9,13 @@
|
|
|
7
9
|
* classNames rather than its own timeline, its own fold and its own idea of
|
|
8
10
|
* what `{}` means (rule #9).
|
|
9
11
|
*
|
|
12
|
+
* THE TWO HALVES SHIP FROM ONE SUBPATH DELIBERATELY. The conflict dialog reads
|
|
13
|
+
* the SAME trail through the SAME injected {@link RevisionClient} and names its
|
|
14
|
+
* actor through the SAME `ActorIdentity`, because the 412 carries no identity by
|
|
15
|
+
* design (bd startsim-j4kx6). A second subpath would be a second place for the
|
|
16
|
+
* three-state actor vocabulary to drift, and that vocabulary is the one thing in
|
|
17
|
+
* this epic that must not.
|
|
18
|
+
*
|
|
10
19
|
* THE ONE THING A CONSUMER MUST NOT GET WRONG: `actorKind` is THREE-state, and
|
|
11
20
|
* `unknown` is the common case for genuine human edits for up to 14 days after
|
|
12
21
|
* rollout. Render it through this panel — or through `ActorIdentity` with
|
|
@@ -47,6 +56,60 @@ export type {
|
|
|
47
56
|
|
|
48
57
|
export { diffLines, unifiedTextDiff } from './text-diff';
|
|
49
58
|
|
|
59
|
+
/* -------------------------------------------------------------------------- */
|
|
60
|
+
/* THE CONDITIONAL WRITE (bd startsim-jkkn7.3) */
|
|
61
|
+
/* */
|
|
62
|
+
/* Send the version on every write; on a 412 keep the reviewer's text, pause */
|
|
63
|
+
/* autosave and say who changed the record. The ONE contract a consumer must */
|
|
64
|
+
/* not break: never adopt `current_version` and retry on the client's own */
|
|
65
|
+
/* initiative — that is the overwrite the 412 just prevented. */
|
|
66
|
+
/* -------------------------------------------------------------------------- */
|
|
67
|
+
|
|
68
|
+
export { useConditionalSave } from './useConditionalSave';
|
|
69
|
+
export type {
|
|
70
|
+
ConditionalSave,
|
|
71
|
+
ConditionalSaveResult,
|
|
72
|
+
ConditionalWriteAttempt,
|
|
73
|
+
ConditionalWriteClient,
|
|
74
|
+
ConditionalWriteOutcome,
|
|
75
|
+
IdentityGap,
|
|
76
|
+
StaleSaveConflict,
|
|
77
|
+
UseConditionalSaveOptions,
|
|
78
|
+
} from './useConditionalSave';
|
|
79
|
+
|
|
80
|
+
export { StaleSaveDialog } from './StaleSaveDialog';
|
|
81
|
+
export type { StaleSaveDialogProps } from './StaleSaveDialog';
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
STALE_SAVE_DIALOG_DEFAULTS,
|
|
85
|
+
type StaleSaveDialogClassNames,
|
|
86
|
+
} from './stale-save-dialog-default-class-names';
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
CONDITIONAL_WRITES_UNKNOWN,
|
|
90
|
+
PRECONDITION_BODY_FIELD,
|
|
91
|
+
PRECONDITION_HEADER,
|
|
92
|
+
STALE_SAVE_STATUS,
|
|
93
|
+
etagFor,
|
|
94
|
+
isVersion,
|
|
95
|
+
newestRevision,
|
|
96
|
+
parseStaleSaveRefusal,
|
|
97
|
+
preconditionFor,
|
|
98
|
+
readConditionalWriteCapability,
|
|
99
|
+
toVersion,
|
|
100
|
+
versionFromEtag,
|
|
101
|
+
versionFromRecord,
|
|
102
|
+
versionFromWriteOutcome,
|
|
103
|
+
} from './conditional-write';
|
|
104
|
+
export type {
|
|
105
|
+
ConditionalWriteCapability,
|
|
106
|
+
HeaderReader,
|
|
107
|
+
PreconditionParts,
|
|
108
|
+
PreconditionSpelling,
|
|
109
|
+
ResponseHeaders,
|
|
110
|
+
StaleSaveRefusal,
|
|
111
|
+
} from './conditional-write';
|
|
112
|
+
|
|
50
113
|
export type {
|
|
51
114
|
ActorKind,
|
|
52
115
|
ActorKindSource,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link StaleSaveDialog}. bd startsim-jkkn7.3.
|
|
2
|
+
* Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe, and no
|
|
3
|
+
* literal colour, so a dark tenant theme recolours this without a fork. */
|
|
4
|
+
|
|
5
|
+
export interface StaleSaveDialogClassNames {
|
|
6
|
+
backdrop?: string;
|
|
7
|
+
dialog?: string;
|
|
8
|
+
header?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
/** The line that tells the reviewer their text is still here. */
|
|
11
|
+
reassurance?: string;
|
|
12
|
+
body?: string;
|
|
13
|
+
/** The server's sentence, and the "we could not say who" sentences. */
|
|
14
|
+
notice?: string;
|
|
15
|
+
fieldList?: string;
|
|
16
|
+
field?: string;
|
|
17
|
+
footer?: string;
|
|
18
|
+
primaryAction?: string;
|
|
19
|
+
secondaryAction?: string;
|
|
20
|
+
/** The one action that throws the reviewer's own text away. */
|
|
21
|
+
destructiveAction?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BUTTON = 'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium disabled:opacity-50';
|
|
25
|
+
|
|
26
|
+
export const STALE_SAVE_DIALOG_DEFAULTS: Required<StaleSaveDialogClassNames> = {
|
|
27
|
+
backdrop: 'fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4',
|
|
28
|
+
dialog: 'w-full max-w-lg rounded-lg border border-border bg-card shadow-lg',
|
|
29
|
+
header: 'space-y-2 border-b border-border px-5 py-4',
|
|
30
|
+
title: 'flex flex-wrap items-center gap-x-1.5 gap-y-1 font-medium text-foreground',
|
|
31
|
+
// NOT a warning colour. The reviewer's work is safe; this sentence is the
|
|
32
|
+
// reassurance, and painting it like a failure would say the opposite.
|
|
33
|
+
reassurance: 'text-sm text-foreground',
|
|
34
|
+
body: 'space-y-3 px-5 py-4',
|
|
35
|
+
notice: 'text-xs text-muted-foreground',
|
|
36
|
+
fieldList: 'flex flex-wrap gap-1',
|
|
37
|
+
field: 'rounded bg-muted px-1.5 py-0.5 text-xs text-foreground',
|
|
38
|
+
footer: 'flex flex-wrap items-center justify-end gap-2 border-t border-border px-5 py-3',
|
|
39
|
+
primaryAction: `${BUTTON} bg-primary text-primary-foreground`,
|
|
40
|
+
secondaryAction: `${BUTTON} border border-border bg-background text-foreground`,
|
|
41
|
+
// Underplayed ON PURPOSE. It is the only control here that destroys the text
|
|
42
|
+
// this dialog exists to protect, so it must never be the thing a hurried
|
|
43
|
+
// reviewer clicks by muscle memory.
|
|
44
|
+
destructiveAction: 'text-xs text-muted-foreground underline underline-offset-2',
|
|
45
|
+
};
|