Skip to content

Commit 4f48d2b

Browse files
feat(ui): an edit form's state-machine select offers only the moves the server accepts (#26033)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi955CwQcSiWwJCH5htb8Ncqt)_ Part of #25933 — this PR is the edit form half; the board half (drop gate on the Kanban columns) follows in a second PR from the same branch. Clause-②: yes Metadata-protocol: none ## What changes An object whose field is governed by an error-severity `state_machine` validation (the showcase task's `status`, for one) used to offer every option in the record edit form. The server refused the invalid move only after submit, with "Invalid task status transition." and no list of the allowed next states. The edit form now offers the persisted value plus the states the machine declares as its next moves, and leaves the rest out. - **`@object-ui/core` — new export `stateMachineNextValues(objectSchema, fieldName, currentValue)`** in `validation/validators/state-machine-next-values.ts`, published through the validators barrel. It was the grid's private helper (`plugin-grid/src/inline-edit-options.ts`); it now narrows exactly where the server's rule validator refuses: `severity` `error` (the spec default), `active` not `false`, `events` covering `update` (the default covers it). An undeclared from-state stays unconstrained, and several applicable rules on one field intersect. It reads the declared transition map for choice controls; it does not import, mirror or wire the deprecated object-level rule engine (`validation-engine-stays-unwired.test.ts` still green). - **`@object-ui/plugin-form` — the narrowing runs in `gateFormFields`**, the one field-gate step every form layout draws its resolved fields through. It takes an optional `previousValues` (the persisted record) and narrows a single-value select on both carriers: the entry's own `options` (the form renderer's built-in select) and `field.options` (the registered select widget). All six layouts pass their persisted record: the simple form, `ModalForm`, `DrawerForm`, `TabbedForm`, `SplitForm` and `WizardForm`. The from-state is the stored row, never the live value. A create form is unconstrained, and the current value always stays offered. - **`@object-ui/plugin-grid`** re-exports the helper from core, so `ObjectGrid.tsx` is untouched. ### Visible change in the grid's inline editor (intended) The grid used to narrow on ANY `state_machine` rule for the field. It now narrows only on a rule the server enforces on update. A `severity: 'warning'` / `'info'` machine (for example the showcase `project_health_progression`), an `active: false` rule, or an `events: ['insert']` rule no longer narrows the inline editor: the server never refuses those moves, so every option is offered again. ### Why `gateFormFields` and not `ObjectForm.tsx` alone The console's record edit dialog is `ModalForm`, rendered directly by `app-shell` `AppContent.tsx` (`formType: 'modal'`, `mode: 'edit'`), not `ObjectForm`'s default arm; `ObjectForm` also routes `drawer` / `tabbed` / `split` / `wizard` to their own files. Narrowing only the default arm would have left the reported dialog unchanged. The seat approved the wider file surface (Claim amendment on the card). ## Tests Run at head `80690c5b` from `ui/` under the verify lock: - `packages/core/src/validation/__tests__/state-machine-next-values.test.ts` (new, 12): default severity narrows; warning / info / inactive / insert-only do not; undeclared and empty from-states are unconstrained; several rules intersect; published from the package root. - `packages/core/src/validation/__tests__/validation-engine-stays-unwired.test.ts` — still green. - `packages/plugin-grid/src/inline-edit-options.test.ts` — existing pins green, plus the warning-machine case. - `packages/plugin-form/src/stateMachineOptions.test.ts` (new, 11): both carriers narrowed, create unconstrained, unloaded record unconstrained, warning keeps every option, string options, multi-value untouched, and through `gateFormFields`. - `packages/plugin-form/src/__tests__/stateMachineSelect-25933.test.tsx` (new, 5, rendered): the console edit dialog on a `backlog` task offers `backlog`, `todo`; an `in_review` task offers `in_progress`, `in_review`, `done`; the simple edit form on a `done` task offers `in_progress`, `done`; CONTROLS: a create dialog and a warning-severity machine offer all five. Result: the five files above, 41 + 5 tests, all pass. The full `@object-ui/core` and `@object-ui/plugin-form` suites (`npx vitest run --maxWorkers=2 packages/core/ packages/plugin-form/`, 413 files, 6484 passed, 28 skipped) ran at `8e28b0ff`; their one red, `column-identity.ratchet.test.ts` reading `f?.name || f.multiple || f.field?.multiple` as a column-identity dual read, was fixed by splitting the guard and re-run green with the four pinned files. `pnpm --filter @object-ui/core --filter @object-ui/plugin-form --filter @object-ui/plugin-grid run type-check`: exit 0. The `@object-ui/plugin-grid` suite was narrowed to `inline-edit-options.test.ts` (its only change is the re-export); the full grid suite is left to CI `Test Core`. **Ablation** (fix committed first; `scripts/ablation-replace.mjs`, mutation proven on disk, restored to the HEAD blob): handing `narrowStateMachineOptions` `previousValues: undefined` inside `gateFormFields` turned the 4 narrowing pins red (the `gateFormFields` unit pin and the three rendered narrowing pins) while the 12 others, controls included, stayed green. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 46 commands at `573a23e7`; 45 exited 0 there. `node scripts/check-sdui-manifest.mjs` was red there because the record predated the last source commit; after the re-record it exits 0 at the final head `80690c5b`, whose only further change is `scripts/sdui-manifest.record.json`. `--ran` reconciliation: 46 derived, 46 run, 0 NOT-MEASURED (a derived zero: every row carries its exit code). Repo-wide scans (`pnpm lint`, the full `check:*` farm) are CI's. ## Acceptance notes - The wizard narrows from `persistedRecord`, the record as first read; it does not advance after a mid-flow save. The wizard only writes at its final commit, so this matches its own `previousValues`. - Only top-level `state_machine` rules narrow. A `state_machine` nested inside a `conditional` rule's `then` / `otherwise` is not read; that leaves the control permissive, never stricter than the server. - The refusal message after submit is unchanged; it still does not name the allowed next states. With the select narrowed the user should no longer reach it from the form. Session: `session_015Kzn5VKHAGKe251waHoUqj` (card thread, `domain:ui#2`) --- _Generated by [Claude Code](https://claude.ai/code/session_015Kzn5VKHAGKe251waHoUqj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffecf03 commit 4f48d2b

19 files changed

Lines changed: 705 additions & 56 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-form': patch
4+
'@object-ui/plugin-grid': patch
5+
---
6+
7+
feat(ui): an edit form's `state_machine` select offers only the moves the server accepts
8+
9+
Clause-②: yes
10+
Metadata-protocol: none
11+
12+
A record whose `status` is governed by an error-severity `state_machine` validation used to offer every option in the edit form, and the server refused the invalid ones only after submit, without naming the allowed next states. The form now offers the persisted value plus the states the machine declares as its next moves.
13+
14+
- **`@object-ui/core`:** new export `stateMachineNextValues(objectSchema, fieldName, currentValue)`. It returns the set of values a field may move to from its persisted value, or `null` for "don't constrain". It narrows only where the server refuses: rules with `severity: 'error'` (the default), `active` not `false`, and `events` covering `update` (the default covers it). A from-state the transition map does not declare stays unconstrained, and several applicable rules on one field intersect. It is a read of the declared transition map for choice controls, not a client-side rule engine.
15+
- **`@object-ui/plugin-form`:** `gateFormFields` takes an optional `previousValues` (the persisted record) and narrows each single-value select governed by such a machine, on both the entry's own `options` and its `field.options`. Every layout passes its persisted record: the simple, modal, drawer, tabbed, split and wizard forms. The from-state is the stored row, never the live form value. A create form is unconstrained, and the current value always stays offered. Options outside the reachable set are left out rather than drawn disabled.
16+
- **`@object-ui/plugin-grid`:** `stateMachineNextValues` is now re-exported from `@object-ui/core`. The grid's inline editor therefore stops narrowing on a `warning` / `info` machine, an inactive one, or one whose `events` leave out `update`. The server never refuses those moves, so the editor now offers every option for them.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/core","sources":110,"sha256":"c4ca58ee32a488b2139abc179f34d5606ba0d89b86fccf8bd5c4d7b69d07f654","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/core","sources":111,"sha256":"a5c4bc9e88dbbe531e2d19963f3f5e4367e23b2adabbb838eb05770ef086d16c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-form","sources":40,"sha256":"8a4fd7ee6ef925de6f8c9f49873e2c69fa7bf223a6f14ac87ebcc7e3866ac227","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-form","sources":41,"sha256":"ce9bbd902f3df3f7ac87d6102fcb3489440234ec1741aff5c5518bb94e648687","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"c7afb4b073f949588abff953f2f9c14c6c9275b5892d98a6da6c695e4f415136","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"871e72570111ff4df50cd03ad7e7f98045364b7d8c7b11ceead655308a9f263a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
9+
import { describe, it, expect } from 'vitest';
10+
import { stateMachineNextValues } from '../validators/state-machine-next-values';
11+
import { stateMachineNextValues as fromPackageRoot } from '../../index';
12+
13+
// The showcase task's machine (examples/app-showcase task.object.ts).
14+
const transitions = {
15+
backlog: ['todo'],
16+
todo: ['in_progress', 'backlog'],
17+
in_progress: ['in_review', 'todo'],
18+
in_review: ['done', 'in_progress'],
19+
done: ['in_progress'],
20+
};
21+
22+
const withRule = (rule: Record<string, unknown>) => ({
23+
validations: [{ type: 'state_machine', field: 'status', transitions, ...rule }],
24+
});
25+
26+
const sorted = (s: Set<string> | null) => (s ? [...s].sort() : s);
27+
28+
describe('stateMachineNextValues', () => {
29+
it('is published from the package root', () => {
30+
expect(fromPackageRoot).toBe(stateMachineNextValues);
31+
});
32+
33+
it('offers the persisted value plus its declared next states', () => {
34+
expect(sorted(stateMachineNextValues(withRule({}), 'status', 'backlog'))).toEqual([
35+
'backlog',
36+
'todo',
37+
]);
38+
});
39+
40+
it('narrows on an explicit error-severity, active, update-event rule', () => {
41+
const rule = withRule({ severity: 'error', active: true, events: ['update'] });
42+
expect(sorted(stateMachineNextValues(rule, 'status', 'in_review'))).toEqual([
43+
'done',
44+
'in_progress',
45+
'in_review',
46+
]);
47+
});
48+
49+
it('does not narrow on a warning or info machine — the server never blocks it', () => {
50+
expect(stateMachineNextValues(withRule({ severity: 'warning' }), 'status', 'backlog')).toBeNull();
51+
expect(stateMachineNextValues(withRule({ severity: 'info' }), 'status', 'backlog')).toBeNull();
52+
});
53+
54+
it('does not narrow on an inactive rule', () => {
55+
expect(stateMachineNextValues(withRule({ active: false }), 'status', 'backlog')).toBeNull();
56+
});
57+
58+
it('does not narrow on a rule whose events leave out update', () => {
59+
expect(stateMachineNextValues(withRule({ events: ['insert'] }), 'status', 'backlog')).toBeNull();
60+
});
61+
62+
it('reads an empty events list as the spec default, which covers update', () => {
63+
expect(sorted(stateMachineNextValues(withRule({ events: [] }), 'status', 'done'))).toEqual([
64+
'done',
65+
'in_progress',
66+
]);
67+
});
68+
69+
it('leaves an undeclared from-state unconstrained', () => {
70+
expect(stateMachineNextValues(withRule({}), 'status', 'archived')).toBeNull();
71+
});
72+
73+
it('leaves an empty persisted value unconstrained', () => {
74+
expect(stateMachineNextValues(withRule({}), 'status', null)).toBeNull();
75+
expect(stateMachineNextValues(withRule({}), 'status', undefined)).toBeNull();
76+
});
77+
78+
it('intersects several blocking rules on the same field', () => {
79+
const schema = {
80+
validations: [
81+
{ type: 'state_machine', field: 'status', transitions },
82+
{ type: 'state_machine', field: 'status', transitions: { in_review: ['done'] } },
83+
],
84+
};
85+
expect(sorted(stateMachineNextValues(schema, 'status', 'in_review'))).toEqual([
86+
'done',
87+
'in_review',
88+
]);
89+
});
90+
91+
it('ignores a warning rule beside a blocking one', () => {
92+
const schema = {
93+
validations: [
94+
{ type: 'state_machine', field: 'status', transitions },
95+
{
96+
type: 'state_machine',
97+
field: 'status',
98+
severity: 'warning',
99+
transitions: { in_review: [] },
100+
},
101+
],
102+
};
103+
expect(sorted(stateMachineNextValues(schema, 'status', 'in_review'))).toEqual([
104+
'done',
105+
'in_progress',
106+
'in_review',
107+
]);
108+
});
109+
110+
it('ignores rules on other fields and rules of other types', () => {
111+
const schema = {
112+
validations: [
113+
{ type: 'state_machine', field: 'phase', transitions },
114+
{ type: 'script', field: 'status', condition: 'true' },
115+
],
116+
};
117+
expect(stateMachineNextValues(schema, 'status', 'backlog')).toBeNull();
118+
});
119+
});

‎ui/packages/core/src/validation/validators/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,8 @@ export {
2929
type ObjectValidationResult,
3030
type ValidationExpressionEvaluator,
3131
} from './object-validation-engine.js';
32+
33+
// Not part of the deprecated engine: a presentation read of a declared
34+
// `state_machine` transition map, so a choice control offers only the moves
35+
// the server would accept (#25933). It imports nothing from the engine.
36+
export { stateMachineNextValues } from './state-machine-next-values.js';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
9+
/**
10+
* @object-ui/core - State-machine next values
11+
*
12+
* Which values a `state_machine`-governed field may move to from its persisted
13+
* value, read from the object's served `validations`. A choice control (the
14+
* grid's inline editor, the record form's select, the board's columns) offers
15+
* only these, so it does not stage a move the server is bound to refuse.
16+
*
17+
* This is a PRESENTATION read of the declared transition map, not a client-side
18+
* enforcement path: it never judges a write, and the server's rule validator
19+
* stays the one authority that refuses an invalid transition. The deprecated
20+
* object-level rule engine next door is a different thing and stays unwired
21+
* (#3110); this module neither imports nor mirrors it.
22+
*
23+
* It narrows exactly where the server would refuse, and nowhere else:
24+
*
25+
* - only `severity: 'error'` rules (the spec default) — a `warning` / `info`
26+
* machine never blocks the write, so every option stays offered;
27+
* - only rules that are `active` (default `true`) and whose `events` cover
28+
* `update` (default `['insert', 'update']`) — a move is an update;
29+
* - a from-state the transition map does not declare is unconstrained, as
30+
* the server is lenient there;
31+
* - several applicable rules on one field all have to pass, so their allowed
32+
* sets intersect.
33+
*
34+
* `active`, `events` and `severity` arrive ABSENT from `/meta` when the author
35+
* left them at the spec default, so an absent key reads as the default, never
36+
* as JavaScript falsiness.
37+
*
38+
* @module state-machine-next-values
39+
* @packageDocumentation
40+
*/
41+
42+
/** The spec's `events` default for a validation rule. */
43+
const DEFAULT_EVENTS: readonly string[] = ['insert', 'update'];
44+
45+
/** A `state_machine` rule that refuses an invalid move on update. */
46+
function isBlockingUpdateMachine(
47+
rule: unknown,
48+
fieldName: string,
49+
): rule is { transitions: Record<string, unknown> } {
50+
if (!rule || typeof rule !== 'object') return false;
51+
const r = rule as {
52+
type?: unknown;
53+
field?: unknown;
54+
transitions?: unknown;
55+
active?: unknown;
56+
events?: unknown;
57+
severity?: unknown;
58+
};
59+
if (r.type !== 'state_machine' || r.field !== fieldName) return false;
60+
if (!r.transitions || typeof r.transitions !== 'object') return false;
61+
if (r.active === false) return false;
62+
if ((r.severity ?? 'error') !== 'error') return false;
63+
const events = Array.isArray(r.events) && r.events.length > 0 ? r.events : DEFAULT_EVENTS;
64+
return events.includes('update');
65+
}
66+
67+
/**
68+
* For a field governed by a blocking `state_machine` validation, the set of
69+
* values reachable from `currentValue` — the current value itself plus its
70+
* declared allowed transitions.
71+
*
72+
* `currentValue` is the PERSISTED value (the row as the server last stored
73+
* it), because that is the from-state the server judges a move against — never
74+
* a value the user has staged but not saved.
75+
*
76+
* Returns `null` (= "don't constrain the options") when:
77+
* - the object has no applicable `state_machine` validation for the field
78+
* (none at all, or only warning / inactive / insert-only ones), or
79+
* - the current value is empty, or no applicable rule declares it as a
80+
* from-state.
81+
*/
82+
export function stateMachineNextValues(
83+
objectSchema: unknown,
84+
fieldName: string,
85+
currentValue: unknown,
86+
): Set<string> | null {
87+
const rules = (objectSchema as { validations?: unknown } | null | undefined)?.validations;
88+
if (!Array.isArray(rules)) return null;
89+
if (currentValue === undefined || currentValue === null) return null;
90+
const from = String(currentValue);
91+
92+
const perRule: Set<string>[] = [];
93+
for (const rule of rules) {
94+
if (!isBlockingUpdateMachine(rule, fieldName)) continue;
95+
const allowed = rule.transitions[from];
96+
// An undeclared from-state is not policed by this rule.
97+
if (!Array.isArray(allowed)) continue;
98+
perRule.push(new Set<string>([from, ...allowed.map((s) => String(s))]));
99+
}
100+
if (perRule.length === 0) return null;
101+
return perRule.reduce((acc, next) => new Set([...acc].filter((v) => next.has(v))));
102+
}

‎ui/packages/plugin-form/src/DrawerForm.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
247247
const uploadGate = useUploadGate();
248248
const previewMode = usePreviewMode();
249249
const [objectSchema, setObjectSchema] = useState<any>(null);
250+
const [formData, setFormData] = useState<Record<string, any>>({});
250251
/**
251252
* The ONE field-gate step every layout draws through (`gateFormFields`,
252253
* objectui#10612): FLS drops non-readable fields and locks non-editable ones,
@@ -262,11 +263,12 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
262263
objectName: schema.objectName,
263264
mode: schema.mode,
264265
objectSchema,
266+
// The persisted record a `state_machine` select narrows by (#25933).
267+
previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined,
265268
}) as FormField[],
266-
[perms, schema.objectName, schema.mode, objectSchema],
269+
[perms, schema.objectName, schema.mode, schema.recordId, objectSchema, formData],
267270
);
268271
const [formFields, setFormFields] = useState<FormField[]>([]);
269-
const [formData, setFormData] = useState<Record<string, any>>({});
270272
const [loading, setLoading] = useState(true);
271273
// objectui#10682 — the load error, kept per read (the object schema and the
272274
// record), each written only by the current run of its read and cleared when

‎ui/packages/plugin-form/src/ModalForm.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
323323
const previewMode = usePreviewMode();
324324
const perms = usePermissions();
325325
const [objectSchema, setObjectSchema] = useState<any>(null);
326+
const [formData, setFormData] = useState<Record<string, any>>({});
326327
// The ONE field-gate step every layout draws through (`gateFormFields`,
327328
// objectui#10612): FLS drops non-readable fields and disables non-editable
328329
// ones (`80c54122e`; fail-open when no PermissionProvider is mounted), and
@@ -336,11 +337,12 @@ export const ModalForm: React.FC<ModalFormProps> = ({
336337
objectName: schema.objectName,
337338
mode: schema.mode,
338339
objectSchema,
340+
// The persisted record a `state_machine` select narrows by (#25933).
341+
previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined,
339342
}) as FormField[],
340-
[perms, schema.objectName, schema.mode, objectSchema],
343+
[perms, schema.objectName, schema.mode, schema.recordId, objectSchema, formData],
341344
);
342345
const [formFields, setFormFields] = useState<FormField[]>([]);
343-
const [formData, setFormData] = useState<Record<string, any>>({});
344346
const [loading, setLoading] = useState(true);
345347
// objectui#10682 — the load error, kept per read (the object schema and the
346348
// record), each written only by the current run of its read and cleared when

‎ui/packages/plugin-form/src/ObjectForm.tsx‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,16 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
670670
// `t`, is what its dependency list carries. It changes when the language, or
671671
// that language's late-arriving catalogue, does.
672672
const maxFileSizeSentence = t('form.maxFileSize', { size: '' });
673+
const [initialData, setInitialData] = useState<any>(null);
674+
// The persisted record, for `previous`-scoped field rules and the read-only
675+
// submit strip (objectui#3484). Edit mode only, and WITHOUT the schema
676+
// defaults folded in below: `previous` must be the row as the server last
677+
// stored it, not "the row plus what a blank column would default to". It is
678+
// also the from-state a `state_machine` select narrows by (#25933).
679+
const previousValues =
680+
schema.mode === 'edit' && schema.recordId && initialData && typeof initialData === 'object'
681+
? (initialData as Record<string, any>)
682+
: undefined;
673683
// The ONE field-gate step every layout draws through (objectui#10612):
674684
// field-level security plus the ADR-0092 D4 managed-object lock, which this
675685
// arm's field generator used to stamp on its own — see `gateFormFields`.
@@ -681,8 +691,9 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
681691
mode: schema.mode,
682692
objectSchema,
683693
deniedDescription,
694+
previousValues,
684695
}) as FormField[],
685-
[perms, schema.objectName, schema.mode, objectSchema, deniedDescription],
696+
[perms, schema.objectName, schema.mode, objectSchema, deniedDescription, previousValues],
686697
);
687698
// objectui#11000 — why `gateFields` locked every field, when the lock is the
688699
// form-wide one: the affordance for the mode is closed. Rendered above the
@@ -701,7 +712,6 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
701712
/>
702713
);
703714
const [formFields, setFormFields] = useState<FormField[]>([]);
704-
const [initialData, setInitialData] = useState<any>(null);
705715
const [loading, setLoading] = useState(true);
706716
// objectui#10682 — the load error, kept per read (the object schema and the
707717
// record), each written only by the current run of its read and cleared when
@@ -1524,15 +1534,6 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
15241534
}, [schema]);
15251535

15261536

1527-
// The persisted record, for `previous`-scoped field rules and the read-only
1528-
// submit strip (objectui#3484). Edit mode only, and WITHOUT the schema
1529-
// defaults folded in above: `previous` must be the row as the server last
1530-
// stored it, not "the row plus what a blank column would default to".
1531-
const previousValues =
1532-
schema.mode === 'edit' && schema.recordId && initialData && typeof initialData === 'object'
1533-
? (initialData as Record<string, any>)
1534-
: undefined;
1535-
15361537
// Auto-layout parity with the flat path (which runs these inside
15371538
// applyAutoLayout): drop platform-managed system fields — and, in create
15381539
// mode, server-computed fields — BEFORE deriving field-group sections, so a

0 commit comments

Comments
 (0)