diff --git a/.changeset/25933-state-machine-form-select.md b/.changeset/25933-state-machine-form-select.md new file mode 100644 index 00000000000..3fd1f7b5c36 --- /dev/null +++ b/.changeset/25933-state-machine-form-select.md @@ -0,0 +1,16 @@ +--- +'@object-ui/core': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-grid': patch +--- + +feat(ui): an edit form's `state_machine` select offers only the moves the server accepts + +Clause-②: yes +Metadata-protocol: none + +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. + +- **`@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. +- **`@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. +- **`@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. diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 43d8e41c0cd..18bbd8105a4 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":110,"sha256":"c4ca58ee32a488b2139abc179f34d5606ba0d89b86fccf8bd5c4d7b69d07f654","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":111,"sha256":"a5c4bc9e88dbbe531e2d19963f3f5e4367e23b2adabbb838eb05770ef086d16c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/plugin-form.json b/scripts/sdui-manifest.sources/plugin-form.json index e9a509fcc36..f6970ea07e2 100644 --- a/scripts/sdui-manifest.sources/plugin-form.json +++ b/scripts/sdui-manifest.sources/plugin-form.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-form","sources":40,"sha256":"8a4fd7ee6ef925de6f8c9f49873e2c69fa7bf223a6f14ac87ebcc7e3866ac227","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-form","sources":41,"sha256":"ce9bbd902f3df3f7ac87d6102fcb3489440234ec1741aff5c5518bb94e648687","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index 2e9f3579ad7..ca52aaa8efa 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":38,"sha256":"c7afb4b073f949588abff953f2f9c14c6c9275b5892d98a6da6c695e4f415136","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":38,"sha256":"871e72570111ff4df50cd03ad7e7f98045364b7d8c7b11ceead655308a9f263a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/packages/core/src/validation/__tests__/state-machine-next-values.test.ts b/ui/packages/core/src/validation/__tests__/state-machine-next-values.test.ts new file mode 100644 index 00000000000..6a49df52676 --- /dev/null +++ b/ui/packages/core/src/validation/__tests__/state-machine-next-values.test.ts @@ -0,0 +1,119 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { stateMachineNextValues } from '../validators/state-machine-next-values'; +import { stateMachineNextValues as fromPackageRoot } from '../../index'; + +// The showcase task's machine (examples/app-showcase task.object.ts). +const transitions = { + backlog: ['todo'], + todo: ['in_progress', 'backlog'], + in_progress: ['in_review', 'todo'], + in_review: ['done', 'in_progress'], + done: ['in_progress'], +}; + +const withRule = (rule: Record) => ({ + validations: [{ type: 'state_machine', field: 'status', transitions, ...rule }], +}); + +const sorted = (s: Set | null) => (s ? [...s].sort() : s); + +describe('stateMachineNextValues', () => { + it('is published from the package root', () => { + expect(fromPackageRoot).toBe(stateMachineNextValues); + }); + + it('offers the persisted value plus its declared next states', () => { + expect(sorted(stateMachineNextValues(withRule({}), 'status', 'backlog'))).toEqual([ + 'backlog', + 'todo', + ]); + }); + + it('narrows on an explicit error-severity, active, update-event rule', () => { + const rule = withRule({ severity: 'error', active: true, events: ['update'] }); + expect(sorted(stateMachineNextValues(rule, 'status', 'in_review'))).toEqual([ + 'done', + 'in_progress', + 'in_review', + ]); + }); + + it('does not narrow on a warning or info machine — the server never blocks it', () => { + expect(stateMachineNextValues(withRule({ severity: 'warning' }), 'status', 'backlog')).toBeNull(); + expect(stateMachineNextValues(withRule({ severity: 'info' }), 'status', 'backlog')).toBeNull(); + }); + + it('does not narrow on an inactive rule', () => { + expect(stateMachineNextValues(withRule({ active: false }), 'status', 'backlog')).toBeNull(); + }); + + it('does not narrow on a rule whose events leave out update', () => { + expect(stateMachineNextValues(withRule({ events: ['insert'] }), 'status', 'backlog')).toBeNull(); + }); + + it('reads an empty events list as the spec default, which covers update', () => { + expect(sorted(stateMachineNextValues(withRule({ events: [] }), 'status', 'done'))).toEqual([ + 'done', + 'in_progress', + ]); + }); + + it('leaves an undeclared from-state unconstrained', () => { + expect(stateMachineNextValues(withRule({}), 'status', 'archived')).toBeNull(); + }); + + it('leaves an empty persisted value unconstrained', () => { + expect(stateMachineNextValues(withRule({}), 'status', null)).toBeNull(); + expect(stateMachineNextValues(withRule({}), 'status', undefined)).toBeNull(); + }); + + it('intersects several blocking rules on the same field', () => { + const schema = { + validations: [ + { type: 'state_machine', field: 'status', transitions }, + { type: 'state_machine', field: 'status', transitions: { in_review: ['done'] } }, + ], + }; + expect(sorted(stateMachineNextValues(schema, 'status', 'in_review'))).toEqual([ + 'done', + 'in_review', + ]); + }); + + it('ignores a warning rule beside a blocking one', () => { + const schema = { + validations: [ + { type: 'state_machine', field: 'status', transitions }, + { + type: 'state_machine', + field: 'status', + severity: 'warning', + transitions: { in_review: [] }, + }, + ], + }; + expect(sorted(stateMachineNextValues(schema, 'status', 'in_review'))).toEqual([ + 'done', + 'in_progress', + 'in_review', + ]); + }); + + it('ignores rules on other fields and rules of other types', () => { + const schema = { + validations: [ + { type: 'state_machine', field: 'phase', transitions }, + { type: 'script', field: 'status', condition: 'true' }, + ], + }; + expect(stateMachineNextValues(schema, 'status', 'backlog')).toBeNull(); + }); +}); diff --git a/ui/packages/core/src/validation/validators/index.ts b/ui/packages/core/src/validation/validators/index.ts index c0d81a176ee..d4c7c5b00d4 100644 --- a/ui/packages/core/src/validation/validators/index.ts +++ b/ui/packages/core/src/validation/validators/index.ts @@ -29,3 +29,8 @@ export { type ObjectValidationResult, type ValidationExpressionEvaluator, } from './object-validation-engine.js'; + +// Not part of the deprecated engine: a presentation read of a declared +// `state_machine` transition map, so a choice control offers only the moves +// the server would accept (#25933). It imports nothing from the engine. +export { stateMachineNextValues } from './state-machine-next-values.js'; diff --git a/ui/packages/core/src/validation/validators/state-machine-next-values.ts b/ui/packages/core/src/validation/validators/state-machine-next-values.ts new file mode 100644 index 00000000000..6ef90a2efb3 --- /dev/null +++ b/ui/packages/core/src/validation/validators/state-machine-next-values.ts @@ -0,0 +1,102 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/core - State-machine next values + * + * Which values a `state_machine`-governed field may move to from its persisted + * value, read from the object's served `validations`. A choice control (the + * grid's inline editor, the record form's select, the board's columns) offers + * only these, so it does not stage a move the server is bound to refuse. + * + * This is a PRESENTATION read of the declared transition map, not a client-side + * enforcement path: it never judges a write, and the server's rule validator + * stays the one authority that refuses an invalid transition. The deprecated + * object-level rule engine next door is a different thing and stays unwired + * (#3110); this module neither imports nor mirrors it. + * + * It narrows exactly where the server would refuse, and nowhere else: + * + * - only `severity: 'error'` rules (the spec default) — a `warning` / `info` + * machine never blocks the write, so every option stays offered; + * - only rules that are `active` (default `true`) and whose `events` cover + * `update` (default `['insert', 'update']`) — a move is an update; + * - a from-state the transition map does not declare is unconstrained, as + * the server is lenient there; + * - several applicable rules on one field all have to pass, so their allowed + * sets intersect. + * + * `active`, `events` and `severity` arrive ABSENT from `/meta` when the author + * left them at the spec default, so an absent key reads as the default, never + * as JavaScript falsiness. + * + * @module state-machine-next-values + * @packageDocumentation + */ + +/** The spec's `events` default for a validation rule. */ +const DEFAULT_EVENTS: readonly string[] = ['insert', 'update']; + +/** A `state_machine` rule that refuses an invalid move on update. */ +function isBlockingUpdateMachine( + rule: unknown, + fieldName: string, +): rule is { transitions: Record } { + if (!rule || typeof rule !== 'object') return false; + const r = rule as { + type?: unknown; + field?: unknown; + transitions?: unknown; + active?: unknown; + events?: unknown; + severity?: unknown; + }; + if (r.type !== 'state_machine' || r.field !== fieldName) return false; + if (!r.transitions || typeof r.transitions !== 'object') return false; + if (r.active === false) return false; + if ((r.severity ?? 'error') !== 'error') return false; + const events = Array.isArray(r.events) && r.events.length > 0 ? r.events : DEFAULT_EVENTS; + return events.includes('update'); +} + +/** + * For a field governed by a blocking `state_machine` validation, the set of + * values reachable from `currentValue` — the current value itself plus its + * declared allowed transitions. + * + * `currentValue` is the PERSISTED value (the row as the server last stored + * it), because that is the from-state the server judges a move against — never + * a value the user has staged but not saved. + * + * Returns `null` (= "don't constrain the options") when: + * - the object has no applicable `state_machine` validation for the field + * (none at all, or only warning / inactive / insert-only ones), or + * - the current value is empty, or no applicable rule declares it as a + * from-state. + */ +export function stateMachineNextValues( + objectSchema: unknown, + fieldName: string, + currentValue: unknown, +): Set | null { + const rules = (objectSchema as { validations?: unknown } | null | undefined)?.validations; + if (!Array.isArray(rules)) return null; + if (currentValue === undefined || currentValue === null) return null; + const from = String(currentValue); + + const perRule: Set[] = []; + for (const rule of rules) { + if (!isBlockingUpdateMachine(rule, fieldName)) continue; + const allowed = rule.transitions[from]; + // An undeclared from-state is not policed by this rule. + if (!Array.isArray(allowed)) continue; + perRule.push(new Set([from, ...allowed.map((s) => String(s))])); + } + if (perRule.length === 0) return null; + return perRule.reduce((acc, next) => new Set([...acc].filter((v) => next.has(v)))); +} diff --git a/ui/packages/plugin-form/src/DrawerForm.tsx b/ui/packages/plugin-form/src/DrawerForm.tsx index 56fbb3c4543..4b8e3f6b5ce 100644 --- a/ui/packages/plugin-form/src/DrawerForm.tsx +++ b/ui/packages/plugin-form/src/DrawerForm.tsx @@ -247,6 +247,7 @@ export const DrawerForm: React.FC = ({ const uploadGate = useUploadGate(); const previewMode = usePreviewMode(); const [objectSchema, setObjectSchema] = useState(null); + const [formData, setFormData] = useState>({}); /** * The ONE field-gate step every layout draws through (`gateFormFields`, * objectui#10612): FLS drops non-readable fields and locks non-editable ones, @@ -262,11 +263,12 @@ export const DrawerForm: React.FC = ({ objectName: schema.objectName, mode: schema.mode, objectSchema, + // The persisted record a `state_machine` select narrows by (#25933). + previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined, }) as FormField[], - [perms, schema.objectName, schema.mode, objectSchema], + [perms, schema.objectName, schema.mode, schema.recordId, objectSchema, formData], ); const [formFields, setFormFields] = useState([]); - const [formData, setFormData] = useState>({}); const [loading, setLoading] = useState(true); // objectui#10682 — the load error, kept per read (the object schema and the // record), each written only by the current run of its read and cleared when diff --git a/ui/packages/plugin-form/src/ModalForm.tsx b/ui/packages/plugin-form/src/ModalForm.tsx index ce00451ec85..7f87066f814 100644 --- a/ui/packages/plugin-form/src/ModalForm.tsx +++ b/ui/packages/plugin-form/src/ModalForm.tsx @@ -323,6 +323,7 @@ export const ModalForm: React.FC = ({ const previewMode = usePreviewMode(); const perms = usePermissions(); const [objectSchema, setObjectSchema] = useState(null); + const [formData, setFormData] = useState>({}); // The ONE field-gate step every layout draws through (`gateFormFields`, // objectui#10612): FLS drops non-readable fields and disables non-editable // ones (`80c54122e`; fail-open when no PermissionProvider is mounted), and @@ -336,11 +337,12 @@ export const ModalForm: React.FC = ({ objectName: schema.objectName, mode: schema.mode, objectSchema, + // The persisted record a `state_machine` select narrows by (#25933). + previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined, }) as FormField[], - [perms, schema.objectName, schema.mode, objectSchema], + [perms, schema.objectName, schema.mode, schema.recordId, objectSchema, formData], ); const [formFields, setFormFields] = useState([]); - const [formData, setFormData] = useState>({}); const [loading, setLoading] = useState(true); // objectui#10682 — the load error, kept per read (the object schema and the // record), each written only by the current run of its read and cleared when diff --git a/ui/packages/plugin-form/src/ObjectForm.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index c33580f1746..b41f9006841 100644 --- a/ui/packages/plugin-form/src/ObjectForm.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.tsx @@ -670,6 +670,16 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // `t`, is what its dependency list carries. It changes when the language, or // that language's late-arriving catalogue, does. const maxFileSizeSentence = t('form.maxFileSize', { size: '' }); + const [initialData, setInitialData] = useState(null); + // The persisted record, for `previous`-scoped field rules and the read-only + // submit strip (objectui#3484). Edit mode only, and WITHOUT the schema + // defaults folded in below: `previous` must be the row as the server last + // stored it, not "the row plus what a blank column would default to". It is + // also the from-state a `state_machine` select narrows by (#25933). + const previousValues = + schema.mode === 'edit' && schema.recordId && initialData && typeof initialData === 'object' + ? (initialData as Record) + : undefined; // The ONE field-gate step every layout draws through (objectui#10612): // field-level security plus the ADR-0092 D4 managed-object lock, which this // arm's field generator used to stamp on its own — see `gateFormFields`. @@ -681,8 +691,9 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource mode: schema.mode, objectSchema, deniedDescription, + previousValues, }) as FormField[], - [perms, schema.objectName, schema.mode, objectSchema, deniedDescription], + [perms, schema.objectName, schema.mode, objectSchema, deniedDescription, previousValues], ); // objectui#11000 — why `gateFields` locked every field, when the lock is the // form-wide one: the affordance for the mode is closed. Rendered above the @@ -701,7 +712,6 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource /> ); const [formFields, setFormFields] = useState([]); - const [initialData, setInitialData] = useState(null); const [loading, setLoading] = useState(true); // objectui#10682 — the load error, kept per read (the object schema and the // 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 }, [schema]); - // The persisted record, for `previous`-scoped field rules and the read-only - // submit strip (objectui#3484). Edit mode only, and WITHOUT the schema - // defaults folded in above: `previous` must be the row as the server last - // stored it, not "the row plus what a blank column would default to". - const previousValues = - schema.mode === 'edit' && schema.recordId && initialData && typeof initialData === 'object' - ? (initialData as Record) - : undefined; - // Auto-layout parity with the flat path (which runs these inside // applyAutoLayout): drop platform-managed system fields — and, in create // mode, server-computed fields — BEFORE deriving field-group sections, so a diff --git a/ui/packages/plugin-form/src/SplitForm.tsx b/ui/packages/plugin-form/src/SplitForm.tsx index e0568fd8890..2154cf5f163 100644 --- a/ui/packages/plugin-form/src/SplitForm.tsx +++ b/ui/packages/plugin-form/src/SplitForm.tsx @@ -350,9 +350,11 @@ export const SplitForm: React.FC = ({ objectName: schema.objectName, mode: schema.mode, objectSchema, + // The persisted record a `state_machine` select narrows by (#25933). + previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined, }) ?? fields) as FormField[]; }, - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel, perms], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel, perms, formData], ); // Handle form submission diff --git a/ui/packages/plugin-form/src/TabbedForm.tsx b/ui/packages/plugin-form/src/TabbedForm.tsx index 714f7a8009d..9813651b0fc 100644 --- a/ui/packages/plugin-form/src/TabbedForm.tsx +++ b/ui/packages/plugin-form/src/TabbedForm.tsx @@ -448,9 +448,11 @@ export const TabbedForm: React.FC = ({ objectName: schema.objectName, mode: schema.mode, objectSchema, + // The persisted record a `state_machine` select narrows by (#25933). + previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined, }) ?? fields) as FormField[]; }, - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel, perms], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel, perms, formData], ); // Handle form submission diff --git a/ui/packages/plugin-form/src/WizardForm.tsx b/ui/packages/plugin-form/src/WizardForm.tsx index b95293f29ae..4d685ce93f2 100644 --- a/ui/packages/plugin-form/src/WizardForm.tsx +++ b/ui/packages/plugin-form/src/WizardForm.tsx @@ -724,9 +724,11 @@ export const WizardForm: React.FC = ({ objectName: schema.objectName, mode: schema.mode, objectSchema, + // The persisted record a `state_machine` select narrows by (#25933). + previousValues: schema.mode === 'edit' ? persistedRecord : undefined, }) ?? fields) as FormField[]; }, - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel, perms], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel, perms, persistedRecord], ); // objectui#11000 — the affordance the lock above found CLOSED for this diff --git a/ui/packages/plugin-form/src/__tests__/stateMachineSelect-25933.test.tsx b/ui/packages/plugin-form/src/__tests__/stateMachineSelect-25933.test.tsx new file mode 100644 index 00000000000..c690cf9e8f4 --- /dev/null +++ b/ui/packages/plugin-form/src/__tests__/stateMachineSelect-25933.test.tsx @@ -0,0 +1,141 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * #25933 — the edit form's `status` select offers only the moves the object's + * error-severity `state_machine` allows from the stored value. + * + * Measured on the showcase task before the fix: a `backlog` task's edit dialog + * listed all five states, and picking `done` came back from the server as + * "Invalid task status transition." Pinned through the console's edit dialog + * (the global `ModalForm`) and the in-page simple form, and read off the + * hidden native `