@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.
- package/package.json +4 -3
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/StaleSaveDialog.tsx +277 -0
- package/src/components/history/__tests__/conditional-write.test.ts +252 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/stale-save-dialog.test.tsx +224 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -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 +125 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/stale-save-dialog-default-class-names.ts +45 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/history/useConditionalSave.ts +356 -0
- package/src/components/index.ts +11 -1
|
@@ -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'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
|
+
}
|