@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.136",
3
+ "version": "0.4.138",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -48,8 +48,8 @@
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",
52
+ "@startsimpli/api": "^0.5.66",
53
53
  "@startsimpli/i18n": "^0.1.3"
54
54
  },
55
55
  "peerDependenciesMeta": {
@@ -124,8 +124,8 @@
124
124
  "tailwindcss": "^3.4.19",
125
125
  "tsup": "^8.5.1",
126
126
  "typescript": "^6.0.3",
127
- "@startsimpli/realtime": "0.0.2",
128
- "@startsimpli/i18n": "0.1.3"
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
+ });