Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/25933-state-machine-form-select.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/core.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/core","sources":110,"sha256":"c4ca58ee32a488b2139abc179f34d5606ba0d89b86fccf8bd5c4d7b69d07f654","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/core","sources":111,"sha256":"a5c4bc9e88dbbe531e2d19963f3f5e4367e23b2adabbb838eb05770ef086d16c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-form.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-form","sources":40,"sha256":"8a4fd7ee6ef925de6f8c9f49873e2c69fa7bf223a6f14ac87ebcc7e3866ac227","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-form","sources":41,"sha256":"ce9bbd902f3df3f7ac87d6102fcb3489440234ec1741aff5c5518bb94e648687","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-grid.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"c7afb4b073f949588abff953f2f9c14c6c9275b5892d98a6da6c695e4f415136","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"871e72570111ff4df50cd03ad7e7f98045364b7d8c7b11ceead655308a9f263a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
validations: [{ type: 'state_machine', field: 'status', transitions, ...rule }],
});

const sorted = (s: Set<string> | 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();
});
});
5 changes: 5 additions & 0 deletions ui/packages/core/src/validation/validators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } {
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<string> | 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<string>[] = [];
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<string>([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))));
}
6 changes: 4 additions & 2 deletions ui/packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
const uploadGate = useUploadGate();
const previewMode = usePreviewMode();
const [objectSchema, setObjectSchema] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
/**
* The ONE field-gate step every layout draws through (`gateFormFields`,
* objectui#10612): FLS drops non-readable fields and locks non-editable ones,
Expand All @@ -262,11 +263,12 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
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<FormField[]>([]);
const [formData, setFormData] = useState<Record<string, any>>({});
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
Expand Down
6 changes: 4 additions & 2 deletions ui/packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
const previewMode = usePreviewMode();
const perms = usePermissions();
const [objectSchema, setObjectSchema] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 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
Expand All @@ -336,11 +337,12 @@ export const ModalForm: React.FC<ModalFormProps> = ({
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<FormField[]>([]);
const [formData, setFormData] = useState<Record<string, any>>({});
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
Expand Down
23 changes: 12 additions & 11 deletions ui/packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(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<string, any>)
: 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`.
Expand All @@ -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
Expand All @@ -701,7 +712,6 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
/>
);
const [formFields, setFormFields] = useState<FormField[]>([]);
const [initialData, setInitialData] = useState<any>(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
Expand Down Expand Up @@ -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<string, any>)
: 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
Expand Down
4 changes: 3 additions & 1 deletion ui/packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,11 @@ export const SplitForm: React.FC<SplitFormProps> = ({
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
Expand Down
4 changes: 3 additions & 1 deletion ui/packages/plugin-form/src/TabbedForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,9 +448,11 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
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
Expand Down
4 changes: 3 additions & 1 deletion ui/packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -724,9 +724,11 @@ export const WizardForm: React.FC<WizardFormProps> = ({
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
Expand Down
Loading
Loading