Skip to content

Commit 2e3e058

Browse files
os-zhuangclaude
andauthored
feat(grid): inline select editor only offers valid state-machine transitions (#2110)
The inline editor would happily offer a status change the server then rejects (e.g. done → in_review), leaving the author to discover the failure on save. Now, when a field is governed by a `state_machine` validation, the dropdown is filtered to the values reachable from the current state — the current value plus its declared transitions — so the invalid choice isn't offered at all. - `stateMachineNextValues(objectSchema, field, value)` reads the object's `validations` (the same metadata the server enforces, already served on the schema and passed through by the adapter's getObjectSchema) and returns the reachable set, or null = "don't constrain" when there's no state machine or the current state is undeclared (mirrors the validation engine's lenient allow). - ObjectGrid.renderCellEditor filters the field's `options` by that set before handing the field to FieldEditWidget; non-select / non-state-machine fields are untouched. - 8 unit tests incl. the live bug case (from `done` → only [done, in_progress]). Complements #2106 (surface save failures): prevent the invalid edit at the source, and still report it if one slips through. Verified end-to-end that the data path is live — the backend serves the transitions and getObjectSchema passes them through unmodified. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b138c9f commit 2e3e058

4 files changed

Lines changed: 158 additions & 1 deletion

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@object-ui/plugin-grid": patch
3+
---
4+
5+
feat(grid): inline select editor only offers valid state-machine transitions
6+
7+
When a field is governed by a `state_machine` validation, the inline cell
8+
editor now filters its dropdown to the values reachable from the current state
9+
(the current value plus its declared transitions) — so you can't stage an edit
10+
the server is bound to reject. Example: a task already `Done` only offers
11+
`Done` and `In Progress`, not `In Review`.
12+
13+
This reads the same `validations` metadata the server enforces (already served
14+
on the object schema), and falls back to showing all options when the field has
15+
no state machine or its current state is undeclared (mirroring the validation
16+
engine's lenient allow). Complements the save-failure surfacing — prevent the
17+
invalid edit at the source, and still report it if one slips through.

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { I18nLabel } from '@objectstack/spec/ui';
2727
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useObjectTranslation, useSafeFieldLabel } from '@object-ui/react';
2828
import { getCellRenderer, resolveCellRendererType, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES } from '@object-ui/fields';
2929
import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n';
30+
import { stateMachineNextValues } from './inline-edit-options';
3031
import {
3132
Badge, Button, NavigationOverlay, EmptyValue,
3233
Popover, PopoverContent, PopoverTrigger,
@@ -1704,9 +1705,20 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
17041705
const fieldDef = (objectSchema as any)?.fields?.[ctx.column?.accessorKey];
17051706
if (!fieldDef || !hasFieldEditWidget(fieldDef.type)) return null;
17061707
const discrete = DISCRETE_EDIT_TYPES.has(fieldDef.type);
1708+
let field: any = { name: ctx.column.accessorKey, ...fieldDef };
1709+
// State-machine-aware: a field bound to a `state_machine` validation
1710+
// only offers transitions valid from the current value, so the editor
1711+
// can't stage an edit the server would reject (e.g. done → in_review).
1712+
const reachable = stateMachineNextValues(objectSchema, ctx.column.accessorKey, ctx.value);
1713+
if (reachable && Array.isArray(field.options)) {
1714+
field = {
1715+
...field,
1716+
options: field.options.filter((o: any) => reachable.has(String(o?.value ?? o))),
1717+
};
1718+
}
17071719
return (
17081720
<FieldEditWidget
1709-
field={{ name: ctx.column.accessorKey, ...fieldDef }}
1721+
field={field}
17101722
value={ctx.value}
17111723
onChange={(v: any) => (discrete ? ctx.commit(v) : ctx.stage(v))}
17121724
/>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { stateMachineNextValues } from './inline-edit-options';
11+
12+
// Mirrors examples/app-showcase task.object.ts — the state machine that rejected
13+
// done → in_review live (done only transitions to in_progress).
14+
const taskSchema = {
15+
validations: [
16+
{
17+
type: 'state_machine',
18+
field: 'status',
19+
transitions: {
20+
backlog: ['todo'],
21+
todo: ['in_progress', 'backlog'],
22+
in_progress: ['in_review', 'todo'],
23+
in_review: ['done', 'in_progress'],
24+
done: ['in_progress'],
25+
},
26+
},
27+
],
28+
};
29+
30+
describe('stateMachineNextValues', () => {
31+
it('returns the current value plus its allowed transitions', () => {
32+
const r = stateMachineNextValues(taskSchema, 'status', 'in_review');
33+
expect(r).not.toBeNull();
34+
expect([...(r as Set<string>)].sort()).toEqual(['done', 'in_progress', 'in_review']);
35+
});
36+
37+
it('constrains a near-terminal state to itself + its one valid move', () => {
38+
// The exact live bug: from `done` the only valid move is `in_progress`,
39+
// so `in_review` must NOT be offered.
40+
const r = stateMachineNextValues(taskSchema, 'status', 'done');
41+
expect([...(r as Set<string>)].sort()).toEqual(['done', 'in_progress']);
42+
expect((r as Set<string>).has('in_review')).toBe(false);
43+
});
44+
45+
it('always includes the current value so it stays selectable', () => {
46+
const r = stateMachineNextValues(taskSchema, 'status', 'backlog');
47+
expect((r as Set<string>).has('backlog')).toBe(true);
48+
expect((r as Set<string>).has('todo')).toBe(true);
49+
});
50+
51+
it('returns null (unconstrained) for a field with no state machine', () => {
52+
expect(stateMachineNextValues(taskSchema, 'priority', 'medium')).toBeNull();
53+
});
54+
55+
it('returns null when the current state is undeclared (lenient, mirrors the engine)', () => {
56+
expect(stateMachineNextValues(taskSchema, 'status', 'archived')).toBeNull();
57+
});
58+
59+
it('returns only the current value for a terminal state (no outgoing edges)', () => {
60+
const terminal = {
61+
validations: [{ type: 'state_machine', field: 'status', transitions: { done: [] } }],
62+
};
63+
const r = stateMachineNextValues(terminal, 'status', 'done');
64+
expect([...(r as Set<string>)]).toEqual(['done']);
65+
});
66+
67+
it('returns null for missing/empty schema or validations', () => {
68+
expect(stateMachineNextValues(null, 'status', 'done')).toBeNull();
69+
expect(stateMachineNextValues({}, 'status', 'done')).toBeNull();
70+
expect(stateMachineNextValues({ validations: [] }, 'status', 'done')).toBeNull();
71+
});
72+
73+
it('coerces non-string transition values to strings', () => {
74+
const numeric = {
75+
validations: [{ type: 'state_machine', field: 'level', transitions: { 1: [2, 3] } }],
76+
};
77+
const r = stateMachineNextValues(numeric, 'level', 1);
78+
expect([...(r as Set<string>)].sort()).toEqual(['1', '2', '3']);
79+
});
80+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Helpers for constraining an inline cell editor's choices to what the data
11+
* layer would actually accept — so the editor can't stage an edit the server
12+
* is bound to reject.
13+
*/
14+
15+
/**
16+
* For a field governed by a `state_machine` validation, the set of values
17+
* reachable from `currentValue` — the current value itself plus its declared
18+
* allowed transitions.
19+
*
20+
* Returns `null` (= "don't constrain the options") when:
21+
* - the object has no `state_machine` validation for the field, or
22+
* - the current state isn't declared in the transition map.
23+
*
24+
* The second case mirrors the validation engine's lenient "allow" for
25+
* undeclared from-states (an unconstrained state simply isn't policed), so the
26+
* editor stays permissive exactly where the server would.
27+
*/
28+
export function stateMachineNextValues(
29+
objectSchema: unknown,
30+
fieldName: string,
31+
currentValue: unknown,
32+
): Set<string> | null {
33+
const rules = (objectSchema as { validations?: unknown })?.validations;
34+
if (!Array.isArray(rules)) return null;
35+
const sm = rules.find(
36+
(r): r is { transitions: Record<string, string[]> } =>
37+
!!r &&
38+
typeof r === 'object' &&
39+
(r as { type?: unknown }).type === 'state_machine' &&
40+
(r as { field?: unknown }).field === fieldName &&
41+
!!(r as { transitions?: unknown }).transitions,
42+
);
43+
if (!sm) return null;
44+
const from = currentValue == null ? '' : String(currentValue);
45+
const allowed = sm.transitions[from];
46+
if (!Array.isArray(allowed)) return null; // undeclared from-state → unconstrained
47+
return new Set<string>([from, ...allowed.map((s) => String(s))]);
48+
}

0 commit comments

Comments
 (0)