@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.
- package/package.json +5 -5
- 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 +387 -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 +356 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.137",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
49
49
|
"react": "^18.0.0 || ^19.0.0",
|
|
50
50
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
51
|
-
"@startsimpli/api": "^0.5.66",
|
|
52
51
|
"@startsimpli/auth": "^0.4.38",
|
|
53
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
52
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
53
|
+
"@startsimpli/api": "^0.5.66"
|
|
54
54
|
},
|
|
55
55
|
"peerDependenciesMeta": {
|
|
56
56
|
"@startsimpli/api": {
|
|
@@ -124,8 +124,8 @@
|
|
|
124
124
|
"tailwindcss": "^3.4.19",
|
|
125
125
|
"tsup": "^8.5.1",
|
|
126
126
|
"typescript": "^6.0.3",
|
|
127
|
-
"@startsimpli/
|
|
128
|
-
"@startsimpli/
|
|
127
|
+
"@startsimpli/i18n": "0.1.3",
|
|
128
|
+
"@startsimpli/realtime": "0.0.2"
|
|
129
129
|
},
|
|
130
130
|
"keywords": [
|
|
131
131
|
"ui",
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StaleSaveDialog — "Jurga changed this while you were editing", and your three
|
|
5
|
+
* paragraphs are still on screen (bd startsim-jkkn7.3).
|
|
6
|
+
*
|
|
7
|
+
* The refusal this renders is the right outcome: the server declined to let one
|
|
8
|
+
* reviewer's save destroy another's text. But a refusal that DISCARDS the typed
|
|
9
|
+
* work is worse than the overwrite it prevented — at least the overwrite kept
|
|
10
|
+
* somebody's text — so the first thing this dialog says is that nothing was
|
|
11
|
+
* lost, before it says anything about versions.
|
|
12
|
+
*
|
|
13
|
+
* ── PURE ON PURPOSE ─────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* No data fetching, no react-query, and therefore NO `QueryClientProvider`
|
|
16
|
+
* requirement. {@link useConditionalSave} has already resolved who changed the
|
|
17
|
+
* record by the time this mounts. That split is deliberate: this component only
|
|
18
|
+
* ever appears when a save has just been refused, which is the worst possible
|
|
19
|
+
* moment to discover that a modal throws because of a missing provider.
|
|
20
|
+
*
|
|
21
|
+
* ── WHY THE SAFE ACTION IS THE DEFAULT ──────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* Three things a reviewer can do, and they are ordered by how recoverable they
|
|
24
|
+
* are rather than by how likely:
|
|
25
|
+
*
|
|
26
|
+
* - SEE WHAT CHANGED — default focus. Non-destructive, and it is the only one of
|
|
27
|
+
* the three that makes the other two informed choices.
|
|
28
|
+
* - SAVE MINE ANYWAY — legitimate, and labelled with what it does to the other
|
|
29
|
+
* person's change rather than as a neutral "retry". This is the only path that
|
|
30
|
+
* adopts the version named in the refusal; a client that adopted it
|
|
31
|
+
* automatically would have reduced the guard to a decoration.
|
|
32
|
+
* - DISCARD MY EDITS — the only control that destroys the text this dialog
|
|
33
|
+
* exists to protect, so it is a de-emphasised link and never a button.
|
|
34
|
+
*
|
|
35
|
+
* ESCAPE, THE BACKDROP AND THE CLOSE BUTTON ARE NOT A FOURTH OPTION. They
|
|
36
|
+
* dismiss to the editor with the text intact and autosave still paused. That is
|
|
37
|
+
* the accidental path, and it must not resolve anything.
|
|
38
|
+
*
|
|
39
|
+
* ── AND THE SENTENCE IT REFUSES TO SAY ──────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* `actorKind: 'unknown'` means CENTRAL DID NOT SAY — the claim is minted at
|
|
42
|
+
* login, so for up to the 14-day refresh expiry after rollout a genuine human
|
|
43
|
+
* edit carries no kind at all. It is rendered through `ActorIdentity`, which
|
|
44
|
+
* marks a machine and stays quiet otherwise, so an unrecorded kind NEVER reads
|
|
45
|
+
* as "a machine did this". That conflation is the specific confusion this epic
|
|
46
|
+
* exists to end, and it would be worst here, where the reviewer is deciding
|
|
47
|
+
* whether to overwrite somebody.
|
|
48
|
+
*/
|
|
49
|
+
import { useEffect, useRef, type ReactNode } from 'react';
|
|
50
|
+
|
|
51
|
+
import { ActorIdentity } from '../ActorIdentity';
|
|
52
|
+
import type { StaleSaveConflict } from './useConditionalSave';
|
|
53
|
+
import {
|
|
54
|
+
STALE_SAVE_DIALOG_DEFAULTS,
|
|
55
|
+
type StaleSaveDialogClassNames,
|
|
56
|
+
} from './stale-save-dialog-default-class-names';
|
|
57
|
+
|
|
58
|
+
export interface StaleSaveDialogProps {
|
|
59
|
+
conflict: StaleSaveConflict | null;
|
|
60
|
+
/** Usually `dialogOpen` off {@link useConditionalSave}. */
|
|
61
|
+
open?: boolean;
|
|
62
|
+
/** What the record is called, for the headline — "this draft", "this topic".
|
|
63
|
+
* Generic by default so a fork that forgets still reads correctly. */
|
|
64
|
+
recordLabel?: string;
|
|
65
|
+
/** Escape, the backdrop and the close button. KEEPS the text and the pause. */
|
|
66
|
+
onDismiss: () => void;
|
|
67
|
+
/** Overwrite their change with the refused text. Omit to hide the action. */
|
|
68
|
+
onKeepMine?: () => void;
|
|
69
|
+
/** Throw the local edit away and load theirs. Omit to hide the action. */
|
|
70
|
+
onAcceptTheirs?: () => void;
|
|
71
|
+
/** Show the host's own diff or history panel. Omit to hide the action. */
|
|
72
|
+
onReviewChange?: () => void;
|
|
73
|
+
/** A resolution is in flight. */
|
|
74
|
+
busy?: boolean;
|
|
75
|
+
/** Map a wire field name to something a reader recognises. Identity by
|
|
76
|
+
* default — this module never re-cases a declared attribute name. */
|
|
77
|
+
fieldLabel?: (field: string) => string;
|
|
78
|
+
/** Extra content, below the fields and above the footer. */
|
|
79
|
+
children?: ReactNode;
|
|
80
|
+
classNames?: StaleSaveDialogClassNames;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A timestamp a person can read, falling back to the raw string rather than to
|
|
84
|
+
* "Invalid Date" — a row from an older build may carry anything. */
|
|
85
|
+
function when(iso: string): string {
|
|
86
|
+
if (!iso) return '';
|
|
87
|
+
const parsed = new Date(iso);
|
|
88
|
+
return Number.isNaN(parsed.getTime()) ? iso : parsed.toLocaleString();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Why we cannot name a person — one sentence each, and not one of them
|
|
92
|
+
* speculates about a machine. */
|
|
93
|
+
function identityNotice(conflict: StaleSaveConflict): string {
|
|
94
|
+
switch (conflict.identityGap) {
|
|
95
|
+
case 'forbidden':
|
|
96
|
+
// The revisions route is nested, so a 404 means the PARENT record is
|
|
97
|
+
// unreadable to this principal — history is withheld, not absent.
|
|
98
|
+
return 'This record’s history is not available to you, so we cannot show who changed it.';
|
|
99
|
+
case 'empty':
|
|
100
|
+
return 'No change was recorded for this record, so we cannot show who changed it.';
|
|
101
|
+
case 'error':
|
|
102
|
+
return 'We could not load who changed it. Your edits are unaffected.';
|
|
103
|
+
case 'no-client':
|
|
104
|
+
return '';
|
|
105
|
+
default:
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function StaleSaveDialog({
|
|
111
|
+
conflict,
|
|
112
|
+
open = true,
|
|
113
|
+
recordLabel = 'this record',
|
|
114
|
+
onDismiss,
|
|
115
|
+
onKeepMine,
|
|
116
|
+
onAcceptTheirs,
|
|
117
|
+
onReviewChange,
|
|
118
|
+
busy = false,
|
|
119
|
+
fieldLabel = (field) => field,
|
|
120
|
+
children,
|
|
121
|
+
classNames,
|
|
122
|
+
}: StaleSaveDialogProps) {
|
|
123
|
+
const css = { ...STALE_SAVE_DIALOG_DEFAULTS, ...classNames };
|
|
124
|
+
const focusTarget = useRef<HTMLButtonElement | null>(null);
|
|
125
|
+
const visible = open && conflict !== null;
|
|
126
|
+
|
|
127
|
+
// Escape dismisses. Bound on the document rather than the dialog so it works
|
|
128
|
+
// before anything inside has been focused.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!visible) return;
|
|
131
|
+
const onKey = (event: KeyboardEvent) => {
|
|
132
|
+
if (event.key === 'Escape') onDismiss();
|
|
133
|
+
};
|
|
134
|
+
document.addEventListener('keydown', onKey);
|
|
135
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
136
|
+
}, [visible, onDismiss]);
|
|
137
|
+
|
|
138
|
+
// Focus lands on the SAFE action, never on one that writes or discards.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (visible) focusTarget.current?.focus();
|
|
141
|
+
}, [visible]);
|
|
142
|
+
|
|
143
|
+
if (!visible || !conflict) return null;
|
|
144
|
+
|
|
145
|
+
const actor = conflict.changedBy;
|
|
146
|
+
const label = (actor?.actorLabel ?? '').trim();
|
|
147
|
+
const fields = actor ? Object.keys(actor.changed).sort() : [];
|
|
148
|
+
const notice = identityNotice(conflict);
|
|
149
|
+
const at = actor ? when(actor.createdAt) : '';
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
className={css.backdrop}
|
|
154
|
+
data-testid="stale-save-backdrop"
|
|
155
|
+
// A click on the backdrop is a dismissal, not a resolution. Guarded on the
|
|
156
|
+
// target so a click that merely BUBBLES out of the dialog does not close it.
|
|
157
|
+
onClick={(event) => {
|
|
158
|
+
if (event.target === event.currentTarget) onDismiss();
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<div
|
|
162
|
+
role="dialog"
|
|
163
|
+
aria-modal="true"
|
|
164
|
+
aria-labelledby="stale-save-title"
|
|
165
|
+
className={css.dialog}
|
|
166
|
+
data-testid="stale-save-dialog"
|
|
167
|
+
>
|
|
168
|
+
<div className={css.header}>
|
|
169
|
+
<h2 id="stale-save-title" data-testid="stale-save-title" className={css.title}>
|
|
170
|
+
{label ? (
|
|
171
|
+
<>
|
|
172
|
+
<ActorIdentity
|
|
173
|
+
actor={{ email: label }}
|
|
174
|
+
showInitials={false}
|
|
175
|
+
{...(actor?.actorKind ? { kind: actor.actorKind } : {})}
|
|
176
|
+
{...(actor?.actorKindSource ? { kindSource: actor.actorKindSource } : {})}
|
|
177
|
+
/>
|
|
178
|
+
<span>changed {recordLabel} while you were editing.</span>
|
|
179
|
+
</>
|
|
180
|
+
) : (
|
|
181
|
+
/* NEVER "someone", and never a guess at a machine. The record
|
|
182
|
+
changed; who is a separate question this dialog answers only
|
|
183
|
+
when the trail actually said. */
|
|
184
|
+
<span>{`${recordLabel.charAt(0).toUpperCase()}${recordLabel.slice(1)} changed while you were editing.`}</span>
|
|
185
|
+
)}
|
|
186
|
+
</h2>
|
|
187
|
+
{/* THE LOAD-BEARING SENTENCE, and it comes first for that reason. */}
|
|
188
|
+
<p className={css.reassurance} data-testid="stale-save-reassurance">
|
|
189
|
+
Your edits are still here and have not been saved.
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className={css.body}>
|
|
194
|
+
{at && (
|
|
195
|
+
<p className={css.notice} data-testid="stale-save-when">
|
|
196
|
+
Changed {at}.
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{fields.length > 0 && (
|
|
201
|
+
<div className={css.fieldList} data-testid="stale-save-fields">
|
|
202
|
+
{fields.map((field) => (
|
|
203
|
+
/* Rendered EXACTLY as received. These are the tenant's declared
|
|
204
|
+
attribute names and there is no safe inverse for a camelising
|
|
205
|
+
client, so guessing one would print a name nobody declared. */
|
|
206
|
+
<code key={field} className={css.field}>
|
|
207
|
+
{fieldLabel(field)}
|
|
208
|
+
</code>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{notice && (
|
|
214
|
+
<p className={css.notice} data-testid="stale-save-identity-gap">
|
|
215
|
+
{notice}
|
|
216
|
+
</p>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{conflict.detail && (
|
|
220
|
+
<p className={css.notice} data-testid="stale-save-detail">
|
|
221
|
+
{conflict.detail}
|
|
222
|
+
</p>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{children}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div className={css.footer}>
|
|
229
|
+
{onAcceptTheirs && (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
className={css.destructiveAction}
|
|
233
|
+
data-testid="stale-save-accept-theirs"
|
|
234
|
+
disabled={busy}
|
|
235
|
+
onClick={onAcceptTheirs}
|
|
236
|
+
>
|
|
237
|
+
Discard my edits and load theirs
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
/* Focus falls here only when there is nothing to review. Either way
|
|
243
|
+
it lands on a control that cannot write and cannot discard. */
|
|
244
|
+
ref={onReviewChange ? undefined : focusTarget}
|
|
245
|
+
className={css.secondaryAction}
|
|
246
|
+
data-testid="stale-save-dismiss"
|
|
247
|
+
onClick={onDismiss}
|
|
248
|
+
>
|
|
249
|
+
Keep editing
|
|
250
|
+
</button>
|
|
251
|
+
{onReviewChange && (
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
ref={focusTarget}
|
|
255
|
+
className={css.secondaryAction}
|
|
256
|
+
data-testid="stale-save-review"
|
|
257
|
+
onClick={onReviewChange}
|
|
258
|
+
>
|
|
259
|
+
See what changed
|
|
260
|
+
</button>
|
|
261
|
+
)}
|
|
262
|
+
{onKeepMine && (
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
className={css.primaryAction}
|
|
266
|
+
data-testid="stale-save-keep-mine"
|
|
267
|
+
disabled={busy}
|
|
268
|
+
onClick={onKeepMine}
|
|
269
|
+
>
|
|
270
|
+
{busy ? 'Saving…' : 'Save mine, replacing their change'}
|
|
271
|
+
</button>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The precondition rules (bd startsim-jkkn7.3; server bd startsim-3c2wc).
|
|
3
|
+
*
|
|
4
|
+
* Every case here is pinned against `tenant-starter/apps/api/preconditions.py`
|
|
5
|
+
* as merged, not against the design — the two disagree about the 412 body and
|
|
6
|
+
* the server is the one that ships.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
CONDITIONAL_WRITES_UNKNOWN,
|
|
10
|
+
PRECONDITION_BODY_FIELD,
|
|
11
|
+
PRECONDITION_HEADER,
|
|
12
|
+
etagFor,
|
|
13
|
+
isVersion,
|
|
14
|
+
newestRevision,
|
|
15
|
+
parseStaleSaveRefusal,
|
|
16
|
+
preconditionFor,
|
|
17
|
+
readConditionalWriteCapability,
|
|
18
|
+
toVersion,
|
|
19
|
+
versionFromEtag,
|
|
20
|
+
versionFromRecord,
|
|
21
|
+
versionFromWriteOutcome,
|
|
22
|
+
} from '../conditional-write';
|
|
23
|
+
|
|
24
|
+
describe('version 0 is a real version', () => {
|
|
25
|
+
// Every record predating the revision trail reads 0, and the server accepts 0
|
|
26
|
+
// back. `if (version)` would silently unguard exactly the oldest records.
|
|
27
|
+
it('treats 0 as a version and sends it', () => {
|
|
28
|
+
expect(isVersion(0)).toBe(true);
|
|
29
|
+
expect(preconditionFor(0)).toEqual({
|
|
30
|
+
headers: { [PRECONDITION_HEADER]: '"0"' },
|
|
31
|
+
body: {},
|
|
32
|
+
guards: true,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects the values that are not integer versions', () => {
|
|
37
|
+
for (const bad of [undefined, null, '', 'x', 1.5, true, false, NaN, {}, []]) {
|
|
38
|
+
expect(isVersion(bad)).toBe(false);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('honours a numeric string but never a bool', () => {
|
|
43
|
+
expect(toVersion('3')).toBe(3);
|
|
44
|
+
expect(toVersion(' 3 ')).toBe(3);
|
|
45
|
+
expect(toVersion('-1')).toBe(-1);
|
|
46
|
+
expect(toVersion(true)).toBeUndefined();
|
|
47
|
+
expect(toVersion('1.5')).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('an unknown version still saves', () => {
|
|
52
|
+
// THE POSTURE: enforced when present, optional when absent. A guard that can
|
|
53
|
+
// make a save impossible is worse than the overwrite it prevents.
|
|
54
|
+
it('asserts nothing rather than refusing to build a write', () => {
|
|
55
|
+
expect(preconditionFor(undefined)).toEqual({ headers: {}, body: {}, guards: false });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('one spelling per call', () => {
|
|
60
|
+
// `preconditions.enforce` checks BOTH sources and refuses if EITHER disagrees,
|
|
61
|
+
// so a stale If-Match beside a fresh expected_version refuses itself with a
|
|
62
|
+
// 412 that looks exactly like a real conflict.
|
|
63
|
+
it('sends If-Match and leaves the body untouched', () => {
|
|
64
|
+
const parts = preconditionFor(7, 'header');
|
|
65
|
+
expect(parts.headers).toEqual({ 'If-Match': '"7"' });
|
|
66
|
+
expect(parts.body).toEqual({});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('sends expected_version and leaves the headers untouched', () => {
|
|
70
|
+
const parts = preconditionFor(7, 'body');
|
|
71
|
+
expect(parts.body).toEqual({ expected_version: 7 });
|
|
72
|
+
expect(parts.headers).toEqual({});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('names the body field at the top level, never inside data', () => {
|
|
76
|
+
// Inside the blob it stops being a precondition and starts being stored.
|
|
77
|
+
expect(PRECONDITION_BODY_FIELD).toBe('expected_version');
|
|
78
|
+
expect(Object.keys(preconditionFor(1, 'body').body)).toEqual(['expected_version']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('quotes the validator strongly, with no W/', () => {
|
|
82
|
+
expect(etagFor(12)).toBe('"12"');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('reading the version back', () => {
|
|
87
|
+
it('reads the read-only field off a record', () => {
|
|
88
|
+
expect(versionFromRecord({ id: 'a', version: 4 })).toBe(4);
|
|
89
|
+
expect(versionFromRecord({ id: 'a' })).toBeUndefined();
|
|
90
|
+
expect(versionFromRecord(null)).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('parses a quoted ETag, a list and a weak validator', () => {
|
|
94
|
+
expect(versionFromEtag('"3"')).toBe(3);
|
|
95
|
+
expect(versionFromEtag('"1", "3"')).toBe(1);
|
|
96
|
+
expect(versionFromEtag('W/"5"')).toBe(5);
|
|
97
|
+
expect(versionFromEtag('"0"')).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('reads * as no version at all', () => {
|
|
101
|
+
// It asserts only that a representation exists, so it guards nothing — and
|
|
102
|
+
// sending it back would record `precondition: none` for a write the client
|
|
103
|
+
// believed it had checked.
|
|
104
|
+
expect(versionFromEtag('*')).toBeUndefined();
|
|
105
|
+
expect(versionFromEtag('')).toBeUndefined();
|
|
106
|
+
expect(versionFromEtag(undefined)).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('off the WRITE response — the one that makes autosave free', () => {
|
|
110
|
+
// THE BODY IS AUTHORITATIVE, not the header. ETag is not CORS-safelisted and
|
|
111
|
+
// tenant-starter sets no CORS_EXPOSE_HEADERS, so a cross-origin caller reads
|
|
112
|
+
// null — silently, and only on the write path. The server stamps the header
|
|
113
|
+
// FROM `response.data["version"]`, so the two cannot disagree.
|
|
114
|
+
it('prefers the body field over the header', () => {
|
|
115
|
+
expect(
|
|
116
|
+
versionFromWriteOutcome({
|
|
117
|
+
body: { version: 9 },
|
|
118
|
+
headers: new Headers({ ETag: '"9"' }),
|
|
119
|
+
}),
|
|
120
|
+
).toBe(9);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('still reads the version when the header is invisible', () => {
|
|
124
|
+
expect(versionFromWriteOutcome({ body: { version: 9 }, headers: undefined })).toBe(9);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('falls back to the header when the body carries no version', () => {
|
|
128
|
+
expect(versionFromWriteOutcome({ body: {}, headers: new Headers({ ETag: '"4"' }) })).toBe(4);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('reads a plain header record case-insensitively', () => {
|
|
132
|
+
expect(versionFromWriteOutcome({ headers: { etag: '"6"' } })).toBe(6);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('is undefined when nothing said — leaving the next save unguarded, not broken', () => {
|
|
136
|
+
expect(versionFromWriteOutcome({})).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('the refusal', () => {
|
|
142
|
+
const body = {
|
|
143
|
+
detail: 'the record changed since it was loaded: version 2 was sent and the record is now at version 3. Reload it, re-apply the change and save again.',
|
|
144
|
+
current_version: 3,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
it('is read off a resolved 412 outcome', () => {
|
|
148
|
+
expect(parseStaleSaveRefusal({ status: 412, body })).toEqual({
|
|
149
|
+
currentVersion: 3,
|
|
150
|
+
detail: body.detail,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('is read off a THROWN 412, which is what a wrapper does', () => {
|
|
155
|
+
expect(parseStaleSaveRefusal({ response: { status: 412, data: body } })).toEqual({
|
|
156
|
+
currentVersion: 3,
|
|
157
|
+
detail: body.detail,
|
|
158
|
+
});
|
|
159
|
+
expect(parseStaleSaveRefusal({ statusCode: 412, data: body })?.currentVersion).toBe(3);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('reads currentVersion in the camelised spelling too', () => {
|
|
163
|
+
// The shared API client camelCases every response key recursively and has no
|
|
164
|
+
// preserve list — including on an error body, which is where it would be
|
|
165
|
+
// found last.
|
|
166
|
+
expect(parseStaleSaveRefusal({ status: 412, body: { currentVersion: 5 } })).toEqual({
|
|
167
|
+
currentVersion: 5,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('keeps current_version an integer', () => {
|
|
172
|
+
// The server assigns `detail` after super().__init__ specifically so DRF
|
|
173
|
+
// does not coerce this to "3". Retrying with the exact value lands.
|
|
174
|
+
const parsed = parseStaleSaveRefusal({ status: 412, body });
|
|
175
|
+
expect(typeof parsed?.currentVersion).toBe('number');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('survives a 412 that names nothing, because the dialog must still open', () => {
|
|
179
|
+
expect(parseStaleSaveRefusal({ status: 412 })).toEqual({});
|
|
180
|
+
expect(parseStaleSaveRefusal({ status: 412, body: 'nope' })).toEqual({});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('is not a refusal for any other status', () => {
|
|
184
|
+
for (const status of [200, 201, 400, 403, 404, 409, 500, undefined]) {
|
|
185
|
+
expect(parseStaleSaveRefusal({ status, body })).toBeUndefined();
|
|
186
|
+
}
|
|
187
|
+
// 412 and not 409: 409 is what this viewset answers when the resource's own
|
|
188
|
+
// STATE refuses a change. A stale validator is a different thing.
|
|
189
|
+
expect(parseStaleSaveRefusal({ status: 409, body })).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('feature detection', () => {
|
|
194
|
+
const live = {
|
|
195
|
+
entities: {
|
|
196
|
+
conditional_writes: true,
|
|
197
|
+
precondition_required: false,
|
|
198
|
+
precondition: {
|
|
199
|
+
token: 'version',
|
|
200
|
+
header: 'If-Match',
|
|
201
|
+
body_field: 'expected_version',
|
|
202
|
+
refused_with: 412,
|
|
203
|
+
inert_without_type_history: true,
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
it('reads the live document', () => {
|
|
209
|
+
expect(readConditionalWriteCapability(live)).toEqual({
|
|
210
|
+
conditionalWrites: true,
|
|
211
|
+
preconditionRequired: false,
|
|
212
|
+
inertWithoutTypeHistory: true,
|
|
213
|
+
header: 'If-Match',
|
|
214
|
+
bodyField: 'expected_version',
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('defaults a fork that does not answer to NOT guarded', () => {
|
|
219
|
+
// A build predating bd startsim-3c2wc accepts `expected_version` and
|
|
220
|
+
// silently ignores it, answering 200 either way — so a client cannot learn
|
|
221
|
+
// this from a write and must not assume it.
|
|
222
|
+
for (const absent of [undefined, null, {}, { entities: {} }, 'nope']) {
|
|
223
|
+
expect(readConditionalWriteCapability(absent).conditionalWrites).toBe(false);
|
|
224
|
+
}
|
|
225
|
+
expect(CONDITIONAL_WRITES_UNKNOWN.conditionalWrites).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('reads the camelised document too', () => {
|
|
229
|
+
expect(
|
|
230
|
+
readConditionalWriteCapability({ entities: { conditionalWrites: true } }).conditionalWrites,
|
|
231
|
+
).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('never reports precondition_required, which no build sets', () => {
|
|
235
|
+
// A client reading `true` here would stop making writes it is entitled to.
|
|
236
|
+
expect(readConditionalWriteCapability(live).preconditionRequired).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('row 0 of the trail is the newest', () => {
|
|
241
|
+
// The route orders `-version, -created_at`. An ordering flip would make the
|
|
242
|
+
// dialog confidently name whoever CREATED the record.
|
|
243
|
+
it('picks the highest version', () => {
|
|
244
|
+
expect(newestRevision([{ id: 'c', version: 3 }, { id: 'a', version: 1 }])?.id).toBe('c');
|
|
245
|
+
expect(newestRevision([{ id: 'a', version: 1 }, { id: 'c', version: 3 }])?.id).toBe('c');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('is undefined on an empty or absent page', () => {
|
|
249
|
+
expect(newestRevision([])).toBeUndefined();
|
|
250
|
+
expect(newestRevision(undefined)).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
});
|