@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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/history — the shapes the shared record history panel renders
|
|
3
|
+
* (bd startsim-g4rwo; trail bd startsim-o1qib).
|
|
4
|
+
*
|
|
5
|
+
* Structural on purpose, exactly like `activity/types.ts`: packages/ui imports
|
|
6
|
+
* no API client, so these mirror one `/api/v1/entities/<id>/revisions/` row
|
|
7
|
+
* without depending on @startsimpli/api. The consuming app injects its own
|
|
8
|
+
* authed client (see {@link RevisionClient}).
|
|
9
|
+
*
|
|
10
|
+
* ── THE SPELLING TRAP, AND WHY EVERY KEY HERE IS OPTIONAL TWICE ─────────────
|
|
11
|
+
*
|
|
12
|
+
* The wire is snake_case. The shared API client
|
|
13
|
+
* (`packages/api/src/utils/case-transform.ts`) camelCases every response key
|
|
14
|
+
* RECURSIVELY and has no preserve list, so an app that fetches this route
|
|
15
|
+
* through `FetchWrapper` receives `actorKind`, `createdAt`, `redactedFields`
|
|
16
|
+
* and `valuesOmitted`. Both spellings are therefore declared and
|
|
17
|
+
* {@link normalizeRevision} reads either, because a panel that renders blank
|
|
18
|
+
* actors against a camelising client — and correctly against a raw one — is a
|
|
19
|
+
* bug nobody would find twice.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THAT TRANSFORM BREAKS AND THIS MODULE CANNOT FIX: the keys INSIDE
|
|
22
|
+
* `metadata.changed` are the tenant's own declared attribute names, so a
|
|
23
|
+
* camelising client renames `team_verdict` to `teamVerdict` and there is no
|
|
24
|
+
* safe inverse — `snake_to_camel` is not injective, and guessing one would
|
|
25
|
+
* print a field name the tenant never declared. So field names are rendered
|
|
26
|
+
* EXACTLY as received and never re-cased. A consumer that wants the declared
|
|
27
|
+
* spellings must fetch this route without the case transform; one that cannot
|
|
28
|
+
* should pass `fieldLabel` to map them. Stated here rather than discovered
|
|
29
|
+
* later, because the same transform already corrupted declared names once.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The actor vocabulary lives on `ActorIdentity`, which is the component that
|
|
34
|
+
* RENDERS it, and is re-exported here so a consumer of this module needs one
|
|
35
|
+
* import rather than two. Declared once (rule #1): a second union would be a
|
|
36
|
+
* second place for "person | machine | unknown" to drift, and the three-state
|
|
37
|
+
* shape is the one thing in this epic that must not.
|
|
38
|
+
*/
|
|
39
|
+
import type { ActorKind, ActorKindSource } from '../ActorIdentity';
|
|
40
|
+
|
|
41
|
+
export type { ActorKind, ActorKindSource };
|
|
42
|
+
|
|
43
|
+
/** `deleted` is declared server-side and not yet written — bd startsim-jkkn7.6. */
|
|
44
|
+
export type RevisionAction = 'created' | 'updated' | 'deleted';
|
|
45
|
+
|
|
46
|
+
/** Which endpoint wrote. `server` is a management command or a worker — a real
|
|
47
|
+
* write that must not claim to be an endpoint. */
|
|
48
|
+
export type RevisionSource = 'create' | 'patch' | 'put' | 'upsert' | 'server';
|
|
49
|
+
|
|
50
|
+
/** `none` always, today; `matched` arrives with the conditional write
|
|
51
|
+
* (bd startsim-3c2wc). */
|
|
52
|
+
export type RevisionPrecondition = 'none' | 'matched';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One `metadata.changed` entry, straight off the wire.
|
|
56
|
+
*
|
|
57
|
+
* THE ABSENCE OF A SIDE IS THE PAYLOAD, which is why neither key is nullable
|
|
58
|
+
* rather than optional-and-nullable:
|
|
59
|
+
*
|
|
60
|
+
* - a field the write CREATED has `after` and NO `before` key;
|
|
61
|
+
* - a field the write DELETED has `before` and NO `after` key — the server
|
|
62
|
+
* never writes `after: null`, because a key a person deleted is a different
|
|
63
|
+
* fact from a key set to `null`, and `null` IS a legal stored value;
|
|
64
|
+
* - a field the read gate WITHHELD is `{}` and the row's `redacted_fields`
|
|
65
|
+
* names it;
|
|
66
|
+
* - a row over the byte cap has `{}` for EVERY field and
|
|
67
|
+
* `metadata.values_omitted` set.
|
|
68
|
+
*
|
|
69
|
+
* So `'after' in entry` is the only correct test and `entry.after === undefined`
|
|
70
|
+
* is wrong. {@link normalizeFieldChange} is the one place that does it.
|
|
71
|
+
*/
|
|
72
|
+
export interface RevisionFieldChange {
|
|
73
|
+
before?: unknown;
|
|
74
|
+
after?: unknown;
|
|
75
|
+
/** The value was too large to store. WHEN THIS IS TRUE A MISSING SIDE SAYS
|
|
76
|
+
* NOTHING — it was dropped, not deleted — so the delete inference above is
|
|
77
|
+
* invalid and a diff over this entry would show a deletion that never
|
|
78
|
+
* happened. */
|
|
79
|
+
truncated?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RevisionMetadata {
|
|
83
|
+
/** ALWAYS PRESENT, possibly `{}`. Keyed by the tenant's declared attribute
|
|
84
|
+
* names — see the spelling trap in this module's header. */
|
|
85
|
+
changed?: Record<string, RevisionFieldChange>;
|
|
86
|
+
/** Present only when the name COLUMN moved. Namespaced away from `changed`
|
|
87
|
+
* because a tenant may declare a data key literally called "name". */
|
|
88
|
+
name?: RevisionFieldChange;
|
|
89
|
+
/** Fields `human_edits.guard_machine_write` HELD BACK. A row carrying these
|
|
90
|
+
* is a machine ATTEMPT, not a change, and its `changed` is `{}` — which is
|
|
91
|
+
* correct, not a bug. */
|
|
92
|
+
preserved?: string[];
|
|
93
|
+
/** What an upsert CLAIMED it was re-authoring. The one override that
|
|
94
|
+
* legitimately destroys a person's text, so it is shown. */
|
|
95
|
+
regenerate?: string[];
|
|
96
|
+
/** The whole row exceeded the row cap: every field is still NAMED in
|
|
97
|
+
* `changed`, every value is `{}`. */
|
|
98
|
+
values_omitted?: boolean;
|
|
99
|
+
/** Camelised alias — see the header. */
|
|
100
|
+
valuesOmitted?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One row of the append-only revision trail. Only `id` and `version` are
|
|
104
|
+
* relied on; everything else is treated as optional so a row from a tenant
|
|
105
|
+
* running an older build still renders. */
|
|
106
|
+
export interface EntityRevision {
|
|
107
|
+
id: string;
|
|
108
|
+
/** 1-based, GAPLESS 1..N per entity, DB-unique. A hole is a provably lost
|
|
109
|
+
* revision — which is what the unique constraint exists to make visible. */
|
|
110
|
+
version: number;
|
|
111
|
+
action?: RevisionAction | string;
|
|
112
|
+
source?: RevisionSource | string;
|
|
113
|
+
precondition?: RevisionPrecondition | string;
|
|
114
|
+
created_at?: string | null;
|
|
115
|
+
createdAt?: string | null;
|
|
116
|
+
/** May be `""`. */
|
|
117
|
+
actor_sub?: string | null;
|
|
118
|
+
actorSub?: string | null;
|
|
119
|
+
/** The actor's email, denormalised at write time. For a machine this is
|
|
120
|
+
* often not an address at all (`svc:n8n-ogmc`). */
|
|
121
|
+
actor_label?: string | null;
|
|
122
|
+
actorLabel?: string | null;
|
|
123
|
+
actor_kind?: ActorKind | string | null;
|
|
124
|
+
actorKind?: ActorKind | string | null;
|
|
125
|
+
actor_kind_source?: ActorKindSource | string | null;
|
|
126
|
+
actorKindSource?: ActorKindSource | string | null;
|
|
127
|
+
metadata?: RevisionMetadata | null;
|
|
128
|
+
/** PRESENT ONLY when the per-field gate withheld something on this row. */
|
|
129
|
+
redacted_fields?: string[] | null;
|
|
130
|
+
redactedFields?: string[] | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The DRF envelope. `redacted_fields` is the server's roll-up of every row on
|
|
134
|
+
* this page (`apps/api/pagination.py`), present only when something really
|
|
135
|
+
* was held back. */
|
|
136
|
+
export interface RevisionPage {
|
|
137
|
+
count?: number;
|
|
138
|
+
next?: string | null;
|
|
139
|
+
previous?: string | null;
|
|
140
|
+
results?: EntityRevision[];
|
|
141
|
+
redacted_fields?: string[] | null;
|
|
142
|
+
redactedFields?: string[] | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Narrowing the panel asks for. Server-side paginated (repo rule 8) — the
|
|
146
|
+
* route caps `page_size` at 200 and 400s a parameter it does not recognise,
|
|
147
|
+
* so this stays deliberately minimal. */
|
|
148
|
+
export interface RevisionQuery {
|
|
149
|
+
page?: number;
|
|
150
|
+
pageSize?: number;
|
|
151
|
+
// Index signature so this satisfies an API client's query-params type; the
|
|
152
|
+
// injected client is the app's own.
|
|
153
|
+
[key: string]: unknown;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The one call the panel needs. The consuming app injects its authed client
|
|
158
|
+
* (`{ list: (p) => api.entities.revisions(id, p) }`), which is what keeps
|
|
159
|
+
* @startsimpli/api out of packages/ui and the app wrapper under ten lines.
|
|
160
|
+
*
|
|
161
|
+
* A 404 from this route means the caller may not read the PARENT record — the
|
|
162
|
+
* route is nested and an unreadable row is simply absent. The panel surfaces
|
|
163
|
+
* that as "you cannot see this record's history", never as an empty trail.
|
|
164
|
+
*/
|
|
165
|
+
export interface RevisionClient {
|
|
166
|
+
list: (query: RevisionQuery) => Promise<RevisionPage>;
|
|
167
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useConditionalSave — every write carries the version it loaded, and a refusal
|
|
5
|
+
* KEEPS THE REVIEWER'S TEXT (bd startsim-jkkn7.3; server bd startsim-3c2wc).
|
|
6
|
+
*
|
|
7
|
+
* The loss this closes, as a person experiences it: two reviewers open one
|
|
8
|
+
* draft, the second save carries the first reviewer's field as the second page
|
|
9
|
+
* loaded it, the rewrite is gone and the response is 200. The server now refuses
|
|
10
|
+
* that second save with a 412 — and this hook is the half that decides what
|
|
11
|
+
* happens to the three paragraphs the reviewer had just typed.
|
|
12
|
+
*
|
|
13
|
+
* ── THE ONE INVARIANT, AND IT IS THE WHOLE BEAD ─────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* A 412 NEVER ADVANCES THE HELD VERSION, AND NEVER RETRIES.
|
|
16
|
+
*
|
|
17
|
+
* The tempting shortcut is to read `current_version` off the refusal, adopt it,
|
|
18
|
+
* and re-send. It makes the error go away and every happy-path test still
|
|
19
|
+
* passes — and it reduces the guard to a decoration, because a blind retry at
|
|
20
|
+
* the current version is EXACTLY the overwrite the 412 just prevented, with one
|
|
21
|
+
* extra round-trip. So the held version advances from one place only: a write
|
|
22
|
+
* that SUCCEEDED. Adopting `current_version` is a deliberate act a person takes
|
|
23
|
+
* ({@link ConditionalSave.keepMine} / {@link ConditionalSave.acceptTheirs}), and
|
|
24
|
+
* `a refusal does not advance the held version` is a test, not a comment.
|
|
25
|
+
*
|
|
26
|
+
* ── WHAT "KEEP THE USER'S TEXT" MEANS MECHANICALLY ──────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* Three things, because the promise is worth nothing as an intention:
|
|
29
|
+
*
|
|
30
|
+
* 1. THIS HOOK NEVER OWNS THE DRAFT and never clears, resets or reverts it. A
|
|
31
|
+
* refused save touches no local state but the conflict itself, so whatever is
|
|
32
|
+
* in the editor stays in the editor.
|
|
33
|
+
* 2. THE REFUSED PAYLOAD IS RETAINED, on {@link StaleSaveConflict.refusedBody}.
|
|
34
|
+
* This is the part that survives the ACCIDENTAL path, which is not the user
|
|
35
|
+
* clicking something rash — it is the host refetching the record to show
|
|
36
|
+
* "what changed" and its own load-into-editor effect overwriting the text
|
|
37
|
+
* with the server's copy. The bytes are still here when that happens, so the
|
|
38
|
+
* reviewer's work is recoverable rather than merely promised.
|
|
39
|
+
* 3. AUTOSAVE PAUSES AND THE CONFLICT SURFACES ONCE. The draft editor debounces
|
|
40
|
+
* at 1,200 ms, so a 412 that neither paused nor deduplicated would repaint a
|
|
41
|
+
* modal on every keystroke burst — worse than no dialog at all. While
|
|
42
|
+
* {@link ConditionalSave.paused} holds, `save` returns without sending.
|
|
43
|
+
*
|
|
44
|
+
* DISMISSING IS NOT RESOLVING. Escape, the backdrop and the close button all
|
|
45
|
+
* land on {@link ConditionalSave.dismissConflict}, which closes the dialog and
|
|
46
|
+
* leaves the conflict and the pause in place. The reviewer keeps their text and
|
|
47
|
+
* can still reopen; what they cannot do is wander back into autosaving over
|
|
48
|
+
* somebody else's change because they pressed Escape.
|
|
49
|
+
*/
|
|
50
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
isVersion,
|
|
54
|
+
parseStaleSaveRefusal,
|
|
55
|
+
preconditionFor,
|
|
56
|
+
versionFromWriteOutcome,
|
|
57
|
+
newestRevision,
|
|
58
|
+
type PreconditionSpelling,
|
|
59
|
+
type ResponseHeaders,
|
|
60
|
+
} from './conditional-write';
|
|
61
|
+
import { normalizeRevision, type NormalizedRevision } from './revision-history';
|
|
62
|
+
import type { RevisionClient } from './types';
|
|
63
|
+
|
|
64
|
+
/** What the host sends. Merge `headers` into the request headers and `body` is
|
|
65
|
+
* the request body — the precondition is already in whichever one the chosen
|
|
66
|
+
* spelling uses. */
|
|
67
|
+
export interface ConditionalWriteAttempt {
|
|
68
|
+
body: Record<string, unknown>;
|
|
69
|
+
headers: Record<string, string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the host hands back. A plain `fetch` adapter is about eight lines. */
|
|
73
|
+
export interface ConditionalWriteOutcome {
|
|
74
|
+
status?: number;
|
|
75
|
+
body?: unknown;
|
|
76
|
+
headers?: ResponseHeaders;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The one call this hook needs, injected exactly like {@link RevisionClient} —
|
|
81
|
+
* which is what keeps @startsimpli/api out of packages/ui.
|
|
82
|
+
*
|
|
83
|
+
* It may RESOLVE with a 412 outcome or THROW one; both are understood, because
|
|
84
|
+
* a plain `fetch` does the first and a wrapper does the second.
|
|
85
|
+
*/
|
|
86
|
+
export interface ConditionalWriteClient {
|
|
87
|
+
write: (attempt: ConditionalWriteAttempt) => Promise<ConditionalWriteOutcome>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Why the dialog cannot name a person. Each reads differently to a reviewer,
|
|
91
|
+
* and none of them may be rendered as "a machine did this". */
|
|
92
|
+
export type IdentityGap = 'forbidden' | 'empty' | 'error' | 'no-client';
|
|
93
|
+
|
|
94
|
+
export interface StaleSaveConflict {
|
|
95
|
+
/** The version the record is at NOW, straight off the refusal. */
|
|
96
|
+
currentVersion?: number;
|
|
97
|
+
/** The server's sentence. It names both versions and is safe to show. */
|
|
98
|
+
detail?: string;
|
|
99
|
+
/** The version the editor was holding when it was refused. */
|
|
100
|
+
sentVersion?: number;
|
|
101
|
+
/**
|
|
102
|
+
* THE REVIEWER'S TEXT, as the server refused it. Retained so it is
|
|
103
|
+
* recoverable even if the host reloaded the server's copy over the editor.
|
|
104
|
+
*/
|
|
105
|
+
refusedBody: Record<string, unknown>;
|
|
106
|
+
/** The newest row of the trail — who changed it, when, and which fields.
|
|
107
|
+
* Absent whenever {@link identityGap} is set. */
|
|
108
|
+
changedBy?: NormalizedRevision;
|
|
109
|
+
/** Set when the trail could not say who. The dialog still works. */
|
|
110
|
+
identityGap?: IdentityGap;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type ConditionalSaveResult =
|
|
114
|
+
| { status: 'saved'; version?: number; outcome: ConditionalWriteOutcome }
|
|
115
|
+
| { status: 'refused'; conflict: StaleSaveConflict }
|
|
116
|
+
/** A conflict is already open: nothing was sent. */
|
|
117
|
+
| { status: 'paused' }
|
|
118
|
+
| { status: 'failed'; error: unknown };
|
|
119
|
+
|
|
120
|
+
export interface UseConditionalSaveOptions {
|
|
121
|
+
client: ConditionalWriteClient;
|
|
122
|
+
/** The version the record was loaded at. Seeding this is what makes the FIRST
|
|
123
|
+
* save guarded; without it the first write is unguarded and the trail says
|
|
124
|
+
* so (`precondition: "none"`). */
|
|
125
|
+
version?: number;
|
|
126
|
+
/** Reads `/api/v1/entities/<id>/revisions/` for the human-readable half. The
|
|
127
|
+
* 412 carries no identity by design (bd startsim-j4kx6) — without this the
|
|
128
|
+
* dialog still opens and simply does not name anyone. */
|
|
129
|
+
revisions?: RevisionClient;
|
|
130
|
+
/** `header` (default) sends `If-Match` and leaves the body untouched. */
|
|
131
|
+
spelling?: PreconditionSpelling;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ConditionalSave {
|
|
135
|
+
/** The version every write will assert. `undefined` means unguarded. */
|
|
136
|
+
version?: number;
|
|
137
|
+
/** Seed or re-seed it — on load, and after the host reloads the record. */
|
|
138
|
+
setVersion: (version: number | undefined) => void;
|
|
139
|
+
/** Whether the next save will actually assert a version. */
|
|
140
|
+
guarded: boolean;
|
|
141
|
+
save: (body: Record<string, unknown>) => Promise<ConditionalSaveResult>;
|
|
142
|
+
conflict: StaleSaveConflict | null;
|
|
143
|
+
/** An unresolved conflict: autosave must not fire. Stays true across a
|
|
144
|
+
* dismissal, because dismissing is not resolving. */
|
|
145
|
+
paused: boolean;
|
|
146
|
+
/** Whether the dialog should be on screen. */
|
|
147
|
+
dialogOpen: boolean;
|
|
148
|
+
/** Close the dialog. KEEPS the text, the conflict and the pause. */
|
|
149
|
+
dismissConflict: () => void;
|
|
150
|
+
reopenConflict: () => void;
|
|
151
|
+
/** Deliberately overwrite their change with the refused text, at the version
|
|
152
|
+
* the server named. The only path that adopts `current_version`. */
|
|
153
|
+
keepMine: () => Promise<ConditionalSaveResult>;
|
|
154
|
+
/** Give up the local edit: clear the conflict and adopt their version so the
|
|
155
|
+
* host can reload. Destructive to the reviewer's text BY DESIGN, which is
|
|
156
|
+
* why nothing but an explicit choice reaches it. */
|
|
157
|
+
acceptTheirs: () => void;
|
|
158
|
+
/** A `keepMine` is in flight. */
|
|
159
|
+
resolving: boolean;
|
|
160
|
+
/** The last non-412 failure, since `save` reports rather than throws. */
|
|
161
|
+
lastError: unknown;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function useConditionalSave({
|
|
165
|
+
client,
|
|
166
|
+
version: initialVersion,
|
|
167
|
+
revisions,
|
|
168
|
+
spelling = 'header',
|
|
169
|
+
}: UseConditionalSaveOptions): ConditionalSave {
|
|
170
|
+
const [version, setVersionState] = useState<number | undefined>(initialVersion);
|
|
171
|
+
const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
|
|
172
|
+
const [dismissed, setDismissed] = useState(false);
|
|
173
|
+
const [resolving, setResolving] = useState(false);
|
|
174
|
+
const [lastError, setLastError] = useState<unknown>(null);
|
|
175
|
+
|
|
176
|
+
// Read inside `save` so a stale closure cannot send a version the hook has
|
|
177
|
+
// already moved past, and so `paused` is evaluated against the live conflict
|
|
178
|
+
// rather than the one captured when the debounce was scheduled.
|
|
179
|
+
const versionRef = useRef(version);
|
|
180
|
+
versionRef.current = version;
|
|
181
|
+
const conflictRef = useRef(conflict);
|
|
182
|
+
conflictRef.current = conflict;
|
|
183
|
+
|
|
184
|
+
// The identity fetch resolves after the conflict is already on screen, so it
|
|
185
|
+
// must not write into a hook that has unmounted or already moved on.
|
|
186
|
+
const alive = useRef(true);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
alive.current = true;
|
|
189
|
+
return () => {
|
|
190
|
+
alive.current = false;
|
|
191
|
+
};
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
const setVersion = useCallback((next: number | undefined) => {
|
|
195
|
+
setVersionState(isVersion(next) ? next : undefined);
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
// Re-seed when the host loads a different record. Guarded on the value so a
|
|
199
|
+
// save's own advance is not stomped by a re-render carrying the old prop.
|
|
200
|
+
const seeded = useRef(initialVersion);
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (seeded.current !== initialVersion) {
|
|
203
|
+
seeded.current = initialVersion;
|
|
204
|
+
setVersionState(isVersion(initialVersion) ? initialVersion : undefined);
|
|
205
|
+
}
|
|
206
|
+
}, [initialVersion]);
|
|
207
|
+
|
|
208
|
+
/** Fill in "who changed it" after the dialog is already up. Never blocks the
|
|
209
|
+
* conflict from surfacing, and never throws into the save path. */
|
|
210
|
+
const resolveIdentity = useCallback(
|
|
211
|
+
async (key: StaleSaveConflict) => {
|
|
212
|
+
const patch = (gap: IdentityGap) => {
|
|
213
|
+
if (!alive.current) return;
|
|
214
|
+
setConflict((current) => (current === key ? { ...current, identityGap: gap } : current));
|
|
215
|
+
};
|
|
216
|
+
if (!revisions) return patch('no-client');
|
|
217
|
+
try {
|
|
218
|
+
const page = await revisions.list({ page: 1, pageSize: 1 });
|
|
219
|
+
const row = newestRevision(page?.results);
|
|
220
|
+
if (!alive.current) return;
|
|
221
|
+
if (!row) return patch('empty');
|
|
222
|
+
setConflict((current) =>
|
|
223
|
+
current === key ? { ...current, changedBy: normalizeRevision(row) } : current,
|
|
224
|
+
);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
// A 404 on this nested route means the caller may not read the PARENT
|
|
227
|
+
// record, so history is withheld rather than absent — a different
|
|
228
|
+
// sentence, and never an empty trail.
|
|
229
|
+
patch(parseStatus(error) === 404 ? 'forbidden' : 'error');
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
[revisions],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const refuse = useCallback(
|
|
236
|
+
(
|
|
237
|
+
refusedBody: Record<string, unknown>,
|
|
238
|
+
sent: number | undefined,
|
|
239
|
+
refusal: { currentVersion?: number; detail?: string },
|
|
240
|
+
): ConditionalSaveResult => {
|
|
241
|
+
const next: StaleSaveConflict = {
|
|
242
|
+
...refusal,
|
|
243
|
+
...(sent !== undefined ? { sentVersion: sent } : {}),
|
|
244
|
+
refusedBody,
|
|
245
|
+
};
|
|
246
|
+
if (alive.current) {
|
|
247
|
+
// NOTE WHAT IS NOT HERE: setVersionState. The refusal names the current
|
|
248
|
+
// version and this hook deliberately declines to take it.
|
|
249
|
+
setConflict(next);
|
|
250
|
+
setDismissed(false);
|
|
251
|
+
setLastError(null);
|
|
252
|
+
void resolveIdentity(next);
|
|
253
|
+
}
|
|
254
|
+
return { status: 'refused', conflict: next };
|
|
255
|
+
},
|
|
256
|
+
[resolveIdentity],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const attempt = useCallback(
|
|
260
|
+
async (
|
|
261
|
+
body: Record<string, unknown>,
|
|
262
|
+
assertVersion: number | undefined,
|
|
263
|
+
): Promise<ConditionalSaveResult> => {
|
|
264
|
+
const parts = preconditionFor(assertVersion, spelling);
|
|
265
|
+
try {
|
|
266
|
+
const outcome = await client.write({
|
|
267
|
+
body: { ...body, ...parts.body },
|
|
268
|
+
headers: { ...parts.headers },
|
|
269
|
+
});
|
|
270
|
+
const refusal = parseStaleSaveRefusal(outcome);
|
|
271
|
+
if (refusal) return refuse(body, assertVersion, refusal);
|
|
272
|
+
const next = versionFromWriteOutcome(outcome ?? {});
|
|
273
|
+
if (alive.current) {
|
|
274
|
+
// THE ONLY PLACE THE HELD VERSION ADVANCES.
|
|
275
|
+
if (next !== undefined) setVersionState(next);
|
|
276
|
+
setConflict(null);
|
|
277
|
+
setDismissed(false);
|
|
278
|
+
setLastError(null);
|
|
279
|
+
}
|
|
280
|
+
return { status: 'saved', ...(next !== undefined ? { version: next } : {}), outcome };
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const refusal = parseStaleSaveRefusal(error);
|
|
283
|
+
if (refusal) return refuse(body, assertVersion, refusal);
|
|
284
|
+
if (alive.current) setLastError(error);
|
|
285
|
+
return { status: 'failed', error };
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
[client, refuse, spelling],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const save = useCallback(
|
|
292
|
+
async (body: Record<string, unknown>): Promise<ConditionalSaveResult> => {
|
|
293
|
+
// Surfaced once, not retried. An unresolved conflict means the reviewer
|
|
294
|
+
// still has a decision to make and their keystrokes must not re-send.
|
|
295
|
+
if (conflictRef.current) return { status: 'paused' };
|
|
296
|
+
return attempt(body, versionRef.current);
|
|
297
|
+
},
|
|
298
|
+
[attempt],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const keepMine = useCallback(async (): Promise<ConditionalSaveResult> => {
|
|
302
|
+
const current = conflictRef.current;
|
|
303
|
+
if (!current) return { status: 'paused' };
|
|
304
|
+
// Adopting `current_version` is legitimate HERE and nowhere else: a person
|
|
305
|
+
// has been told what changed and has chosen to replace it.
|
|
306
|
+
const target = isVersion(current.currentVersion) ? current.currentVersion : undefined;
|
|
307
|
+
// The pause is NOT lifted here. `save` stays refused for the whole of this
|
|
308
|
+
// round-trip, so a debounce that fires mid-resolution cannot send the same
|
|
309
|
+
// body a second time and race the write this call is already making.
|
|
310
|
+
setResolving(true);
|
|
311
|
+
try {
|
|
312
|
+
return await attempt(current.refusedBody, target);
|
|
313
|
+
} finally {
|
|
314
|
+
if (alive.current) setResolving(false);
|
|
315
|
+
}
|
|
316
|
+
}, [attempt]);
|
|
317
|
+
|
|
318
|
+
const acceptTheirs = useCallback(() => {
|
|
319
|
+
const current = conflictRef.current;
|
|
320
|
+
setVersionState(isVersion(current?.currentVersion) ? current!.currentVersion : undefined);
|
|
321
|
+
setConflict(null);
|
|
322
|
+
setDismissed(false);
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const dismissConflict = useCallback(() => setDismissed(true), []);
|
|
326
|
+
const reopenConflict = useCallback(() => setDismissed(false), []);
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
...(version !== undefined ? { version } : {}),
|
|
330
|
+
setVersion,
|
|
331
|
+
guarded: isVersion(version),
|
|
332
|
+
save,
|
|
333
|
+
conflict,
|
|
334
|
+
paused: conflict !== null,
|
|
335
|
+
dialogOpen: conflict !== null && !dismissed,
|
|
336
|
+
dismissConflict,
|
|
337
|
+
reopenConflict,
|
|
338
|
+
keepMine,
|
|
339
|
+
acceptTheirs,
|
|
340
|
+
resolving,
|
|
341
|
+
lastError,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function parseStatus(error: unknown): number | undefined {
|
|
346
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
347
|
+
const candidate = error as {
|
|
348
|
+
status?: unknown;
|
|
349
|
+
statusCode?: unknown;
|
|
350
|
+
response?: { status?: unknown };
|
|
351
|
+
};
|
|
352
|
+
for (const raw of [candidate.status, candidate.statusCode, candidate.response?.status]) {
|
|
353
|
+
if (typeof raw === 'number') return raw;
|
|
354
|
+
}
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -155,7 +155,13 @@ export * from './calendar'
|
|
|
155
155
|
// display name is never printed alone as if it were an identity
|
|
156
156
|
// (startsim-768w.19.8.1).
|
|
157
157
|
export { ActorIdentity, actorInitials, actorLabel, actorHandle } from './ActorIdentity'
|
|
158
|
-
export
|
|
158
|
+
export { actorKindTitle } from './ActorIdentity'
|
|
159
|
+
export type {
|
|
160
|
+
ActorIdentityProps,
|
|
161
|
+
ActorIdentityValue,
|
|
162
|
+
ActorKind,
|
|
163
|
+
ActorKindSource,
|
|
164
|
+
} from './ActorIdentity'
|
|
159
165
|
|
|
160
166
|
// Activity components (timeline, quick log, log dialog)
|
|
161
167
|
export { ActivityTimeline, dayHeading } from './ActivityTimeline'
|
|
@@ -168,6 +174,10 @@ export type {
|
|
|
168
174
|
// The shared, filterable activity feed: filter bar + page-level composer over
|
|
169
175
|
// any append-only event stream (startsim-768w.19.5).
|
|
170
176
|
export * from './activity'
|
|
177
|
+
|
|
178
|
+
// The shared record history panel: who changed which field, from what, to
|
|
179
|
+
// what, and whether a machine did it (bd startsim-g4rwo).
|
|
180
|
+
export * from './history'
|
|
171
181
|
export { QuickLogButtons } from './QuickLogButtons'
|
|
172
182
|
export type { QuickLogButtonsProps, QuickLogAction } from './QuickLogButtons'
|
|
173
183
|
export { LogActivityDialog } from './LogActivityDialog'
|