@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.
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * What moved inside one sitting, field by field (bd startsim-g4rwo).
5
+ *
6
+ * ── THE THREE WAYS A FIELD CAN BE NAMED WITHOUT ITS VALUES ──────────────────
7
+ *
8
+ * All three arrive as `{}` and they mean different things, so rendering them
9
+ * the same way — or worse, rendering `{}` as "changed to nothing" — turns a
10
+ * permission boundary and a storage cap into apparent data loss. Each gets its
11
+ * own sentence:
12
+ *
13
+ * - WITHHELD (`redacted_fields` names it): you are not allowed to read this
14
+ * field, so you may not read its history either. The FACT of the edit is
15
+ * real and is shown; the values are not. This route serves every historical
16
+ * value a record ever held, which makes it a RICHER leak than the row read —
17
+ * so the gate is honoured here and SAID OUT LOUD, because silence reads as
18
+ * data loss and would send somebody looking for a bug.
19
+ * - OMITTED (`metadata.values_omitted`): the whole revision was over the byte
20
+ * cap, so every field is named and no values were stored. Nobody is being
21
+ * kept out; the values were never written.
22
+ * - TRUNCATED (`truncated: true` on the entry): the value was too large to
23
+ * store. The critical consequence is that A MISSING SIDE NOW SAYS NOTHING —
24
+ * everywhere else a missing `after` means the write DELETED the key — so the
25
+ * created/deleted inference is suppressed and the diff is REFUSED with its
26
+ * reason, rather than showing a deletion that never happened.
27
+ */
28
+ import { useState } from 'react';
29
+ import { ChevronRight, EyeOff, Scissors } from 'lucide-react';
30
+
31
+ import { cn } from '../../lib/utils';
32
+ import { DiffViewer } from '../diff';
33
+ import {
34
+ canDiffFieldChange,
35
+ classifyFieldChange,
36
+ type FoldedFieldChange,
37
+ type RevisionSitting,
38
+ } from './revision-history';
39
+ import { unifiedTextDiff } from './text-diff';
40
+
41
+ /** A stored value as one line of text, for the side-by-side summary. Objects
42
+ * are JSON so a reader can at least see the shape; nothing is invented. */
43
+ function preview(value: unknown): string {
44
+ if (typeof value === 'string') return value;
45
+ if (value === null) return 'null';
46
+ if (value === undefined) return '';
47
+ try {
48
+ return JSON.stringify(value);
49
+ } catch {
50
+ return String(value);
51
+ }
52
+ }
53
+
54
+ const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
55
+
56
+ /** One side of a change. ABSENCE IS RENDERED AS ABSENCE — an em dash with
57
+ * screen-reader text — never as an empty box that looks like "" was stored. */
58
+ function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
59
+ return (
60
+ <div className="min-w-0 flex-1">
61
+ <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
62
+ {label}
63
+ </div>
64
+ {has ? (
65
+ <pre className={cn('rounded border border-border bg-muted/40 p-2 font-mono text-xs', CLAMP)}>
66
+ {preview(value)}
67
+ </pre>
68
+ ) : (
69
+ <p className="rounded border border-dashed border-border p-2 text-xs text-muted-foreground">
70
+ <span aria-hidden="true">—</span>
71
+ <span className="sr-only">not set</span>
72
+ <span className="ms-1">not set</span>
73
+ </p>
74
+ )}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange }) {
80
+ const kind = classifyFieldChange(change);
81
+
82
+ if (kind === 'withheld') {
83
+ return (
84
+ <p className="flex items-start gap-2 text-xs text-muted-foreground">
85
+ <EyeOff className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
86
+ <span>
87
+ This field was changed, but <strong className="font-medium text-foreground">its
88
+ values are not shown to you</strong> — your access does not include reading{' '}
89
+ <code className="rounded bg-muted px-1">{field}</code> on this record. The edit itself
90
+ is real and is recorded.
91
+ </span>
92
+ </p>
93
+ );
94
+ }
95
+
96
+ if (kind === 'omitted') {
97
+ return (
98
+ <p className="flex items-start gap-2 text-xs text-muted-foreground">
99
+ <Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
100
+ <span>
101
+ This field changed, but this revision was too large to store its values. The field
102
+ names were kept; the before and after were not written.
103
+ </span>
104
+ </p>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div className="space-y-2">
110
+ {kind === 'truncated' && (
111
+ <p className="flex items-start gap-2 text-xs text-muted-foreground">
112
+ <Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
113
+ <span>
114
+ This value was too large to store in full, so what is shown may be a prefix and{' '}
115
+ <strong className="font-medium text-foreground">a missing side means nothing
116
+ here</strong>. No comparison is offered, because a comparison against a shortened
117
+ value would show a deletion that never happened.
118
+ </span>
119
+ </p>
120
+ )}
121
+ <div className="flex flex-wrap gap-3 sm:flex-nowrap">
122
+ <Side label="Before" has={change.hasBefore} value={change.before} />
123
+ <Side label="After" has={change.hasAfter} value={change.after} />
124
+ </div>
125
+ {canDiffFieldChange(change) && (
126
+ <DiffViewer
127
+ diff={unifiedTextDiff(String(change.before), String(change.after), field)}
128
+ emptyLabel="No line-level change"
129
+ className="max-h-72 overflow-auto rounded border border-border"
130
+ />
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /** The one-word verdict next to a field name. Neutral tokens throughout: a
137
+ * deletion is a thing a reviewer did on purpose far more often than it is an
138
+ * accident, so it is not painted as an error. */
139
+ const KIND_LABEL: Record<ReturnType<typeof classifyFieldChange>, string> = {
140
+ withheld: 'not shown to you',
141
+ omitted: 'values not stored',
142
+ truncated: 'too large to store',
143
+ created: 'set',
144
+ deleted: 'cleared',
145
+ updated: 'changed',
146
+ };
147
+
148
+ export interface RevisionFieldsProps {
149
+ sitting: RevisionSitting;
150
+ /** Map a wire field name to something a reader recognises. Identity by
151
+ * default — see `types.ts` on why this module never re-cases a name. */
152
+ fieldLabel?: (field: string) => string;
153
+ }
154
+
155
+ export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionFieldsProps) {
156
+ const [open, setOpen] = useState<string | null>(null);
157
+ const entries: Array<[string, FoldedFieldChange]> = [
158
+ ...Object.keys(sitting.changed)
159
+ .sort()
160
+ .map((field) => [field, sitting.changed[field]!] as [string, FoldedFieldChange]),
161
+ // The name COLUMN, kept namespaced away from the data keys because a tenant
162
+ // may declare a data key literally called "name".
163
+ ...(sitting.name ? ([['name (title)', sitting.name]] as Array<[string, FoldedFieldChange]>) : []),
164
+ ];
165
+
166
+ return (
167
+ <div className="space-y-2" data-testid="revision-fields">
168
+ {/* AN ATTEMPT IS NOT A CHANGE. A machine tried to write and
169
+ `human_edits.guard_machine_write` stopped it, so the record did not
170
+ move — and the whole value of this row is that the attempt is on the
171
+ record. It is listed separately from `changed` for that reason. */}
172
+ {sitting.preserved.length > 0 && (
173
+ <ul className="space-y-1" data-testid="revision-preserved">
174
+ {sitting.preserved.map((field) => (
175
+ <li key={field} className="text-xs text-muted-foreground">
176
+ <code className="rounded bg-muted px-1 text-foreground">{fieldLabel(field)}</code>{' '}
177
+ was not written — a person&apos;s edit was kept instead.
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ )}
182
+
183
+ {sitting.regenerate.length > 0 && (
184
+ <p className="text-xs text-muted-foreground" data-testid="revision-regenerate">
185
+ Claimed permission to re-author{' '}
186
+ {sitting.regenerate.map((f) => fieldLabel(f)).join(', ')}.
187
+ </p>
188
+ )}
189
+
190
+ {entries.length > 0 && (
191
+ <ul className="divide-y divide-border rounded border border-border">
192
+ {entries.map(([field, change]) => {
193
+ const kind = classifyFieldChange(change);
194
+ const isOpen = open === field;
195
+ return (
196
+ <li key={field}>
197
+ <button
198
+ type="button"
199
+ aria-expanded={isOpen}
200
+ onClick={() => setOpen(isOpen ? null : field)}
201
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent"
202
+ >
203
+ <ChevronRight
204
+ className={cn('h-3.5 w-3.5 shrink-0 transition-transform', isOpen && 'rotate-90')}
205
+ aria-hidden="true"
206
+ />
207
+ <code className="min-w-0 truncate font-mono text-foreground">
208
+ {fieldLabel(field)}
209
+ </code>
210
+ <span className="ms-auto shrink-0 text-muted-foreground">{KIND_LABEL[kind]}</span>
211
+ </button>
212
+ {isOpen && (
213
+ <div className="border-t border-border bg-muted/20 px-3 py-2">
214
+ <FieldBody field={field} change={change} />
215
+ </div>
216
+ )}
217
+ </li>
218
+ );
219
+ })}
220
+ </ul>
221
+ )}
222
+ </div>
223
+ );
224
+ }
@@ -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
+ }