Skip to content

Commit 02cc197

Browse files
baozhoutaoclaude
andcommitted
fix(detail): distinguish "in approval (editable)" from locked, and stop losing write warnings (framework#3794)
The record page told the user the opposite of what the backend does, in both directions. The approval band rendered "Locked for approval" on any pending request, ignoring the node's `lockRecord`. A `lockRecord: false` node is pending WITHOUT locking — that is how an approver amends a record while deciding on it — so the band hid the feature; meanwhile the header Edit button stayed enabled on a genuinely locked record, letting the user fill a form the save would reject with RECORD_LOCKED. `useRecordApprovals` now reads `locks_record` off the pending request (framework#3794), `RecordDetailView` derives `approvalPending` and `approvalLocked` separately and threads both through `InlineEditProvider`, and the band picks the amber lock or a neutral "In approval (editable)" from the host's signals. With no approvals-aware host the `approval_status` fallback is unchanged and still assumes a lock. The write-warning toast (framework #3431/#3455) never fired on `batchTransaction`, which is the record form's save path for a master-detail record — precisely where a user edits a `readonlyWhen`-locked field. It now emits per event, resolving each back to its operation via the response `index`. The toast is localized (en + zh) and worded by reason: a `readonly_when` strip says the field is not editable in this record's current state, not that it is read-only. Finally, the user-state adapter no longer hand-stamps the server-managed `updated_at`, which the server strips and reports — that fired a "Some fields were not saved" warning about a field no user touched, on ordinary page loads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3c4d935 commit 02cc197

14 files changed

Lines changed: 422 additions & 58 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@object-ui/plugin-detail": minor
3+
"@object-ui/app-shell": minor
4+
"@object-ui/data-objectstack": minor
5+
"@object-ui/react": minor
6+
"@object-ui/i18n": minor
7+
---
8+
9+
fix(detail): say what the record actually allows — "in approval (editable)" vs locked, and warn on silently stripped fields (framework#3794)
10+
11+
The Console reported record writability wrong in both directions during an
12+
approval, so a user had nothing to go on: what they *could* edit said "locked",
13+
and what they *couldn't* said "updated successfully".
14+
15+
**"Locked for approval" was painted over every pending approval.** The band and
16+
the recall affordance rendered on any open request, ignoring the node's
17+
`lockRecord`. A node declaring `lockRecord: false` is pending *without* locking —
18+
the server's hook lets the write through on purpose, so the approver can amend
19+
the record as part of deciding on it — and the band told them not to bother. The
20+
mirror image was just as bad: on a genuinely locked record the header **Edit**
21+
button stayed live, so the user filled a whole form before the save came back
22+
`RECORD_LOCKED`.
23+
24+
The pending request is now authoritative for both signals. `useRecordApprovals`
25+
reads `locks_record` (framework#3794, resolved from the same node-config snapshot
26+
the lock hook reads); `RecordDetailView` derives `approvalPending` and
27+
`approvalLocked` separately and threads both through `InlineEditProvider`
28+
(new `approvalPending` prop). The band renders the amber lock only when the
29+
record is really locked, a neutral "In approval (editable)" / 审批中(可编辑)
30+
otherwise, and the header Edit CTA is disabled on a locked record. With no
31+
approvals-aware host, the old `approval_status`-field fallback is unchanged and
32+
still assumes a lock — the safe direction.
33+
34+
**Silently stripped fields now surface on the record form's save path.** The
35+
adapter emitted a write-warning for `create`/`update` responses carrying
36+
`droppedFields`, but not for `batchTransaction` — which is how the record form
37+
saves a master-detail record, i.e. the one surface where a user actually edits a
38+
`readonlyWhen`-locked field. `batchTransaction` now emits one warning per event,
39+
resolving each back to its operation via the response's `index`.
40+
41+
The toast itself was hardcoded English and called every strip "read-only". It is
42+
now localized (`detail.writeStripped*`, en + zh) and worded by reason:
43+
`readonly_when` says the field is not editable *in this record's current state*,
44+
which is what actually happened — the field is editable in other states and the
45+
form rendered it as an ordinary input.
46+
47+
**And it stopped crying wolf.** `createObjectStackUserStateAdapter` hand-stamped
48+
the server-managed `updated_at` on every recents/favorites write, which the
49+
server strips and reports — so the console popped "Some fields were not saved"
50+
about a field no user ever touched, on page loads, drowning the signal the toast
51+
exists for. It no longer sends the column; the server stamps it anyway.

packages/app-shell/src/hooks/useRecordApprovals.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ export interface ApprovalRequestLite {
3232
pending_approvers?: string[] | null;
3333
submitted_at?: string;
3434
completed_at?: string | null;
35+
/**
36+
* Whether THIS request's node locks the record for writes (framework #3794).
37+
* Read from the node-config snapshot's `lockRecord` by the same server that
38+
* enforces the lock, so it cannot disagree with the `beforeUpdate` hook.
39+
*
40+
* `false` ⇒ pending but writable — the node deliberately lets the approver
41+
* amend the record while deciding. Absent ⇒ an older server with no opinion;
42+
* callers assume locked (the safe direction).
43+
*/
44+
locks_record?: boolean;
3545
}
3646

3747
interface UseRecordApprovalsResult {

packages/app-shell/src/providers/AdapterProvider.tsx

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,59 @@
77
* @module
88
*/
99

10-
import { useState, useEffect, type ReactNode } from 'react';
10+
import { useState, useEffect, useRef, type ReactNode } from 'react';
1111
import { toast } from 'sonner';
1212
import { ObjectStackAdapter, type WriteWarningEvent } from '@object-ui/data-objectstack';
1313
import { createAuthenticatedFetch } from '@object-ui/auth';
1414
import { AdapterCtx } from '@object-ui/react';
15+
import { useObjectTranslation } from '@object-ui/i18n';
1516
import { installSettleSignalGlobal, withSettleSignal } from '../observability/settleSignal';
1617

1718
export { useAdapter } from '@object-ui/react';
1819

20+
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
21+
1922
/**
2023
* Turn a write-warning (framework #3431/#3455) into a human toast. The write
21-
* SUCCEEDED — some caller-supplied fields were legally stripped (read-only /
22-
* locked by state), so we tell the user rather than let it pass silently.
24+
* SUCCEEDED — some caller-supplied fields were legally stripped, so we tell the
25+
* user rather than let it pass silently.
26+
*
27+
* The REASON decides the wording (#3794). `readonly_when` is not "this field is
28+
* read-only" — the field is editable in other states and the form rendered it
29+
* as an ordinary input; what happened is that THIS record's current state locks
30+
* it. Saying "read-only" there sends the user looking for a permission problem
31+
* that doesn't exist. Fields are named by their machine name: the event carries
32+
* no labels, and a wrong guess at a label is worse than an exact key.
2333
*/
24-
function toastWriteWarning(ev: WriteWarningEvent): void {
25-
const fields = Array.from(new Set(ev.droppedFields.flatMap((d) => d.fields)));
26-
if (fields.length === 0) return;
27-
const list = fields.join(', ');
28-
const description =
29-
fields.length === 1
30-
? `The read-only field “${list}” could not be changed and was not saved.`
31-
: `${fields.length} read-only fields could not be changed and were not saved: ${list}.`;
32-
toast.warning('Some fields were not saved', { description });
34+
function toastWriteWarning(ev: WriteWarningEvent, t: TranslateFn): void {
35+
const byReason = new Map<string, string[]>();
36+
for (const d of ev.droppedFields) {
37+
const seen = byReason.get(d.reason) ?? [];
38+
for (const f of d.fields) if (!seen.includes(f)) seen.push(f);
39+
byReason.set(d.reason, seen);
40+
}
41+
const lines: string[] = [];
42+
for (const [reason, fields] of byReason) {
43+
if (fields.length === 0) continue;
44+
const list = fields.join(', ');
45+
lines.push(
46+
reason === 'readonly_when'
47+
? t('detail.writeStrippedByState', {
48+
fields: list,
49+
defaultValue:
50+
'Not editable in this record\'s current state, so it was not saved: {{fields}}',
51+
})
52+
: t('detail.writeStrippedReadonly', {
53+
fields: list,
54+
defaultValue: 'Read-only, so it was not saved: {{fields}}',
55+
}),
56+
);
57+
}
58+
if (lines.length === 0) return;
59+
toast.warning(
60+
t('detail.writeStrippedTitle', { defaultValue: 'Some fields were not saved' }),
61+
{ description: lines.join('\n') },
62+
);
3363
}
3464

3565
interface AdapterProviderProps {
@@ -44,6 +74,13 @@ interface AdapterProviderProps {
4474
*/
4575
export function AdapterProvider({ children, adapter: externalAdapter }: AdapterProviderProps) {
4676
const [adapter, setAdapter] = useState<ObjectStackAdapter | null>(externalAdapter ?? null);
77+
// The warning listener is registered ONCE (the adapter outlives a language
78+
// switch), so read `t` through a ref instead of capturing it — otherwise a
79+
// user who switches language mid-session keeps getting the old locale, and
80+
// adding `t` to the effect deps would tear down and rebuild the adapter.
81+
const { t } = useObjectTranslation();
82+
const tRef = useRef(t);
83+
tRef.current = t;
4784

4885
useEffect(() => {
4986
if (externalAdapter) {
@@ -72,7 +109,9 @@ export function AdapterProvider({ children, adapter: externalAdapter }: AdapterP
72109

73110
// Surface silently-stripped write fields (#3431/#3455) as a toast so a
74111
// read-only value the user typed doesn't just vanish on save.
75-
unsubscribeWriteWarning = a.onWriteWarning(toastWriteWarning);
112+
unsubscribeWriteWarning = a.onWriteWarning((ev) =>
113+
toastWriteWarning(ev, tRef.current as TranslateFn),
114+
);
76115

77116
await a.connect();
78117

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -868,15 +868,31 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
868868
void approvalsRef.current.refresh();
869869
}, [recordInvalidationNonce]);
870870

871-
// The shared lock signal for the inline-edit session: the record's own
872-
// `approval_status` (what the DetailView lock band keys off) OR an open
873-
// pending request from the approvals API — the latter catches backends
874-
// that lock via the request record without materializing the field.
875-
const approvalLocked =
871+
// "An approval is in flight" — the record's own `approval_status` mirror OR
872+
// an open pending request from the approvals API (the latter catches backends
873+
// that track approvals without materializing the field).
874+
const approvalPending =
876875
(pageRecord as any)?.approval_status === 'pending' ||
877876
(pageRecord as any)?.approval_status === 'in_approval' ||
878877
!!approvals.pendingRequest;
879878

879+
// …and, separately, "the record is LOCKED for writes" (#3794). These are not
880+
// the same statement: an approval node with `lockRecord: false` leaves the
881+
// record editable on purpose — that is how an approver amends a record while
882+
// deciding on it — and the server's lock hook honors it (`lockRecord === false`
883+
// ⇒ the update goes through). Conflating them made the console claim "Locked
884+
// for approval" on a record it would happily save, so approvers never tried.
885+
//
886+
// The pending REQUEST is authoritative when we have one: `locks_record` comes
887+
// from the same node-config snapshot the server hook reads (framework #3794).
888+
// With no request in hand — approvals plugin absent, still loading, or a
889+
// field-only backend — fall back to the `approval_status` mirror and assume
890+
// locked, which is the safe direction (worst case we hide an edit affordance
891+
// instead of letting a user fill a form the server will reject).
892+
const approvalLocked = approvals.pendingRequest
893+
? approvals.pendingRequest.locks_record !== false
894+
: approvalPending;
895+
880896
const approvalHandler = useCallback(async (action: ActionDef) => {
881897
const target = action.target || action.name;
882898
const params = (action.params && !Array.isArray(action.params))
@@ -1796,6 +1812,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
17961812
// can't be stacked on top of the draft (two competing edit sessions
17971813
// with no reconciliation).
17981814
disableDuringInlineEdit: true,
1815+
// #3794 — an approval-LOCKED record refuses every write (the server
1816+
// answers RECORD_LOCKED), so opening the form only to be rejected on
1817+
// Save after filling a screen is wasted work. Disabled rather than
1818+
// hidden: the user should see the affordance exists and is off, with
1819+
// the lock band next to it saying why. Note this is the LOCK, not the
1820+
// mere presence of an approval — a `lockRecord: false` node keeps Edit
1821+
// live, which is the point of that setting.
1822+
disabled: approvalLocked,
17991823
onClick: () => onEdit({ id: pureRecordId }),
18001824
} as any);
18011825
}
@@ -1919,10 +1943,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
19191943
DetailView "Locked for approval" band renders from the SAME
19201944
dual-source `approvalLocked` that gated `canEdit` — engaging even
19211945
on backends that track the lock via approval requests only and
1922-
never materialize an `approval_status` field (objectui#2618). */}
1946+
never materialize an `approval_status` field (objectui#2618).
1947+
`approvalPending` is the separate "a request is in flight" signal
1948+
(#3794): on a `lockRecord: false` node it is true while `locked` is
1949+
false, and the band says "in approval (editable)" instead of lying
1950+
about a lock the server does not enforce. */}
19231951
<InlineEditProvider
19241952
canEdit={resolveRecordHeaderActionGates(objectDef, effectiveApiOperations).edit && !approvalLocked}
19251953
locked={approvalLocked}
1954+
approvalPending={approvalPending}
19261955
lockedReason={t('detail.lockedTooltip', {
19271956
defaultValue: 'This record has a pending approval request; editing is locked',
19281957
})}

packages/data-objectstack/src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,41 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
973973
this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields });
974974
}
975975

976+
/**
977+
* Same, for the cross-object transactional batch (framework #3794). Its
978+
* response hangs the events off a top-level `droppedFields` list, each tagged
979+
* with the `index` of the operation it came from — `results` entries are bare
980+
* record echoes with nowhere to hang a per-row list.
981+
*
982+
* This is the path that matters most for the warning: `batchTransaction` is
983+
* how the console's record form saves a master-detail record, so a
984+
* `readonlyWhen`-locked field edited in that form was stripped server-side
985+
* while the UI reported a plain success. The operation kind is taken from the
986+
* originating op so the toast doesn't call an update a create.
987+
*/
988+
private notifyBatchDroppedFields(
989+
operations: BatchTransactionOperation[],
990+
payload: unknown,
991+
): void {
992+
const dropped = (payload as { droppedFields?: unknown } | null | undefined)?.droppedFields;
993+
if (!Array.isArray(dropped) || dropped.length === 0) return;
994+
for (const entry of dropped) {
995+
if (!entry || typeof entry !== 'object') continue;
996+
const e = entry as DroppedFieldsEvent & { index?: number };
997+
if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
998+
const op = typeof e.index === 'number' ? operations[e.index] : undefined;
999+
// `delete` never drops fields; anything unexpected reads as an update,
1000+
// which is the truthful default for a batch that echoed a strip.
1001+
const operation: 'create' | 'update' = (op?.action ?? 'create') === 'create' ? 'create' : 'update';
1002+
this.emitWriteWarning({
1003+
operation,
1004+
resource: e.object ?? op?.object ?? '',
1005+
...(op?.id !== undefined && op?.id !== null ? { id: op.id } : {}),
1006+
droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }],
1007+
});
1008+
}
1009+
}
1010+
9761011
/**
9771012
* Subscribe to write-warning events (a create/update dropped caller-supplied
9781013
* fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
@@ -1225,6 +1260,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
12251260
// dependency floor (framework #3271). No hand-rolled POST /api/v1/batch.
12261261
const payload = await this.client.data.batchTransaction(operations);
12271262
this.emitBatchMutations(operations, payload?.results);
1263+
this.notifyBatchDroppedFields(operations, payload);
12281264
return payload;
12291265
} catch (err) {
12301266
// On a non-declaring backend, endpoint missing (404/405) or a runtime that

packages/data-objectstack/src/onWriteWarning.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,56 @@ describe('ObjectStackAdapter.onWriteWarning', () => {
9494
expect(events).toEqual([]);
9595
});
9696

97+
// ── cross-object batch (framework #3794) ─────────────────────────────────
98+
//
99+
// `batchTransaction` is the console record form's save path for a
100+
// master-detail record, so this is where a `readonlyWhen` strip actually
101+
// reaches a user editing a form. Its response tags each event with the index
102+
// of the operation that produced it.
103+
104+
it('emits a write-warning per event on a cross-object batch, resolving the op', async () => {
105+
const batchTransaction = vi.fn().mockResolvedValue({
106+
results: [{ id: 'acc1' }, { id: 'inv1' }],
107+
droppedFields: [
108+
{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
109+
],
110+
});
111+
const ds = makeDS({ batchTransaction });
112+
ds.atomicBatchCapability = true;
113+
const events: WriteWarningEvent[] = [];
114+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
115+
116+
await ds.batchTransaction([
117+
{ object: 'account', action: 'create', data: { name: 'Acme' } },
118+
{ object: 'invoice', action: 'update', id: 'inv1', data: { status: 'paid', tax_rate: 9 } },
119+
]);
120+
121+
expect(events).toEqual([
122+
{
123+
operation: 'update',
124+
resource: 'invoice',
125+
id: 'inv1',
126+
droppedFields: [{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' }],
127+
},
128+
]);
129+
});
130+
131+
it('does NOT emit for a clean batch or a malformed droppedFields list', async () => {
132+
const batchTransaction = vi
133+
.fn()
134+
.mockResolvedValueOnce({ results: [{ id: 'a' }] })
135+
.mockResolvedValueOnce({ results: [{ id: 'a' }], droppedFields: [{ object: 'x', fields: [], index: 0 }] });
136+
const ds = makeDS({ batchTransaction });
137+
ds.atomicBatchCapability = true;
138+
const events: WriteWarningEvent[] = [];
139+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
140+
141+
await ds.batchTransaction([{ object: 'x', action: 'create', data: {} }]);
142+
await ds.batchTransaction([{ object: 'x', action: 'create', data: {} }]);
143+
144+
expect(events).toEqual([]);
145+
});
146+
97147
it('stops delivering after unsubscribe', async () => {
98148
const create = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
99149
const ds = makeDS({ create });

0 commit comments

Comments
 (0)