From e07456be6a61887f8d5fb5b494682e27cacb38ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:59:14 +0000 Subject: [PATCH 1/9] feat(ui): hoist stateMachineNextValues into @object-ui/core and narrow the edit form's state-machine select Claude-Session: https://claude.ai/code/session_015Kzn5VKHAGKe251waHoUqj Co-authored-by: Claude --- .../state-machine-next-values.test.ts | 119 +++++++++++++ .../core/src/validation/validators/index.ts | 5 + .../validators/state-machine-next-values.ts | 102 +++++++++++ ui/packages/plugin-form/src/DrawerForm.tsx | 6 +- ui/packages/plugin-form/src/ModalForm.tsx | 6 +- ui/packages/plugin-form/src/ObjectForm.tsx | 23 +-- ui/packages/plugin-form/src/SplitForm.tsx | 4 +- ui/packages/plugin-form/src/TabbedForm.tsx | 4 +- ui/packages/plugin-form/src/WizardForm.tsx | 4 +- ui/packages/plugin-form/src/fieldWriteGate.ts | 27 ++- .../src/stateMachineOptions.test.ts | 161 ++++++++++++++++++ .../plugin-form/src/stateMachineOptions.ts | 88 ++++++++++ .../src/inline-edit-options.test.ts | 10 ++ .../plugin-grid/src/inline-edit-options.ts | 37 +--- 14 files changed, 543 insertions(+), 53 deletions(-) create mode 100644 ui/packages/core/src/validation/__tests__/state-machine-next-values.test.ts create mode 100644 ui/packages/core/src/validation/validators/state-machine-next-values.ts create mode 100644 ui/packages/plugin-form/src/stateMachineOptions.test.ts create mode 100644 ui/packages/plugin-form/src/stateMachineOptions.ts 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..d40de263d8c --- /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); + + let reachable: Set | null = null; + 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; + const next = new Set([from, ...allowed.map((s) => String(s))]); + reachable = reachable === null ? next : new Set([...reachable].filter((v) => next.has(v))); + } + return reachable; +} 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/fieldWriteGate.ts b/ui/packages/plugin-form/src/fieldWriteGate.ts index 161fdfd94f5..c4c409e1627 100644 --- a/ui/packages/plugin-form/src/fieldWriteGate.ts +++ b/ui/packages/plugin-form/src/fieldWriteGate.ts @@ -81,6 +81,7 @@ import { resolveFieldAffordance, type SchemaLike, } from '@object-ui/core'; +import { narrowStateMachineOptions } from './stateMachineOptions'; /** * The permission surface this module consumes — structurally the subset of @@ -210,6 +211,14 @@ export interface GateFormFieldsOptions extends ApplyFieldPermissionsOptions { * has no metadata source, reads as the default bucket. */ objectSchema: SchemaLike | null | undefined; + /** + * The persisted record an edit form holds — the row as the server last + * stored it, the same value the arm hands its renderer as `previousValues`. + * With it, a select governed by a blocking `state_machine` offers only the + * moves the server accepts from that stored value (#25933). Absent while + * the record has not loaded, and always absent on a create form. + */ + previousValues?: Record | null; } /** @@ -298,6 +307,13 @@ export function closedFormAffordance({ * `disabled`, not `readOnly`: the default arm's lock always drew a * disabled input, and the submit button is left as it is. * + * Between the two, on an edit form handed its persisted record + * (`previousValues`), a select governed by a blocking `state_machine` keeps + * only the moves the server accepts from the stored value + * ({@link narrowStateMachineOptions}, #25933). It lives in this step for the + * same reason the lock does: every layout draws through here, so no arm can + * offer a move another arm withholds. + * * Before this step the lock was stamped by the default arm's field generator * alone, so the drawer, modal, tabbed, split and wizard layouts drew live * inputs on a managed object whose write the server refuses (objectui#10613, @@ -310,14 +326,19 @@ export function closedFormAffordance({ */ export function gateFormFields>( fields: T[] | undefined, - { objectSchema, ...options }: GateFormFieldsOptions, + { objectSchema, previousValues, ...options }: GateFormFieldsOptions, ): T[] | undefined { const permitted = applyFieldPermissions(fields, options); if (!Array.isArray(permitted)) return permitted; + const narrowed = narrowStateMachineOptions(permitted, { + objectSchema, + mode: options.mode, + previousValues, + }); if (!managedModeLocked(objectSchema, options.perms, options.objectName, options.mode)) { - return permitted; + return narrowed; } - return permitted.map((f) => (f?.name ? { ...f, disabled: true } : f)); + return narrowed.map((f) => (f?.name ? { ...f, disabled: true } : f)); } /** diff --git a/ui/packages/plugin-form/src/stateMachineOptions.test.ts b/ui/packages/plugin-form/src/stateMachineOptions.test.ts new file mode 100644 index 00000000000..c5ff1785787 --- /dev/null +++ b/ui/packages/plugin-form/src/stateMachineOptions.test.ts @@ -0,0 +1,161 @@ +/** + * 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 — an edit form's `state_machine` select offers the persisted value + * plus its declared next states, through the one field gate every layout draws + * through (`gateFormFields`). + */ + +import { describe, it, expect } from 'vitest'; +import { gateFormFields } from './fieldWriteGate'; +import { narrowStateMachineOptions } from './stateMachineOptions'; + +const STATUS_OPTIONS = [ + { value: 'backlog', label: 'Backlog' }, + { value: 'todo', label: 'To Do' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'in_review', label: 'In Review' }, + { value: 'done', label: 'Done' }, +]; + +const transitions = { + backlog: ['todo'], + todo: ['in_progress', 'backlog'], + in_progress: ['in_review', 'todo'], + in_review: ['done', 'in_progress'], + done: ['in_progress'], +}; + +// The showcase task's rule: update-only, severity left at the spec default. +const taskSchema = (rule: Record = {}) => ({ + name: 'showcase_task', + fields: { status: { type: 'select', options: STATUS_OPTIONS } }, + validations: [ + { type: 'state_machine', name: 'task_status_flow', field: 'status', events: ['update'], transitions, ...rule }, + ], +}); + +// A generated form entry carries BOTH option carriers: its own `options` (the +// form renderer's built-in select) and the raw field metadata under `field` +// (what the registered select widget reads). +const statusField = () => ({ + name: 'status', + type: 'field:select', + options: STATUS_OPTIONS, + field: { type: 'select', options: STATUS_OPTIONS }, +}); +const titleField = () => ({ name: 'title', type: 'input' }); + +const values = (opts: unknown) => (opts as Array<{ value: string }>).map((o) => o.value); + +describe('narrowStateMachineOptions', () => { + it('keeps the persisted value plus its declared next states, on both carriers', () => { + const [status] = narrowStateMachineOptions([statusField()], { + objectSchema: taskSchema(), + mode: 'edit', + previousValues: { status: 'backlog' }, + }); + expect(values(status.options)).toEqual(['backlog', 'todo']); + expect(values(status.field.options)).toEqual(['backlog', 'todo']); + }); + + it('reads the from-state from the persisted record, not from anything live', () => { + const [status] = narrowStateMachineOptions([statusField()], { + objectSchema: taskSchema(), + mode: 'edit', + previousValues: { status: 'in_review' }, + }); + expect(values(status.options)).toEqual(['in_progress', 'in_review', 'done']); + }); + + it('leaves a create form unconstrained', () => { + const fields = [statusField()]; + const out = narrowStateMachineOptions(fields, { + objectSchema: taskSchema(), + mode: 'create', + previousValues: { status: 'backlog' }, + }); + expect(out).toBe(fields); + }); + + it('leaves the form unconstrained until the persisted record has loaded', () => { + const fields = [statusField()]; + expect( + narrowStateMachineOptions(fields, { objectSchema: taskSchema(), mode: 'edit', previousValues: undefined }), + ).toBe(fields); + }); + + it('keeps every option for a warning-severity machine', () => { + const field = statusField(); + const [status] = narrowStateMachineOptions([field], { + objectSchema: taskSchema({ severity: 'warning' }), + mode: 'edit', + previousValues: { status: 'backlog' }, + }); + expect(status).toBe(field); + }); + + it('keeps every option for an undeclared from-state', () => { + const field = statusField(); + const [status] = narrowStateMachineOptions([field], { + objectSchema: taskSchema(), + mode: 'edit', + previousValues: { status: 'archived' }, + }); + expect(status).toBe(field); + }); + + it('narrows plain string options too', () => { + const [status] = narrowStateMachineOptions( + [{ name: 'status', options: ['backlog', 'todo', 'done'] }], + { objectSchema: taskSchema(), mode: 'edit', previousValues: { status: 'backlog' } }, + ); + expect(status.options).toEqual(['backlog', 'todo']); + }); + + it('leaves a multi-value field and fields with no machine untouched', () => { + const multi = { ...statusField(), multiple: true }; + const title = titleField(); + const out = narrowStateMachineOptions([multi, title], { + objectSchema: taskSchema(), + mode: 'edit', + previousValues: { status: 'backlog', title: 'x' }, + }); + expect(out[0]).toBe(multi); + expect(out[1]).toBe(title); + }); +}); + +describe('gateFormFields narrows a state-machine select (#25933)', () => { + const gate = (mode: string, previousValues?: Record) => + gateFormFields([statusField(), titleField()], { + perms: null, + objectName: 'showcase_task', + mode, + objectSchema: taskSchema() as any, + previousValues, + })!; + + it('narrows on an edit form handed its persisted record', () => { + const [status, title] = gate('edit', { status: 'done' }); + expect(values(status.options)).toEqual(['in_progress', 'done']); + expect(values((status as any).field.options)).toEqual(['in_progress', 'done']); + expect(title).toEqual(titleField()); + }); + + it('offers every option on a create form', () => { + const [status] = gate('create', { status: 'done' }); + expect(values(status.options)).toEqual(values(STATUS_OPTIONS)); + }); + + it('offers every option when no persisted record is handed in', () => { + const [status] = gate('edit'); + expect(values(status.options)).toEqual(values(STATUS_OPTIONS)); + }); +}); diff --git a/ui/packages/plugin-form/src/stateMachineOptions.ts b/ui/packages/plugin-form/src/stateMachineOptions.ts new file mode 100644 index 00000000000..7997d135343 --- /dev/null +++ b/ui/packages/plugin-form/src/stateMachineOptions.ts @@ -0,0 +1,88 @@ +/** + * 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. + */ + +/** + * An edit form's select offers only the moves its `state_machine` allows + * (#25933). + * + * The object's served `validations` declare, per from-state, which values a + * field may move to; the server refuses every other move on update. Before + * this pass the record form offered every option and the user learned which + * ones were allowed only from the refusal after submit. Here a select field + * governed by a blocking machine keeps the persisted value plus its declared + * next states, and the rest are left out (not drawn disabled, so no new copy). + * + * Which rules narrow, and from which value, is `stateMachineNextValues` in + * `@object-ui/core` — the same read the grid's inline editor and the board + * use. Three rules of this pass: + * + * - the from-state is the PERSISTED record (`previousValues`), never the live + * form value, because the server judges the move against the stored row; + * - a create form is unconstrained — a new record has no from-state, and the + * insert entry point (`initialStates`) is a different contract; + * - the current value always stays offered, so opening and saving the form + * without touching the field never fails. + * + * Both option carriers are narrowed: the FormField's own `options` (the form + * renderer's built-in select) and the stashed object-field metadata's + * `field.options` (the registered select widget reads that one). + */ + +import { stateMachineNextValues } from '@object-ui/core'; + +type OptionLike = unknown; + +function optionValue(option: OptionLike): string { + if (option && typeof option === 'object' && 'value' in option) { + return String((option as { value: unknown }).value); + } + return String(option); +} + +function keepReachable(options: unknown, reachable: Set): unknown { + return Array.isArray(options) + ? options.filter((o: OptionLike) => reachable.has(optionValue(o))) + : options; +} + +export interface NarrowStateMachineOptionsArgs { + /** The object schema the form resolved; its `validations` carry the machines. */ + objectSchema: unknown; + /** The form's mode; only an edit narrows. */ + mode?: string; + /** The persisted record — the row as the server last stored it. */ + previousValues?: Record | null; +} + +/** + * Narrow each select field's options to the values its blocking + * `state_machine` lets the persisted value move to. Fields with no such + * machine, multi-value fields and every field of a create form pass through + * unchanged (same object identity). + */ +export function narrowStateMachineOptions>( + fields: T[], + { objectSchema, mode, previousValues }: NarrowStateMachineOptionsArgs, +): T[] { + if (mode !== 'edit' || !previousValues || typeof previousValues !== 'object') return fields; + return fields.map((f) => { + if (!f?.name || f.multiple || f.field?.multiple) return f; + const reachable = stateMachineNextValues(objectSchema, f.name, previousValues[f.name]); + if (!reachable) return f; + const ownOptions = Array.isArray(f.options); + const fieldOptions = Array.isArray(f.field?.options); + if (!ownOptions && !fieldOptions) return f; + return { + ...f, + ...(ownOptions ? { options: keepReachable(f.options, reachable) } : {}), + ...(fieldOptions + ? { field: { ...f.field, options: keepReachable(f.field.options, reachable) } } + : {}), + }; + }); +} diff --git a/ui/packages/plugin-grid/src/inline-edit-options.test.ts b/ui/packages/plugin-grid/src/inline-edit-options.test.ts index baee4f89321..e66a4bcb063 100644 --- a/ui/packages/plugin-grid/src/inline-edit-options.test.ts +++ b/ui/packages/plugin-grid/src/inline-edit-options.test.ts @@ -70,6 +70,16 @@ describe('stateMachineNextValues', () => { expect(stateMachineNextValues({ validations: [] }, 'status', 'done')).toBeNull(); }); + // #25933: the helper moved to @object-ui/core and now narrows only where the + // server refuses — a warning machine never blocks the write, so the inline + // editor keeps every option for it (it used to narrow on any machine). + it('returns null (unconstrained) for a warning-severity machine', () => { + const advisory = { + validations: [{ ...taskSchema.validations[0], severity: 'warning' }], + }; + expect(stateMachineNextValues(advisory, 'status', 'done')).toBeNull(); + }); + it('coerces non-string transition values to strings', () => { const numeric = { validations: [{ type: 'state_machine', field: 'level', transitions: { 1: [2, 3] } }], diff --git a/ui/packages/plugin-grid/src/inline-edit-options.ts b/ui/packages/plugin-grid/src/inline-edit-options.ts index 9ea1fbff18b..a45ad75f3cb 100644 --- a/ui/packages/plugin-grid/src/inline-edit-options.ts +++ b/ui/packages/plugin-grid/src/inline-edit-options.ts @@ -15,39 +15,12 @@ import { isInlineExcludedFieldType } from '@object-ui/fields'; /** - * For a field governed by a `state_machine` validation, the set of values - * reachable from `currentValue` — the current value itself plus its declared - * allowed transitions. - * - * Returns `null` (= "don't constrain the options") when: - * - the object has no `state_machine` validation for the field, or - * - the current state isn't declared in the transition map. - * - * The second case mirrors the validation engine's lenient "allow" for - * undeclared from-states (an unconstrained state simply isn't policed), so the - * editor stays permissive exactly where the server would. + * For a field governed by a blocking `state_machine` validation, the values + * reachable from the current one. The helper lives in `@object-ui/core` so the + * record form and the board read the same transition map the same way + * (#25933); it is re-exported here so the grid's import stays as it was. */ -export function stateMachineNextValues( - objectSchema: unknown, - fieldName: string, - currentValue: unknown, -): Set | null { - const rules = (objectSchema as { validations?: unknown })?.validations; - if (!Array.isArray(rules)) return null; - const sm = rules.find( - (r): r is { transitions: Record } => - !!r && - typeof r === 'object' && - (r as { type?: unknown }).type === 'state_machine' && - (r as { field?: unknown }).field === fieldName && - !!(r as { transitions?: unknown }).transitions, - ); - if (!sm) return null; - const from = currentValue == null ? '' : String(currentValue); - const allowed = sm.transitions[from]; - if (!Array.isArray(allowed)) return null; // undeclared from-state → unconstrained - return new Set([from, ...allowed.map((s) => String(s))]); -} +export { stateMachineNextValues } from '@object-ui/core'; /** * Field types that can never be edited as a value in an inline cell: From 794c31502088142a1e927d97e75e0e743937ae17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:06:25 +0000 Subject: [PATCH 2/9] test(ui): pin the state-machine select narrowing through the edit dialog and the simple form Claude-Session: https://claude.ai/code/session_015Kzn5VKHAGKe251waHoUqj Co-authored-by: Claude --- .changeset/25933-state-machine-form-select.md | 16 ++ .../stateMachineSelect-25933.test.tsx | 141 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 .changeset/25933-state-machine-form-select.md create mode 100644 ui/packages/plugin-form/src/__tests__/stateMachineSelect-25933.test.tsx 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/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 `