@gp2f/client-sdk 1.0.0
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/dist/MergeModal.d.ts +21 -0
- package/dist/MergeModal.js +136 -0
- package/dist/ReconciliationBanner.d.ts +18 -0
- package/dist/ReconciliationBanner.js +32 -0
- package/dist/UndoButton.d.ts +14 -0
- package/dist/UndoButton.js +17 -0
- package/dist/client.d.ts +134 -0
- package/dist/client.js +244 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +58 -0
- package/dist/wire.d.ts +71 -0
- package/dist/wire.js +5 -0
- package/package.json +38 -0
- package/src/MergeModal.tsx +321 -0
- package/src/ReconciliationBanner.tsx +87 -0
- package/src/UndoButton.tsx +45 -0
- package/src/client.ts +352 -0
- package/src/index.ts +98 -0
- package/src/wire.ts +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// WebSocket client
|
|
2
|
+
export { Gp2fClient, applyOptimisticUpdate } from "./client";
|
|
3
|
+
// Reconciliation UX components
|
|
4
|
+
export { ReconciliationBanner } from "./ReconciliationBanner";
|
|
5
|
+
export { UndoButton } from "./UndoButton";
|
|
6
|
+
export { MergeModal } from "./MergeModal";
|
|
7
|
+
/**
|
|
8
|
+
* Lazily load the GP2F WASM policy engine.
|
|
9
|
+
*
|
|
10
|
+
* The module is fetched and instantiated on the **first call** only; subsequent
|
|
11
|
+
* calls return the cached instance with no additional network cost.
|
|
12
|
+
*
|
|
13
|
+
* This pattern ("lazy loading") keeps the initial JS bundle small and defers
|
|
14
|
+
* the WASM download until the moment the policy engine is actually needed.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const engine = await loadPolicyEngine();
|
|
19
|
+
* const { result } = engine.evaluate(JSON.stringify(state), JSON.stringify(ast));
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export async function loadPolicyEngine() {
|
|
23
|
+
return _policyEngineCache ?? (_policyEngineCache = await _importPolicyEngine());
|
|
24
|
+
}
|
|
25
|
+
/** Cached module instance (populated after the first successful load). */
|
|
26
|
+
let _policyEngineCache = null;
|
|
27
|
+
/**
|
|
28
|
+
* Perform the actual dynamic import.
|
|
29
|
+
*
|
|
30
|
+
* Replace the module path with the real WASM package once it is published.
|
|
31
|
+
* The `/* webpackChunkName magic comment tells bundlers (webpack / Vite)
|
|
32
|
+
* to emit this as a separate chunk so it is only downloaded on demand.
|
|
33
|
+
*/
|
|
34
|
+
async function _importPolicyEngine() {
|
|
35
|
+
// Dynamic import – bundlers will split this into a separate chunk.
|
|
36
|
+
// We use a try/catch so that the SDK remains usable even when the optional
|
|
37
|
+
// @gp2f/policy-core-wasm peer package is not installed.
|
|
38
|
+
try {
|
|
39
|
+
// @ts-expect-error: @gp2f/policy-core-wasm is an optional peer package
|
|
40
|
+
// that may not be installed. The try/catch below handles the absence case.
|
|
41
|
+
const mod = await import(/* webpackChunkName: "policy-engine" */ "@gp2f/policy-core-wasm");
|
|
42
|
+
return mod;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// WASM package not installed – return a stub that always delegates to the
|
|
46
|
+
// server-side evaluator. Log a warning so developers know the lazy engine
|
|
47
|
+
// is inactive.
|
|
48
|
+
if (typeof console !== "undefined") {
|
|
49
|
+
console.warn("[gp2f] WASM policy engine not found (@gp2f/policy-core-wasm). " +
|
|
50
|
+
"All policy evaluation will be performed server-side.");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
evaluate(_stateJson, _astJson) {
|
|
54
|
+
throw new Error("WASM policy engine is not available. Install @gp2f/policy-core-wasm to enable client-side evaluation.");
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol types shared between the GP2F server and this SDK.
|
|
3
|
+
* These mirror the Rust structs in `server/src/wire.rs`.
|
|
4
|
+
*/
|
|
5
|
+
export interface ClientMessage {
|
|
6
|
+
opId: string;
|
|
7
|
+
astVersion: string;
|
|
8
|
+
action: string;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
clientSnapshotHash: string;
|
|
11
|
+
tenantId?: string;
|
|
12
|
+
workflowId?: string;
|
|
13
|
+
instanceId?: string;
|
|
14
|
+
/** base64url HMAC-SHA256 over canonical op fields */
|
|
15
|
+
clientSignature?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface AcceptResponse {
|
|
18
|
+
opId: string;
|
|
19
|
+
serverSnapshotHash: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ThreeWayPatch {
|
|
22
|
+
baseSnapshot: unknown;
|
|
23
|
+
localDiff: unknown;
|
|
24
|
+
serverDiff: unknown;
|
|
25
|
+
conflicts: FieldConflict[];
|
|
26
|
+
}
|
|
27
|
+
export interface FieldConflict {
|
|
28
|
+
path: string;
|
|
29
|
+
strategy: "CRDT" | "LWW" | "TRANSACTIONAL";
|
|
30
|
+
resolvedValue: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface RejectResponse {
|
|
33
|
+
opId: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
patch: ThreeWayPatch;
|
|
36
|
+
/**
|
|
37
|
+
* Suggested back-off interval in milliseconds (Retry-After semantics).
|
|
38
|
+
* Present when the rejection is caused by server-side backpressure; the
|
|
39
|
+
* client SHOULD pause sending new ops for at least this duration.
|
|
40
|
+
*/
|
|
41
|
+
retryAfterMs?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Sent by the server once per connection, immediately after the WebSocket
|
|
45
|
+
* handshake, for time-synchronisation purposes.
|
|
46
|
+
*/
|
|
47
|
+
export interface HelloMessage {
|
|
48
|
+
/** Server wall-clock time in milliseconds since the Unix epoch. */
|
|
49
|
+
serverTimeMs: number;
|
|
50
|
+
/** Server HLC timestamp at the moment of the hello. */
|
|
51
|
+
serverHlcTs: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Sent by the server when the client's AST schema version is incompatible.
|
|
55
|
+
* The client MUST fetch a fresh policy bundle before reconnecting.
|
|
56
|
+
*/
|
|
57
|
+
export interface ReloadRequiredMessage {
|
|
58
|
+
/** The minimum AST version the server accepts (semver). */
|
|
59
|
+
minRequiredVersion: string;
|
|
60
|
+
/** Human-readable explanation. */
|
|
61
|
+
reason: string;
|
|
62
|
+
}
|
|
63
|
+
export type ServerMessage = {
|
|
64
|
+
type: "ACCEPT";
|
|
65
|
+
} & AcceptResponse | {
|
|
66
|
+
type: "REJECT";
|
|
67
|
+
} & RejectResponse | {
|
|
68
|
+
type: "HELLO";
|
|
69
|
+
} & HelloMessage | {
|
|
70
|
+
type: "RELOAD_REQUIRED";
|
|
71
|
+
} & ReloadRequiredMessage;
|
package/dist/wire.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gp2f/client-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "GP2F client SDK – reconciliation UX components and WebSocket client",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"test": "jest --passWithNoTests"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"gp2f",
|
|
24
|
+
"crdt",
|
|
25
|
+
"reconciliation",
|
|
26
|
+
"optimistic-ui"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=18.0.0",
|
|
31
|
+
"react-dom": ">=18.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^18.0.0",
|
|
35
|
+
"@types/react-dom": "^18.0.0",
|
|
36
|
+
"typescript": "^5.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import type { FieldConflict } from "./wire";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Side-by-side modal shown when a REJECT contains non-CRDT field conflicts.
|
|
6
|
+
*
|
|
7
|
+
* For each conflicting field the user can choose:
|
|
8
|
+
* - **Keep mine** – use the local optimistic value
|
|
9
|
+
* - **Use server** – accept the server's authoritative value (from `resolvedValue`)
|
|
10
|
+
*/
|
|
11
|
+
export interface MergeModalProps {
|
|
12
|
+
conflicts: FieldConflict[];
|
|
13
|
+
/** JSON snapshot of the state *before* the rejected op was applied. */
|
|
14
|
+
baseSnapshot: unknown;
|
|
15
|
+
/** JSON diff representing the local (client) changes. */
|
|
16
|
+
localDiff: unknown;
|
|
17
|
+
/** Called with the user's resolution choices (path → chosen value). */
|
|
18
|
+
onResolve: (resolutions: Record<string, unknown>) => void;
|
|
19
|
+
/** Called when the user cancels without resolving. */
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Resolution = "mine" | "server";
|
|
24
|
+
|
|
25
|
+
export function MergeModal({
|
|
26
|
+
conflicts,
|
|
27
|
+
localDiff,
|
|
28
|
+
onResolve,
|
|
29
|
+
onCancel,
|
|
30
|
+
}: MergeModalProps): React.ReactElement {
|
|
31
|
+
const [choices, setChoices] = useState<Record<string, Resolution>>(
|
|
32
|
+
() =>
|
|
33
|
+
Object.fromEntries(conflicts.map((c) => [c.path, "server" as Resolution]))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
function handleChoice(path: string, choice: Resolution) {
|
|
37
|
+
setChoices((prev) => ({ ...prev, [path]: choice }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleResolve() {
|
|
41
|
+
const resolutions: Record<string, unknown> = {};
|
|
42
|
+
for (const conflict of conflicts) {
|
|
43
|
+
if (choices[conflict.path] === "server") {
|
|
44
|
+
resolutions[conflict.path] = conflict.resolvedValue;
|
|
45
|
+
} else {
|
|
46
|
+
// "mine": extract value from localDiff
|
|
47
|
+
resolutions[conflict.path] = getFieldValue(localDiff, conflict.path);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
onResolve(resolutions);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
role="dialog"
|
|
56
|
+
aria-modal="true"
|
|
57
|
+
aria-labelledby="merge-modal-title"
|
|
58
|
+
style={{
|
|
59
|
+
position: "fixed",
|
|
60
|
+
inset: 0,
|
|
61
|
+
display: "flex",
|
|
62
|
+
alignItems: "center",
|
|
63
|
+
justifyContent: "center",
|
|
64
|
+
backgroundColor: "rgba(0,0,0,0.45)",
|
|
65
|
+
zIndex: 1000,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<div
|
|
69
|
+
style={{
|
|
70
|
+
backgroundColor: "#ffffff",
|
|
71
|
+
borderRadius: "0.5rem",
|
|
72
|
+
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
|
73
|
+
width: "min(90vw, 640px)",
|
|
74
|
+
maxHeight: "80vh",
|
|
75
|
+
display: "flex",
|
|
76
|
+
flexDirection: "column",
|
|
77
|
+
overflow: "hidden",
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{/* Header */}
|
|
81
|
+
<div
|
|
82
|
+
style={{
|
|
83
|
+
padding: "1rem 1.25rem",
|
|
84
|
+
borderBottom: "1px solid #e5e7eb",
|
|
85
|
+
display: "flex",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "space-between",
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<h2
|
|
91
|
+
id="merge-modal-title"
|
|
92
|
+
style={{ margin: 0, fontSize: "1rem", fontWeight: 600 }}
|
|
93
|
+
>
|
|
94
|
+
Resolve Conflicts ({conflicts.length})
|
|
95
|
+
</h2>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
aria-label="Close"
|
|
99
|
+
onClick={onCancel}
|
|
100
|
+
style={{
|
|
101
|
+
background: "none",
|
|
102
|
+
border: "none",
|
|
103
|
+
cursor: "pointer",
|
|
104
|
+
fontSize: "1.25rem",
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
✕
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Conflict list */}
|
|
112
|
+
<div style={{ overflowY: "auto", flex: 1, padding: "0.75rem 1.25rem" }}>
|
|
113
|
+
{conflicts.map((conflict) => (
|
|
114
|
+
<ConflictRow
|
|
115
|
+
key={conflict.path}
|
|
116
|
+
conflict={conflict}
|
|
117
|
+
localValue={getFieldValue(localDiff, conflict.path)}
|
|
118
|
+
choice={choices[conflict.path] ?? "server"}
|
|
119
|
+
onChoose={(c) => handleChoice(conflict.path, c)}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Footer */}
|
|
125
|
+
<div
|
|
126
|
+
style={{
|
|
127
|
+
padding: "0.75rem 1.25rem",
|
|
128
|
+
borderTop: "1px solid #e5e7eb",
|
|
129
|
+
display: "flex",
|
|
130
|
+
justifyContent: "flex-end",
|
|
131
|
+
gap: "0.5rem",
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={onCancel}
|
|
137
|
+
style={{
|
|
138
|
+
padding: "0.5rem 1rem",
|
|
139
|
+
borderRadius: "0.375rem",
|
|
140
|
+
border: "1px solid #d1d5db",
|
|
141
|
+
background: "#ffffff",
|
|
142
|
+
cursor: "pointer",
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
Cancel
|
|
146
|
+
</button>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={handleResolve}
|
|
150
|
+
style={{
|
|
151
|
+
padding: "0.5rem 1rem",
|
|
152
|
+
borderRadius: "0.375rem",
|
|
153
|
+
border: "none",
|
|
154
|
+
backgroundColor: "#2563eb",
|
|
155
|
+
color: "#ffffff",
|
|
156
|
+
cursor: "pointer",
|
|
157
|
+
fontWeight: 500,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
Apply Resolutions
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── internal components ───────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
interface ConflictRowProps {
|
|
171
|
+
conflict: FieldConflict;
|
|
172
|
+
localValue: unknown;
|
|
173
|
+
choice: Resolution;
|
|
174
|
+
onChoose: (c: Resolution) => void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ConflictRow({
|
|
178
|
+
conflict,
|
|
179
|
+
localValue,
|
|
180
|
+
choice,
|
|
181
|
+
onChoose,
|
|
182
|
+
}: ConflictRowProps): React.ReactElement {
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
style={{
|
|
186
|
+
marginBottom: "1rem",
|
|
187
|
+
borderRadius: "0.375rem",
|
|
188
|
+
border: "1px solid #e5e7eb",
|
|
189
|
+
overflow: "hidden",
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<div
|
|
193
|
+
style={{
|
|
194
|
+
padding: "0.5rem 0.75rem",
|
|
195
|
+
backgroundColor: "#f9fafb",
|
|
196
|
+
borderBottom: "1px solid #e5e7eb",
|
|
197
|
+
display: "flex",
|
|
198
|
+
alignItems: "center",
|
|
199
|
+
gap: "0.5rem",
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<code style={{ fontSize: "0.8125rem", flex: 1 }}>{conflict.path}</code>
|
|
203
|
+
<span
|
|
204
|
+
style={{
|
|
205
|
+
fontSize: "0.75rem",
|
|
206
|
+
padding: "0.125rem 0.375rem",
|
|
207
|
+
borderRadius: "0.25rem",
|
|
208
|
+
backgroundColor:
|
|
209
|
+
conflict.strategy === "TRANSACTIONAL" ? "#fde8e8" : "#e0f2fe",
|
|
210
|
+
color:
|
|
211
|
+
conflict.strategy === "TRANSACTIONAL" ? "#991b1b" : "#0369a1",
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{conflict.strategy}
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div
|
|
219
|
+
style={{
|
|
220
|
+
display: "grid",
|
|
221
|
+
gridTemplateColumns: "1fr 1fr",
|
|
222
|
+
gap: 0,
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
{/* Mine */}
|
|
226
|
+
<OptionPanel
|
|
227
|
+
label="My change"
|
|
228
|
+
value={localValue}
|
|
229
|
+
selected={choice === "mine"}
|
|
230
|
+
onClick={() => onChoose("mine")}
|
|
231
|
+
accentColor="#2563eb"
|
|
232
|
+
/>
|
|
233
|
+
{/* Server */}
|
|
234
|
+
<OptionPanel
|
|
235
|
+
label="Server (authoritative)"
|
|
236
|
+
value={conflict.resolvedValue}
|
|
237
|
+
selected={choice === "server"}
|
|
238
|
+
onClick={() => onChoose("server")}
|
|
239
|
+
accentColor="#16a34a"
|
|
240
|
+
bordered
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface OptionPanelProps {
|
|
248
|
+
label: string;
|
|
249
|
+
value: unknown;
|
|
250
|
+
selected: boolean;
|
|
251
|
+
onClick: () => void;
|
|
252
|
+
accentColor: string;
|
|
253
|
+
bordered?: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function OptionPanel({
|
|
257
|
+
label,
|
|
258
|
+
value,
|
|
259
|
+
selected,
|
|
260
|
+
onClick,
|
|
261
|
+
accentColor,
|
|
262
|
+
bordered,
|
|
263
|
+
}: OptionPanelProps): React.ReactElement {
|
|
264
|
+
return (
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={onClick}
|
|
268
|
+
style={{
|
|
269
|
+
padding: "0.75rem",
|
|
270
|
+
textAlign: "left",
|
|
271
|
+
cursor: "pointer",
|
|
272
|
+
border: "none",
|
|
273
|
+
borderLeft: bordered ? "1px solid #e5e7eb" : undefined,
|
|
274
|
+
backgroundColor: selected ? `${accentColor}10` : "#ffffff",
|
|
275
|
+
outline: selected ? `2px solid ${accentColor}` : "none",
|
|
276
|
+
outlineOffset: "-2px",
|
|
277
|
+
transition: "background-color 100ms",
|
|
278
|
+
width: "100%",
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<div
|
|
282
|
+
style={{
|
|
283
|
+
fontSize: "0.75rem",
|
|
284
|
+
fontWeight: 600,
|
|
285
|
+
color: selected ? accentColor : "#6b7280",
|
|
286
|
+
marginBottom: "0.25rem",
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{selected && "✔ "}
|
|
290
|
+
{label}
|
|
291
|
+
</div>
|
|
292
|
+
<pre
|
|
293
|
+
style={{
|
|
294
|
+
margin: 0,
|
|
295
|
+
fontSize: "0.75rem",
|
|
296
|
+
fontFamily: "monospace",
|
|
297
|
+
whiteSpace: "pre-wrap",
|
|
298
|
+
wordBreak: "break-all",
|
|
299
|
+
color: "#1f2937",
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
{JSON.stringify(value, null, 2)}
|
|
303
|
+
</pre>
|
|
304
|
+
</button>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/** Extract the value at a JSON-pointer path (e.g. `/amount`) from an object. */
|
|
311
|
+
function getFieldValue(obj: unknown, pointer: string): unknown {
|
|
312
|
+
if (typeof obj !== "object" || obj === null) return undefined;
|
|
313
|
+
// Strip leading `/` and split on `/`
|
|
314
|
+
const segments = pointer.replace(/^\//, "").split("/");
|
|
315
|
+
let current: unknown = obj;
|
|
316
|
+
for (const seg of segments) {
|
|
317
|
+
if (typeof current !== "object" || current === null) return undefined;
|
|
318
|
+
current = (current as Record<string, unknown>)[seg];
|
|
319
|
+
}
|
|
320
|
+
return current;
|
|
321
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { RejectResponse } from "./wire";
|
|
3
|
+
import { UndoButton } from "./UndoButton";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Displayed as a dismissible banner at the top of the form whenever the server
|
|
7
|
+
* REJECTs an operation. Shows the rejection reason and provides a "Undo" and
|
|
8
|
+
* "Resolve conflicts" action.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReconciliationBannerProps {
|
|
11
|
+
/** The full server REJECT response. */
|
|
12
|
+
rejection: RejectResponse;
|
|
13
|
+
/** Called when the user clicks "Undo". */
|
|
14
|
+
onUndo: () => void;
|
|
15
|
+
/** Called when the user clicks "Resolve conflicts". */
|
|
16
|
+
onResolve: () => void;
|
|
17
|
+
/** Called when the user dismisses the banner. */
|
|
18
|
+
onDismiss: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ReconciliationBanner({
|
|
22
|
+
rejection,
|
|
23
|
+
onUndo,
|
|
24
|
+
onResolve,
|
|
25
|
+
onDismiss,
|
|
26
|
+
}: ReconciliationBannerProps): React.ReactElement {
|
|
27
|
+
const hasConflicts = rejection.patch.conflicts.length > 0;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
role="alert"
|
|
32
|
+
aria-live="assertive"
|
|
33
|
+
style={{
|
|
34
|
+
display: "flex",
|
|
35
|
+
alignItems: "center",
|
|
36
|
+
gap: "0.75rem",
|
|
37
|
+
padding: "0.75rem 1rem",
|
|
38
|
+
borderRadius: "0.375rem",
|
|
39
|
+
backgroundColor: "#fef3c7",
|
|
40
|
+
borderLeft: "4px solid #f59e0b",
|
|
41
|
+
color: "#92400e",
|
|
42
|
+
fontSize: "0.875rem",
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<span style={{ flex: 1 }}>
|
|
46
|
+
<strong>Sync conflict:</strong> {rejection.reason}
|
|
47
|
+
</span>
|
|
48
|
+
|
|
49
|
+
<UndoButton onUndo={onUndo} />
|
|
50
|
+
|
|
51
|
+
{hasConflicts && (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={onResolve}
|
|
55
|
+
style={{
|
|
56
|
+
padding: "0.25rem 0.75rem",
|
|
57
|
+
borderRadius: "0.25rem",
|
|
58
|
+
border: "1px solid #b45309",
|
|
59
|
+
backgroundColor: "transparent",
|
|
60
|
+
color: "#92400e",
|
|
61
|
+
cursor: "pointer",
|
|
62
|
+
fontSize: "0.875rem",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
Resolve conflicts ({rejection.patch.conflicts.length})
|
|
66
|
+
</button>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
aria-label="Dismiss"
|
|
72
|
+
onClick={onDismiss}
|
|
73
|
+
style={{
|
|
74
|
+
background: "none",
|
|
75
|
+
border: "none",
|
|
76
|
+
cursor: "pointer",
|
|
77
|
+
color: "#92400e",
|
|
78
|
+
fontSize: "1rem",
|
|
79
|
+
lineHeight: 1,
|
|
80
|
+
padding: "0.125rem",
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
✕
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A small undo button shown inline (e.g. in the {@link ReconciliationBanner}).
|
|
5
|
+
* Reverts the optimistic local change that was rejected by the server.
|
|
6
|
+
*/
|
|
7
|
+
export interface UndoButtonProps {
|
|
8
|
+
/** Callback invoked when the user clicks the undo button. */
|
|
9
|
+
onUndo: () => void;
|
|
10
|
+
/** Optional label (defaults to "Undo"). */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** Whether the undo action is currently available. */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function UndoButton({
|
|
17
|
+
onUndo,
|
|
18
|
+
label = "Undo",
|
|
19
|
+
disabled = false,
|
|
20
|
+
}: UndoButtonProps): React.ReactElement {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
onClick={onUndo}
|
|
25
|
+
disabled={disabled}
|
|
26
|
+
aria-label="Undo last change"
|
|
27
|
+
style={{
|
|
28
|
+
display: "inline-flex",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
gap: "0.25rem",
|
|
31
|
+
padding: "0.25rem 0.75rem",
|
|
32
|
+
borderRadius: "0.25rem",
|
|
33
|
+
border: "1px solid #b45309",
|
|
34
|
+
backgroundColor: "#ffffff",
|
|
35
|
+
color: "#92400e",
|
|
36
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
37
|
+
fontSize: "0.875rem",
|
|
38
|
+
opacity: disabled ? 0.5 : 1,
|
|
39
|
+
transition: "background-color 150ms",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
↩ {label}
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
}
|