Skip to content

Commit 5b52624

Browse files
authored
feat(detail): record-level inline edit — shared InlineEditContext + one atomic Save (#2542)
objectui#2407 P1. Lift the inline-edit session out of DetailView into a shared record-level InlineEditContext (@object-ui/react) + a record-level <InlineEditSaveBar> (plugin-detail) that commits the whole draft in ONE atomic update(obj,id,draft,{ifMatch:updated_at}) with a 409→ConcurrentUpdateDialog path (callback mode preserves the drawer's per-field contract). DetailView consumes the context (read-only without a provider); record:details + RecordDetailDrawer host the provider+bar. Guardrails (computed/readonly/system non-editable, canEdit gate, OCC, only-edited-keys) preserved. Verified: 200 tests (21 files) incl. new InlineEditContext + InlineEditSaveBar suites; react + plugin-detail vite builds + type-check clean. Browser-verified in real Chromium against a mock-DataSource harness of the record:details path: double-click → whole record enters edit → editing two fields → exactly ONE update('project','p1',{budget,end_date},{ifMatch:'v1'}) with only edited keys → edit exits, no JS errors.
1 parent d90d773 commit 5b52624

12 files changed

Lines changed: 858 additions & 379 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@object-ui/react": minor
3+
"@object-ui/plugin-detail": minor
4+
---
5+
6+
feat(detail): record-level inline edit — shared `InlineEditContext` + one atomic Save (objectui#2407 P1)
7+
8+
Lift the inline-edit session out of `DetailView`'s private state into a
9+
record-level, shared context so a record page's surfaces can share ONE draft and
10+
commit it in ONE atomic, cross-field-validated write (replacing the per-field
11+
save loop).
12+
13+
- **`InlineEditContext` / `InlineEditProvider` / `useInlineEdit`** (@object-ui/react)
14+
— pure UI state (`editing`, `canEdit`, `draft`, `autoFocusField`, `saving`,
15+
`error` + `enter` / `setField` / `cancel` / `reset`). A *separate* context from
16+
`RecordContext` (mirrors `HighlightFieldsContext`) so per-keystroke draft churn
17+
doesn't re-render other `record:*` consumers.
18+
- **`<InlineEditSaveBar>`** (@object-ui/plugin-detail) — the record-level sticky
19+
Save/Cancel bar. Commits the whole draft in ONE
20+
`dataSource.update(obj, id, draft, { ifMatch: data.updated_at })``refresh()`;
21+
a `409 CONCURRENT_UPDATE` reuses `<ConcurrentUpdateDialog>` (reload / overwrite).
22+
A callback mode (`onFieldSave`) preserves the drawer's per-field persistence
23+
contract with plugin-gantt/calendar/kanban.
24+
- **`DetailView`** now consumes `useInlineEdit()` instead of owning inline-edit
25+
state; its header/inline Save-Cancel bars and per-field batch-save are removed
26+
(the approval-lock badge stays). Rendered without a provider it is simply
27+
read-only.
28+
- **`record:details`** and **`RecordDetailDrawer`** each wrap their `DetailView`
29+
in an `<InlineEditProvider>` + `<InlineEditSaveBar>`. The object-lifecycle /
30+
permission gate flows through `canEdit`; computed / readonly / system fields
31+
and the OCC path are unchanged.
32+
33+
Guardrails preserved: computed (`formula`/`summary`/`rollup`/`auto_number`) +
34+
`readonly` + system fields expose no editor; `canEdit` gate; OCC (`ifMatch` +
35+
`ConcurrentUpdateDialog`); the atomic partial update carries only user-edited
36+
keys (never computed/read-only). Editable highlights ride on top of this in P2.

packages/plugin-detail/src/DetailView.tsx

Lines changed: 69 additions & 215 deletions
Large diffs are not rendered by default.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* InlineEditSaveBar — the record-level sticky Save/Cancel bar for inline edit
9+
* (objectui#2407 P1). Reads the shared `InlineEditContext` (draft / editing /
10+
* saving / error) and commits the WHOLE draft in ONE atomic write, so
11+
* cross-field validation runs against a consistent record instead of the old
12+
* per-field save-on-blur loop.
13+
*
14+
* Two persistence modes:
15+
* - **DataSource mode** (record page): one `dataSource.update(obj, id, draft,
16+
* { ifMatch: data.updated_at })` → `refresh()`. A `409 CONCURRENT_UPDATE`
17+
* opens `<ConcurrentUpdateDialog>` (reload / overwrite), reusing the OCC UX
18+
* that previously lived in `record:details`.
19+
* - **Callback mode** (standalone drawer): loops a caller-supplied
20+
* `onFieldSave(field, value)` over the draft, preserving the drawer's
21+
* existing per-field persistence contract with plugin-gantt/calendar/kanban.
22+
*
23+
* The bar renders nothing unless the record is actively being edited.
24+
*/
25+
26+
import * as React from 'react';
27+
import { Button, cn } from '@object-ui/components';
28+
import { Check, X, Loader2 } from 'lucide-react';
29+
import { useInlineEdit } from '@object-ui/react';
30+
import { useDetailTranslation } from './useDetailTranslation';
31+
import {
32+
ConcurrentUpdateDialog,
33+
isConcurrentUpdateError,
34+
type ConcurrentUpdateConflict,
35+
} from './ConcurrentUpdateDialog';
36+
37+
export interface InlineEditSaveBarProps {
38+
/** DataSource for the atomic-update path (record page). */
39+
dataSource?: any;
40+
/** Object machine name for the atomic-update path. */
41+
objectName?: string;
42+
/** Record id for the atomic-update path. */
43+
recordId?: string | number | null;
44+
/** Current server record — supplies `updated_at` for the OCC `ifMatch` token. */
45+
data?: any;
46+
/** Re-fetch the record after a successful save / reload. */
47+
refresh?: () => void | Promise<void>;
48+
/** Map a field machine name to a user-facing label (for the conflict dialog). */
49+
fieldLabelFor?: (name: string) => string | undefined;
50+
/**
51+
* Callback-persistence mode (drawer): when provided, the save loops this
52+
* per-field callback over the draft instead of issuing a DataSource update.
53+
* Takes precedence over the DataSource path.
54+
*/
55+
onFieldSave?: (field: string, value: any) => void | Promise<void>;
56+
/** When true, disables Save and shows a lock hint (e.g. approval-locked). */
57+
locked?: boolean;
58+
/** Tooltip/label explaining why the record is locked. */
59+
lockedHint?: string;
60+
className?: string;
61+
}
62+
63+
/** Strip noisy backend prefixes so the inline error reads cleanly. */
64+
function cleanError(err: any): string {
65+
const raw = err?.message || err?.error || String(err ?? 'Save failed');
66+
return String(raw)
67+
.replace(/^\[[^\]]+\]\s*/, '')
68+
.replace(/^[A-Z][A-Z0-9_]+:\s*/, '');
69+
}
70+
71+
/**
72+
* Issue a partial-record update through whichever method the DataSource
73+
* exposes. Mirrors the update/updateOne/patch fallback + `ifMatch` OCC token
74+
* that `record:details` used for its per-field save.
75+
*/
76+
async function updateVia(
77+
ds: any,
78+
objectName: string,
79+
recordId: string | number,
80+
patch: Record<string, any>,
81+
opts?: { ifMatch?: string },
82+
): Promise<void> {
83+
if (typeof ds?.update === 'function') {
84+
await ds.update(objectName, recordId, patch, opts);
85+
} else if (typeof ds?.updateOne === 'function') {
86+
await ds.updateOne(objectName, recordId, patch, opts);
87+
} else if (typeof ds?.patch === 'function') {
88+
await ds.patch(objectName, recordId, patch, opts);
89+
} else {
90+
throw new Error(
91+
'[InlineEditSaveBar] DataSource exposes no update/updateOne/patch method; cannot persist inline edit',
92+
);
93+
}
94+
}
95+
96+
export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
97+
dataSource,
98+
objectName,
99+
recordId,
100+
data,
101+
refresh,
102+
fieldLabelFor,
103+
onFieldSave,
104+
locked = false,
105+
lockedHint,
106+
className,
107+
}) => {
108+
const { t } = useDetailTranslation();
109+
const inline = useInlineEdit();
110+
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
111+
const [conflictBusy, setConflictBusy] = React.useState(false);
112+
113+
const canAtomic = !!dataSource && !!objectName && recordId != null;
114+
115+
/**
116+
* Build the conflict payload for `<ConcurrentUpdateDialog>` from a 409. A
117+
* single-field draft shows the classic per-field before/after; a multi-field
118+
* draft shows a record-level summary (the dialog JSON-stringifies objects).
119+
*/
120+
const buildConflict = React.useCallback(
121+
(draft: Record<string, any>, err: any): ConcurrentUpdateConflict => {
122+
const keys = Object.keys(draft);
123+
const current = (err?.currentRecord ?? null) as Record<string, unknown> | null;
124+
if (keys.length === 1) {
125+
const f = keys[0];
126+
return {
127+
field: f,
128+
label: fieldLabelFor?.(f),
129+
pendingValue: draft[f],
130+
currentValue: current ? current[f] : undefined,
131+
currentVersion: err?.currentVersion,
132+
currentRecord: current,
133+
};
134+
}
135+
const currentSubset = current
136+
? Object.fromEntries(keys.map((k) => [k, current[k]]))
137+
: undefined;
138+
return {
139+
field: keys.join(', '),
140+
label: t('detail.concurrentUpdateRecordLabel', { defaultValue: 'this record' }),
141+
pendingValue: draft,
142+
currentValue: currentSubset,
143+
currentVersion: err?.currentVersion,
144+
currentRecord: current,
145+
};
146+
},
147+
[fieldLabelFor, t],
148+
);
149+
150+
const handleSave = React.useCallback(async () => {
151+
if (!inline) return;
152+
const draft = inline.draft;
153+
const entries = Object.entries(draft);
154+
// No edits staged → just leave edit mode (matches the old empty-save path).
155+
if (entries.length === 0) {
156+
inline.reset();
157+
return;
158+
}
159+
inline.setSaving(true);
160+
inline.setError(null);
161+
try {
162+
if (onFieldSave) {
163+
// Callback mode (drawer): persist each edited field sequentially so a
164+
// single backend rejection short-circuits, preserving the caller's
165+
// per-field contract.
166+
for (const [field, value] of entries) {
167+
await onFieldSave(field, value);
168+
}
169+
} else if (canAtomic) {
170+
// DataSource mode (record page): ONE atomic write of only the edited
171+
// keys, OCC-guarded by the record's current updated_at.
172+
const ifMatch =
173+
typeof data?.updated_at === 'string' ? (data.updated_at as string) : undefined;
174+
await updateVia(dataSource, objectName!, recordId!, draft, ifMatch ? { ifMatch } : undefined);
175+
await refresh?.();
176+
}
177+
inline.reset();
178+
} catch (err) {
179+
if (isConcurrentUpdateError(err) && canAtomic) {
180+
// Stay in edit mode; the dialog drives reload / overwrite.
181+
setConflict(buildConflict(draft, err));
182+
} else {
183+
inline.setError(cleanError(err));
184+
}
185+
} finally {
186+
inline.setSaving(false);
187+
}
188+
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
189+
190+
const closeConflict = React.useCallback(() => {
191+
setConflict(null);
192+
setConflictBusy(false);
193+
}, []);
194+
195+
const handleConflictReload = React.useCallback(async () => {
196+
setConflictBusy(true);
197+
try {
198+
await refresh?.();
199+
} finally {
200+
// Discard the pending draft — the user chose the server's version.
201+
inline?.reset();
202+
closeConflict();
203+
}
204+
}, [refresh, inline, closeConflict]);
205+
206+
const handleConflictOverwrite = React.useCallback(async () => {
207+
if (!conflict || !canAtomic) {
208+
closeConflict();
209+
return;
210+
}
211+
setConflictBusy(true);
212+
try {
213+
// Re-key the write against the version the server reported in the 409 —
214+
// "I've seen the newer record, apply my whole draft on top of it."
215+
const draft = inline?.draft ?? {};
216+
const opts = conflict.currentVersion ? { ifMatch: conflict.currentVersion } : undefined;
217+
await updateVia(dataSource, objectName!, recordId!, draft, opts);
218+
await refresh?.();
219+
inline?.reset();
220+
} catch (err) {
221+
inline?.setError(cleanError(err));
222+
} finally {
223+
closeConflict();
224+
}
225+
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
226+
227+
// Render nothing unless a provider is present and the record is being edited.
228+
if (!inline || !inline.editing) return null;
229+
230+
return (
231+
<>
232+
<div
233+
className={cn(
234+
'sticky bottom-0 z-30 mt-4 flex flex-wrap items-center justify-end gap-2 rounded-md border bg-background/95 px-3 py-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/80',
235+
className,
236+
)}
237+
role="region"
238+
aria-label={t('detail.editFieldsInline')}
239+
>
240+
{inline.error && (
241+
<div
242+
role="alert"
243+
className="mr-auto max-w-md rounded-md border border-destructive/20 bg-destructive/10 px-2 py-1 text-xs text-destructive"
244+
>
245+
{inline.error}
246+
</div>
247+
)}
248+
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
249+
we only disable Save so a locked record can't be written. */}
250+
<Button
251+
variant="ghost"
252+
size="sm"
253+
onClick={inline.cancel}
254+
disabled={inline.saving}
255+
className="gap-2"
256+
>
257+
<X className="h-4 w-4" />
258+
<span>{t('detail.cancel')}</span>
259+
</Button>
260+
<Button
261+
variant="default"
262+
size="sm"
263+
onClick={handleSave}
264+
disabled={inline.saving || locked}
265+
className="gap-2"
266+
title={locked ? lockedHint : undefined}
267+
>
268+
{inline.saving ? (
269+
<Loader2 className="h-4 w-4 animate-spin" />
270+
) : (
271+
<Check className="h-4 w-4" />
272+
)}
273+
<span>{inline.saving ? t('detail.saving') : t('detail.save')}</span>
274+
</Button>
275+
</div>
276+
<ConcurrentUpdateDialog
277+
open={!!conflict}
278+
conflict={conflict}
279+
busy={conflictBusy}
280+
onCancel={closeConflict}
281+
onReload={handleConflictReload}
282+
onOverwrite={handleConflictOverwrite}
283+
/>
284+
</>
285+
);
286+
};
287+
288+
export default InlineEditSaveBar;

packages/plugin-detail/src/RecordDetailDrawer.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ import {
2828
SheetTitle,
2929
} from '@object-ui/components';
3030
import type { DataSource } from '@object-ui/types';
31+
import { InlineEditProvider } from '@object-ui/react';
3132
import { DetailView } from './DetailView';
33+
import { InlineEditSaveBar } from './InlineEditSaveBar';
3234
import { useDetailTranslation } from './useDetailTranslation';
3335

3436
/**
@@ -283,6 +285,10 @@ export function RecordDetailDrawer({
283285
<SheetTitle>{title}</SheetTitle>
284286
</SheetHeader>
285287
<div className="px-6 pt-6 pb-6">
288+
{/* One inline-edit session scoped to this drawer. `canEdit` gates on
289+
handler presence so an omitted onFieldSave yields a strictly
290+
read-only drawer (objectui#2407 P1). */}
291+
<InlineEditProvider canEdit={!!onFieldSave}>
286292
<DetailView
287293
dataSource={dataSource}
288294
// Capability = handler presence: a caller that omits onFieldSave /
@@ -322,17 +328,6 @@ export function RecordDetailDrawer({
322328
]
323329
: undefined,
324330
} as any}
325-
onFieldSave={onFieldSave ? async (field, value) => {
326-
try {
327-
await onFieldSave(field, value);
328-
} catch (err) {
329-
console.error('[RecordDetailDrawer] inline field save failed:', err);
330-
// Rethrow so DetailView rolls back the optimistic value and
331-
// shows the failure inline — swallowing here made a rejected
332-
// save look successful (stale value kept, no message).
333-
throw err;
334-
}
335-
} : undefined}
336331
onDelete={onDelete ? async () => {
337332
try {
338333
await onDelete();
@@ -342,6 +337,22 @@ export function RecordDetailDrawer({
342337
}
343338
} : undefined}
344339
/>
340+
{/* Record-level Save/Cancel bar. Callback mode: loops the caller's
341+
per-field onFieldSave over the draft, preserving the drawer's
342+
existing persistence contract (plugin-gantt/calendar/kanban). */}
343+
<InlineEditSaveBar
344+
onFieldSave={onFieldSave ? async (field, value) => {
345+
try {
346+
await onFieldSave(field, value);
347+
} catch (err) {
348+
console.error('[RecordDetailDrawer] inline field save failed:', err);
349+
// Rethrow so the save bar surfaces the failure inline and keeps
350+
// the draft — swallowing made a rejected save look successful.
351+
throw err;
352+
}
353+
} : undefined}
354+
/>
355+
</InlineEditProvider>
345356
</div>
346357
</SheetContent>
347358
</Sheet>

0 commit comments

Comments
 (0)