Skip to content

Commit 5e1342a

Browse files
fix(plugin-form): a create form offers a state_machine select only its initialStates (#27969)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg8CD9CXukrjPYCaAJupMsVw)_ ## 维护者速读 - **改了什么**:新建表单里受 `state_machine` 管辖的下拉字段,现在只列出该状态机声明的 `initialStates`(如 showcase 项目只给「Planned」),与编辑表单已有的「只给可达状态」走同一道字段门。 - **为什么改**:服务端新建时只接受 `initialStates`,表单却列出全部选项,用户选别的值点保存才收到 `invalid_initial_state` 拒绝;按分诊「先改平台」裁定,在表单层修好,所有应用的新建表单一起受益。 - **风险与回滚**:只收窄选项列表、不改写任何值;没有声明 `initialStates` 的状态机、warning 级或仅 update 的规则保持全部选项;回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-form/src/stateMachineOptions.ts`(create 分支与「打开值保留」规则)、`ui/packages/core/src/validation/validators/state-machine-next-values.ts`(新 helper)、`ui/packages/plugin-form/src/ObjectForm.tsx`(gateFields 下移以拿到打开值)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-form/src/__tests__/stateMachineCreateSelect-27763.test.tsx`,预期 6 条全绿(主从表单、平铺表单、新建对话框都只给 `planned`)。 - **需要你做的**:无,席位落地。 Fixes #27763 Clause-②: no Metadata-protocol: none ## What changes - `@object-ui/core` gains `stateMachineInitialValues(objectSchema, fieldName)` beside `stateMachineNextValues`: the values a new record may be created in, read from the same served `validations` with the same rule reading (blocking `severity: 'error'`, `active`, `events` covering the write, absent keys read as the spec default). It returns `null` when no applicable rule declares `initialStates`, which is exactly where the server's insert check is a no-op. The two helpers now share one rule predicate, so there is still one reading of a machine. - `narrowStateMachineOptions` (the pass inside `gateFormFields`, which every form layout draws through) gains a create arm: on `mode: 'create'` a select governed by such a machine keeps only its `initialStates`. The edit arm is unchanged. - The value a create form OPENS with (`openingValues`: the seeded `default: true` option or the caller's pre-fill) stays offered even when it lies outside `initialStates`, so the form never silently drops a value it was handed; the server's refusal stays the one that names it. Every layout passes the values it opens with: `ObjectForm` (its gate step moves below `finalDefaultValues` for that), `ModalForm`, `DrawerForm`, `TabbedForm`, `WizardForm`. - The master-detail form needs no path of its own: its header is an `ObjectForm`, so it draws through the same gate (measured: the master-detail test below goes red under the ablation with the others). - When no field narrows, the pass returns the very array it was handed, as before. - The behaviour is written down in `ui/packages/plugin-form/README.md` and `content/docs/frontend/plugins/plugin-form.mdx` (neither described the edit-side narrowing before). ## Why `showcase_project`'s `project_status_flow` declares `initialStates: ['planned']`, and the server refuses any other initial status with `invalid_initial_state`. The "New Project + Tasks" create form offered all five statuses, so four of them were dead ends. The triage ruled platform first: the form stops offering what the server refuses, for every app, and the showcase page stays as it is as the proof. `examples/app-showcase/**` is untouched. ## Tests - `ui/packages/core/src/validation/__tests__/state-machine-next-values.test.ts`: `stateMachineInitialValues` (package-root export, declared states, absent `events`, no or empty `initialStates`, warning / inactive / update-only rules, another field, intersection of two rules). - `ui/packages/plugin-form/src/stateMachineOptions.test.ts`: the create arm on both option carriers, the opening value kept, the persisted record ignored on create, the no-`initialStates` control, the edit control, a `view` form, and through `gateFormFields`. - `ui/packages/plugin-form/src/__tests__/stateMachineCreateSelect-27763.test.tsx` (DOM, read off the native select Radix renders): the showcase master-detail create form, the flat in-page create form and the console create dialog each offer only `planned`; a caller's pre-fill outside `initialStates` stays offered; CONTROL: no `initialStates` keeps every option; CONTROL: an edit form still narrows from the stored value. - `ui/e2e/live/master-detail.spec.ts`: one new case opens the status select on the live showcase page and asserts `planned` is offered and the four refused states are not. The existing cases are unchanged apart from one comment. <details><summary>Ablation (one-shot, on the committed tree)</summary> Via `scripts/ablation-replace.mjs` under the verify lock; `@object-ui/core` and the plugin-form sources resolve to `src/` through the ui vitest alias, so no dist rebuild sits in the path. 1. Create arm off (`if (mode === 'create') {` replaced so it never matches): 8 failed, 16 passed of the two files. Red: the 4 create-arm unit cases and the 4 DOM create cases (master-detail, flat, dialog, pre-fill). Green: both CONTROLs and every edit case. Restored: blob equals HEAD, `git diff HEAD` empty. 2. Opening value dropped (`return initial && opening !== null ? new Set([...initial, opening]) : initial;` replaced by `return initial;`): 2 failed, 22 passed. Red: exactly the two pre-fill cases. Restored the same way. </details> ## Acceptance notes - Seeded default: the showcase status field's `default: true` option is `planned`, which is inside `initialStates`, so the master-detail form opens on a legal value. A seeded default outside `initialStates` would stay offered (the opening-value rule) and the server would refuse it on save; no showcase object has one today. - The master-detail child rows (`showcase_task`) are not narrowed on create: `task_status_flow` is update-only and declares no `initialStates`, so the server does not police their insert either. - Create surfaces outside the form gate (a grid's inline new row, a board's quick-create) are not changed here; this PR covers the form layouts that draw through `gateFormFields`. Filed by the seat as #27976. - Mode: the create arm keys on `mode: 'create'`, the value every create form in the console and the showcase page declares. A form that declares no mode keeps every option, as before. - The live e2e case is measured by CI's `Live E2E UI` job, not locally. ## Verification Local readings. Tests and type check ran on `5c360f28`; the head `54cb3d7d` adds only the README and docs-page text on top of it. The gate union ran on `54cb3d7d`. - Targeted: the four state-machine test files, 48 passed (48). - Affected packages: `pnpm --dir ui exec vitest run packages/plugin-form/ packages/core/`, Test Files 432 passed (432), Tests 6533 passed, 28 skipped. - Type check: `pnpm --filter @object-ui/core --filter @object-ui/plugin-form run type-check`, both `Done`, VERDICT command-exit 0. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 66 families, among them `node scripts/check-sdui-manifest.mjs` and `pnpm --dir ui exec vitest run scripts/` at 169 files passed): every one exit 0. `--ran` reconciliation: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED". `check:skill-examples` first refused with exit 3 (its prerequisite, a built `@objectstack/client-react`, was missing; nothing measured), then ran exit 0 after that build. - `pnpm --dir ui run check:new-line-citations`: 0 new citations. - Every other family is CI's run on this PR. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbUGFznjxdG6YQZm1Z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c87beb commit 5e1342a

16 files changed

Lines changed: 530 additions & 65 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-form': patch
4+
---
5+
6+
fix(plugin-form): a create form offers a `state_machine`-governed select only the machine's `initialStates`, so no option on it ends in `invalid_initial_state`
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
An object's `state_machine` validation can declare `initialStates`, the values a new record may be created in; the server refuses an insert with any other value (`invalid_initial_state`). Create forms used to offer every option of that select anyway, so a user picking, say, `active` on the showcase "New Project + Tasks" form learned only from the refusal after submit that `planned` was the one value allowed.
12+
13+
Now every create form layout (the in-page form, the master-detail header, the drawer, the dialog, the tabbed and wizard forms) offers such a select only its `initialStates`, through the same field gate that already narrows an edit form to the moves from the stored value. Two rules hold:
14+
15+
- A machine that declares no `initialStates`, or that is `warning`/`info`, inactive or update-only, leaves the create form's options unconstrained, exactly where the server does not police the insert.
16+
- The value the form opens with (a seeded `default: true` option or the caller's pre-fill) stays offered even outside `initialStates`, so the form never silently drops what it was handed; the server's refusal still names it.
17+
18+
`@object-ui/core` gains `stateMachineInitialValues(objectSchema, fieldName)`, the insert-side sibling of `stateMachineNextValues`.

‎content/docs/frontend/plugins/plugin-form.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ renderer were removed (ADR-0050): use `tabbed`, or an
9797

9898
Which fields an edit save sends, and which fields and line-item actions a caller is offered, is the same in the SDK and the Console. It is documented under [What an edit save writes](/docs/ui/forms#what-an-edit-save-writes).
9999

100+
## What a state-machine select offers
101+
102+
A select governed by the object's blocking `state_machine` validation offers only the values the server accepts. On an edit form that is the stored value plus its declared next states; on a create form it is the machine's `initialStates`, plus the value the form opens with (a `default: true` option or a caller's pre-fill), which is never dropped. A machine that declares no `initialStates`, or a `warning`, inactive or update-only one, leaves a create form's options as they are. Every form layout, the master-detail header included, applies the same rule.
103+
100104
## Field Types
101105

102106
The plugin supports these field types:

‎ui/e2e/live/master-detail.spec.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ test.beforeEach(async ({ page }) => {
5959
await expect(page.getByRole('heading', { name: 'New Project + Tasks' })).toBeVisible();
6060
});
6161

62+
test('the status select offers only the state the server admits on create', async ({ page }) => {
63+
// `project_status_flow` declares `initialStates: ['planned']`; every other
64+
// option would be refused 400 `invalid_initial_state` (#27763).
65+
await page.getByTestId('select-trigger-status').first().click();
66+
await expect(page.getByTestId('select-option-planned').first()).toBeVisible();
67+
for (const refused of ['active', 'on_hold', 'completed', 'cancelled']) {
68+
await expect(page.getByTestId(`select-option-${refused}`)).toHaveCount(0);
69+
}
70+
});
71+
6272
test('Create stores the populated parent through one atomic batch', async ({ page, request }) => {
6373
const name = `E2E Project ${Date.now()}`;
6474
await page.locator('input[name="name"]').fill(name);
@@ -87,8 +97,8 @@ test('Create with a task line stores the child referencing the parent', async ({
8797
const name = `E2E MD ${Date.now()}`;
8898
await page.locator('input[name="name"]').fill(name);
8999
await fillLookup(page, 'account', 'North');
90-
// `planned` is the only status `project_status_flow` admits on create; any
91-
// other pick is refused 400 `invalid_initial_state`.
100+
// `planned` is the only status `project_status_flow` admits on create, and
101+
// the only one the select offers.
92102
await selectOption(page, 'status', 'planned');
93103
await expect(page.getByText('Northwind', { exact: false })).toBeVisible();
94104

‎ui/packages/core/src/validation/__tests__/state-machine-next-values.test.ts‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
*/
88

99
import { describe, it, expect } from 'vitest';
10-
import { stateMachineNextValues } from '../validators/state-machine-next-values';
11-
import { stateMachineNextValues as fromPackageRoot } from '../../index';
10+
import { stateMachineInitialValues, stateMachineNextValues } from '../validators/state-machine-next-values';
11+
import {
12+
stateMachineInitialValues as fromPackageRootInitial,
13+
stateMachineNextValues as fromPackageRoot,
14+
} from '../../index';
1215

1316
// The showcase task's machine (examples/app-showcase task.object.ts).
1417
const transitions = {
@@ -117,3 +120,57 @@ describe('stateMachineNextValues', () => {
117120
expect(stateMachineNextValues(schema, 'status', 'backlog')).toBeNull();
118121
});
119122
});
123+
124+
// The showcase project's machine (examples/app-showcase project.object.ts):
125+
// `initialStates` is the insert entry point (#27763).
126+
const withInitial = (rule: Record<string, unknown>) => ({
127+
validations: [
128+
{
129+
type: 'state_machine',
130+
field: 'status',
131+
events: ['insert', 'update'],
132+
initialStates: ['planned'],
133+
transitions: { planned: ['active', 'cancelled'] },
134+
...rule,
135+
},
136+
],
137+
});
138+
139+
describe('stateMachineInitialValues', () => {
140+
it('is published from the package root', () => {
141+
expect(fromPackageRootInitial).toBe(stateMachineInitialValues);
142+
});
143+
144+
it('offers the declared initialStates', () => {
145+
expect(sorted(stateMachineInitialValues(withInitial({}), 'status'))).toEqual(['planned']);
146+
});
147+
148+
it('reads absent events as the spec default, which covers insert', () => {
149+
expect(sorted(stateMachineInitialValues(withInitial({ events: undefined }), 'status'))).toEqual(['planned']);
150+
});
151+
152+
it('does not narrow when the rule declares no initialStates — the server does not police that insert', () => {
153+
expect(stateMachineInitialValues(withInitial({ initialStates: undefined }), 'status')).toBeNull();
154+
expect(stateMachineInitialValues(withInitial({ initialStates: [] }), 'status')).toBeNull();
155+
});
156+
157+
it('does not narrow on a warning, inactive or update-only machine', () => {
158+
expect(stateMachineInitialValues(withInitial({ severity: 'warning' }), 'status')).toBeNull();
159+
expect(stateMachineInitialValues(withInitial({ active: false }), 'status')).toBeNull();
160+
expect(stateMachineInitialValues(withInitial({ events: ['update'] }), 'status')).toBeNull();
161+
});
162+
163+
it('does not narrow another field', () => {
164+
expect(stateMachineInitialValues(withInitial({}), 'health')).toBeNull();
165+
});
166+
167+
it('intersects several blocking rules on the same field', () => {
168+
const schema = {
169+
validations: [
170+
{ type: 'state_machine', field: 'status', initialStates: ['planned', 'active'], transitions: {} },
171+
{ type: 'state_machine', field: 'status', initialStates: ['active', 'on_hold'], transitions: {} },
172+
],
173+
};
174+
expect(sorted(stateMachineInitialValues(schema, 'status'))).toEqual(['active']);
175+
});
176+
});

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
} from './object-validation-engine.js';
3232

3333
// 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';
34+
// `state_machine` transition map and entry point, so a choice control offers
35+
// only the moves (#25933) and initial states (#27763) the server would accept.
36+
// It imports nothing from the engine.
37+
export { stateMachineNextValues, stateMachineInitialValues } from './state-machine-next-values.js';

‎ui/packages/core/src/validation/validators/state-machine-next-values.ts‎

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* value, read from the object's served `validations`. A choice control (the
1414
* grid's inline editor, the record form's select, the board's columns) offers
1515
* only these, so it does not stage a move the server is bound to refuse.
16+
* `stateMachineInitialValues` is the insert half: the values a NEW record may
17+
* be created in (`initialStates`), which a create form offers (#27763).
1618
*
1719
* This is a PRESENTATION read of the declared transition map, not a client-side
1820
* enforcement path: it never judges a write, and the server's rule validator
@@ -25,9 +27,11 @@
2527
* - only `severity: 'error'` rules (the spec default) — a `warning` / `info`
2628
* machine never blocks the write, so every option stays offered;
2729
* - only rules that are `active` (default `true`) and whose `events` cover
28-
* `update` (default `['insert', 'update']`) — a move is an update;
30+
* the write (default `['insert', 'update']`) — a move is an update, a
31+
* create is an insert;
2932
* - a from-state the transition map does not declare is unconstrained, as
30-
* the server is lenient there;
33+
* the server is lenient there; so is an insert under a rule that declares
34+
* no `initialStates` (the server's legacy no-op);
3135
* - several applicable rules on one field all have to pass, so their allowed
3236
* sets intersect.
3337
*
@@ -42,26 +46,47 @@
4246
/** The spec's `events` default for a validation rule. */
4347
const DEFAULT_EVENTS: readonly string[] = ['insert', 'update'];
4448

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> } {
49+
/** A `state_machine` rule on `fieldName` that refuses an invalid `event` write. */
50+
function isBlockingMachine(rule: unknown, fieldName: string, event: 'insert' | 'update'): boolean {
5051
if (!rule || typeof rule !== 'object') return false;
5152
const r = rule as {
5253
type?: unknown;
5354
field?: unknown;
54-
transitions?: unknown;
5555
active?: unknown;
5656
events?: unknown;
5757
severity?: unknown;
5858
};
5959
if (r.type !== 'state_machine' || r.field !== fieldName) return false;
60-
if (!r.transitions || typeof r.transitions !== 'object') return false;
6160
if (r.active === false) return false;
6261
if ((r.severity ?? 'error') !== 'error') return false;
6362
const events = Array.isArray(r.events) && r.events.length > 0 ? r.events : DEFAULT_EVENTS;
64-
return events.includes('update');
63+
return events.includes(event);
64+
}
65+
66+
/** A `state_machine` rule that refuses an invalid move on update. */
67+
function isBlockingUpdateMachine(
68+
rule: unknown,
69+
fieldName: string,
70+
): rule is { transitions: Record<string, unknown> } {
71+
if (!isBlockingMachine(rule, fieldName, 'update')) return false;
72+
const transitions = (rule as { transitions?: unknown }).transitions;
73+
return !!transitions && typeof transitions === 'object';
74+
}
75+
76+
/** A `state_machine` rule that refuses a create outside its `initialStates`. */
77+
function isBlockingInsertMachine(
78+
rule: unknown,
79+
fieldName: string,
80+
): rule is { initialStates: unknown[] } {
81+
if (!isBlockingMachine(rule, fieldName, 'insert')) return false;
82+
const initial = (rule as { initialStates?: unknown }).initialStates;
83+
return Array.isArray(initial) && initial.length > 0;
84+
}
85+
86+
/** Several applicable rules on one field all have to pass. */
87+
function intersect(sets: Set<string>[]): Set<string> | null {
88+
if (sets.length === 0) return null;
89+
return sets.reduce((acc, next) => new Set([...acc].filter((v) => next.has(v))));
6590
}
6691

6792
/**
@@ -97,6 +122,30 @@ export function stateMachineNextValues(
97122
if (!Array.isArray(allowed)) continue;
98123
perRule.push(new Set<string>([from, ...allowed.map((s) => String(s))]));
99124
}
100-
if (perRule.length === 0) return null;
101-
return perRule.reduce((acc, next) => new Set([...acc].filter((v) => next.has(v))));
125+
return intersect(perRule);
126+
}
127+
128+
/**
129+
* For a field governed by a blocking `state_machine` validation that declares
130+
* `initialStates`, the set of values a NEW record may be created in — the
131+
* machine's entry point, which the server enforces on insert.
132+
*
133+
* Returns `null` (= "don't constrain the options") when the object has no
134+
* applicable rule for the field: none at all, only warning / inactive /
135+
* update-only ones, or ones that declare no `initialStates` (the server does
136+
* not police an insert under such a rule).
137+
*/
138+
export function stateMachineInitialValues(
139+
objectSchema: unknown,
140+
fieldName: string,
141+
): Set<string> | null {
142+
const rules = (objectSchema as { validations?: unknown } | null | undefined)?.validations;
143+
if (!Array.isArray(rules)) return null;
144+
145+
const perRule: Set<string>[] = [];
146+
for (const rule of rules) {
147+
if (!isBlockingInsertMachine(rule, fieldName)) continue;
148+
perRule.push(new Set<string>(rule.initialStates.map((s) => String(s))));
149+
}
150+
return intersect(perRule);
102151
}

‎ui/packages/plugin-form/README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,24 @@ evaluator that resolves it — `resolveFieldRuleState`, reading
434434
`isServerOwnedValue`. Display and validation share that single verdict, so a
435435
field can never lose its asterisk while still refusing the submit.
436436

437+
### What a state-machine select offers
438+
439+
A select governed by a blocking `state_machine` validation (`severity: 'error'`,
440+
active, its `events` covering the write) offers only the values the server
441+
accepts, through the one field gate every layout draws through
442+
(`gateFormFields`):
443+
444+
| Form | Offers |
445+
|---|---|
446+
| edit, record loaded | the stored value plus its declared next states (`stateMachineNextValues`) |
447+
| create, machine declares `initialStates` | those states (`stateMachineInitialValues`), plus the value the form opens with |
448+
| create, no `initialStates` | every option, as the server does not police that insert |
449+
450+
The value a create form opens with (a `default: true` option, a `defaultValue`,
451+
or the caller's `initialValues` / `initialData`) stays offered even outside
452+
`initialStates`, so the form never silently drops a value it was handed; the
453+
server's refusal names it on save.
454+
437455
### Resolving `sections[].group` outside this package
438456

439457
A form section declares its members exactly one way: it enumerates `fields`, or

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
284284
objectSchema,
285285
// The persisted record a `state_machine` select narrows by (#25933).
286286
previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined,
287+
// The values a create form opens with, which stay offered (#27763).
288+
openingValues: formData,
287289
}) as FormField[],
288290
[perms, schema.objectName, schema.mode, schema.recordId, objectSchema, formData],
289291
);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
374374
objectSchema,
375375
// The persisted record a `state_machine` select narrows by (#25933).
376376
previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined,
377+
// The values a create form opens with, which stay offered (#27763).
378+
openingValues: formData,
377379
}) as FormField[],
378380
[perms, schema.objectName, schema.mode, schema.recordId, objectSchema, formData],
379381
);

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

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -609,21 +609,6 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
609609
schema.mode === 'edit' && schema.recordId && initialData && typeof initialData === 'object'
610610
? (initialData as Record<string, any>)
611611
: undefined;
612-
// The ONE field-gate step every layout draws through (objectui#10612):
613-
// field-level security plus the ADR-0092 D4 managed-object lock, which this
614-
// arm's field generator used to stamp on its own — see `gateFormFields`.
615-
const gateFields = useCallback(
616-
(fields: FormField[]): FormField[] =>
617-
gateFormFields(fields, {
618-
perms,
619-
objectName: schema.objectName,
620-
mode: schema.mode,
621-
objectSchema,
622-
deniedDescription,
623-
previousValues,
624-
}) as FormField[],
625-
[perms, schema.objectName, schema.mode, objectSchema, deniedDescription, previousValues],
626-
);
627612
// objectui#11000 — why `gateFields` locked every field, when the lock is the
628613
// form-wide one: the affordance for the mode is closed. Rendered above the
629614
// fields by `ClosedAffordanceNotice`.
@@ -1195,6 +1180,24 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
11951180
// (objectui#12359), so both readers spell it through `formOpeningValues`.
11961181
const finalDefaultValues = formOpeningValues(schemaDefaults, initialData);
11971182

1183+
// The ONE field-gate step every layout draws through (objectui#10612):
1184+
// field-level security plus the ADR-0092 D4 managed-object lock, which this
1185+
// arm's field generator used to stamp on its own — see `gateFormFields`.
1186+
const gateFields = useCallback(
1187+
(fields: FormField[]): FormField[] =>
1188+
gateFormFields(fields, {
1189+
perms,
1190+
objectName: schema.objectName,
1191+
mode: schema.mode,
1192+
objectSchema,
1193+
deniedDescription,
1194+
previousValues,
1195+
// What the form opens with stays offered on a create (#27763).
1196+
openingValues: finalDefaultValues,
1197+
}) as FormField[],
1198+
[perms, schema.objectName, schema.mode, objectSchema, deniedDescription, previousValues, finalDefaultValues],
1199+
);
1200+
11981201
// Handle form submission
11991202
const handleSubmit = useCallback(async (formData: any, e?: any) => {
12001203
// An upload is still in flight (objectui#10166). A `file`/`image` value is

0 commit comments

Comments
 (0)