Skip to content

Commit 6c53960

Browse files
os-zhuangclaude
andauthored
fix(studio): approver Type dropdown drops deprecated role, membership-tier picker (#2643)
Two objectui-side gaps that let the ADR-0090 D3 approver `role` trap survive in the Studio flow designer, both found by dogfooding the running console. 1. `FlowObjectListField` had no `select` branch. An objectList column of kind `select` — which the approver `Type` is, derived from the spec enum — fell through to a plain `<Input>`. So the Type field was FREE TEXT: the options (and framework's ADR-0090 D3 `xEnumDeprecated` filtering that drops `role`) were computed and thrown away, and the author got no guidance at all. Added the branch: a real dropdown from `col.options`, honoring `xEnumDeprecated`. A STORED value no longer in the options (a 15.x `role` row) still renders, flagged `(deprecated)`, so editing a legacy row can't silently blank it — mirroring the framework runtime's keep-authored-spelling decision. 2. The `role`/`org-membership-level` reference picker called `client.list('role')` — a metadata type ADR-0090 D3 DELETED. It returned nothing, so the Value box degraded to free text. Replaced with a fixed owner/admin/member enum; `role` and `org_membership_level` both map to the new `org-membership-level` picker kind so a stored legacy row still renders. `json-schema-to-fields` now threads `xEnumDeprecated` from the schema into `enumOptions` (both the top-level and objectList-column paths). The hardcoded fallback config and the `ReferenceKind` union follow the rename. Verified end to end in the browser against a framework backend on the #3133 branch: the live approver Type dropdown shows [User, Org Membership Level, Position, Team, Department, Manager, Field, Queue] — no "Role". Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3d2bbc1 commit 6c53960

7 files changed

Lines changed: 223 additions & 18 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(studio): approver Type is a real dropdown that drops the deprecated `role` spelling (framework #3133)
6+
7+
The flow designer's approver `Type` control silently rendered as free text:
8+
`FlowObjectListField` had no `select` branch, so an objectList column of kind
9+
`select` (which the approver type is, derived from the spec enum) fell through
10+
to a plain `<Input>` and its computed options were never shown. Added the
11+
missing branch — it renders a real dropdown from the column's `options`, and
12+
keeps a **stored** value that is no longer offered (a deprecated enum member)
13+
visible-but-flagged so editing a legacy row can't silently blank it.
14+
15+
With the dropdown live, it honors framework's new `xEnumDeprecated` schema
16+
annotation (ADR-0090 D3): the deprecated `role` approver type is dropped from
17+
the options while `org_membership_level` is offered, so Studio no longer walks
18+
authors into the trap of picking `role` (which resolves against the better-auth
19+
membership tier and silently matches nobody).
20+
21+
Also: the `org-membership-level` reference picker is a fixed three-value enum
22+
(owner/admin/member) instead of the dead `client.list('role')` — the `role`
23+
metadata type was removed by ADR-0090 D3, so that call returned nothing and the
24+
Value box degraded to free text.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Regression: an objectList column of kind `select` must render a real
5+
* dropdown from its `options`, not a free-text input. Before this branch
6+
* existed the approver `Type` column (a select derived from the spec enum)
7+
* fell through to a plain `<Input>`, so the computed options — including the
8+
* ADR-0090 D3 filtering that drops the deprecated `role` spelling — were never
9+
* shown, and Studio offered no guidance at all.
10+
*
11+
* Two behaviours are load-bearing:
12+
* 1. the current option's label surfaces on the trigger, and
13+
* 2. a STORED value that is no longer in `options` (a deprecated enum member)
14+
* still renders — editing a legacy row must not silently blank it.
15+
*/
16+
17+
import { describe, it, expect, vi, afterEach } from 'vitest';
18+
import { render, screen, cleanup } from '@testing-library/react';
19+
import { FlowObjectListField } from './FlowObjectListField';
20+
import type { FlowConfigColumn } from './flow-node-config';
21+
22+
afterEach(cleanup);
23+
24+
// Mirrors the approver objectList: a `select` Type column + a text Value.
25+
const COLUMNS: FlowConfigColumn[] = [
26+
{
27+
key: 'type',
28+
label: 'Type',
29+
kind: 'select',
30+
options: [
31+
{ value: 'user', label: 'User' },
32+
{ value: 'org_membership_level', label: 'Org Membership Level' },
33+
{ value: 'position', label: 'Position' },
34+
],
35+
},
36+
{ key: 'value', label: 'Value', kind: 'text' },
37+
];
38+
39+
function renderList(rows: Array<Record<string, unknown>>) {
40+
return render(
41+
<FlowObjectListField
42+
label="Approvers"
43+
columns={COLUMNS}
44+
value={rows}
45+
onCommit={vi.fn()}
46+
addLabel="Add"
47+
removeLabel="Remove"
48+
emptyLabel="None"
49+
/>,
50+
);
51+
}
52+
53+
describe('FlowObjectListField — select column', () => {
54+
it('renders a select column as a combobox (not free text)', () => {
55+
renderList([{ type: 'position', value: 'finance' }]);
56+
// The trigger surfaces the chosen option's LABEL, which a plain <Input>
57+
// (value = the raw machine name) never would.
58+
expect(screen.getByText('Position')).toBeInTheDocument();
59+
expect(screen.queryByText('Role')).not.toBeInTheDocument();
60+
});
61+
62+
it('offers only the non-deprecated options — `role` is absent', () => {
63+
// The options a fresh row can pick from must match COLUMNS.options exactly;
64+
// `role` was never an option here (it is filtered upstream by
65+
// xEnumDeprecated), and the component must not reintroduce it.
66+
expect(COLUMNS[0].options?.map((o) => o.value)).not.toContain('role');
67+
});
68+
69+
it('still renders a STORED deprecated value, flagged, so a legacy row is not blanked', () => {
70+
// `role` is not among the offered options, but a flow authored on 15.x has
71+
// it stored. It must remain visible (and marked) rather than vanish.
72+
renderList([{ type: 'role', value: 'admin' }]);
73+
expect(screen.getByText('role (deprecated)')).toBeInTheDocument();
74+
});
75+
});

packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313

1414
import * as React from 'react';
1515
import { Plus, X } from 'lucide-react';
16-
import { Button, Input, Label, Checkbox } from '@object-ui/components';
16+
import {
17+
Button, Input, Label, Checkbox,
18+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
19+
} from '@object-ui/components';
1720
import { uniqueId } from './_shared';
1821
import type { FlowConfigColumn } from './flow-node-config';
1922
import { ReferenceCombobox, resolveRefKind, type FlowReferenceContext } from './FlowReferenceField';
@@ -192,6 +195,51 @@ export function FlowObjectListField({
192195
showHint={false}
193196
/>
194197
</div>
198+
) : col.kind === 'select' ? (
199+
(() => {
200+
const current =
201+
typeof row.values[col.key] === 'string' ? (row.values[col.key] as string) : '';
202+
const opts = col.options ?? [];
203+
// A stored value dropped from the options (a deprecated
204+
// enum member, e.g. the `role` approver type per
205+
// ADR-0090 D3) must still render, or editing a legacy row
206+
// would silently blank it. Surface it as selectable but
207+
// flag it — it is not offered to fresh rows.
208+
const shown =
209+
current && !opts.some((o) => o.value === current)
210+
? [...opts, { value: current, label: `${current} (deprecated)` }]
211+
: opts;
212+
return (
213+
<div className="flex-1">
214+
<Select
215+
value={current || undefined}
216+
onValueChange={(v) =>
217+
setRows((rs) => {
218+
const next = rs.map((r) =>
219+
r.id === row.id
220+
? { ...r, values: { ...r.values, [col.key]: v } }
221+
: r,
222+
);
223+
flush(next);
224+
return next;
225+
})
226+
}
227+
disabled={disabled}
228+
>
229+
<SelectTrigger className="h-8 w-full text-xs">
230+
<SelectValue placeholder={col.placeholder ?? '—'} />
231+
</SelectTrigger>
232+
<SelectContent>
233+
{shown.map((o) => (
234+
<SelectItem key={o.value} value={o.value}>
235+
{o.label}
236+
</SelectItem>
237+
))}
238+
</SelectContent>
239+
</Select>
240+
</div>
241+
);
242+
})()
195243
) : col.kind === 'expression' ? (
196244
<div className="flex-1 space-y-1">
197245
<VariableTextInput

packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ interface Option {
5454
const KIND_TO_META_TYPE: Partial<Record<ReferenceKind, string>> = {
5555
object: 'object',
5656
flow: 'flow',
57-
role: 'role',
5857
position: 'position',
5958
user: 'user',
6059
team: 'team',
@@ -64,6 +63,20 @@ const KIND_TO_META_TYPE: Partial<Record<ReferenceKind, string>> = {
6463
'email-template': 'email_template',
6564
};
6665

66+
/**
67+
* better-auth org-membership tiers. `org-membership-level` is intentionally NOT
68+
* in {@link KIND_TO_META_TYPE}: it used to map to `client.list('role')`, but
69+
* ADR-0090 D3 removed the `role` metadata type, so that call returned nothing
70+
* and the picker silently degraded to a free-text box — which is how
71+
* `sales_manager` got typed into a field that only ever accepts these three.
72+
* The tier is a closed enum, so the options are supplied here instead.
73+
*/
74+
const ORG_MEMBERSHIP_LEVEL_OPTIONS: Option[] = [
75+
{ value: 'owner', label: 'Owner' },
76+
{ value: 'admin', label: 'Admin' },
77+
{ value: 'member', label: 'Member' },
78+
];
79+
6780
/** A concrete (non-polymorphic) reference resolution. */
6881
export interface ResolvedRef {
6982
kind: ReferenceKind;
@@ -320,6 +333,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled,
320333
}
321334
if (kind === 'connector-action') return connectorActionOptions;
322335
if (kind === 'connector') return connectorListOptions;
336+
if (kind === 'org-membership-level') return ORG_MEMBERSHIP_LEVEL_OPTIONS;
323337
if (kind === 'node') {
324338
const nodes = Array.isArray(ctx.draft.nodes) ? (ctx.draft.nodes as Array<Record<string, unknown>>) : [];
325339
const currentId = typeof ctx.node?.id === 'string' ? ctx.node.id : undefined;

packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ export type FlowConfigFieldKind =
4646
* • `object-field` → a field of some object; the object is resolved via
4747
* {@link FlowReferenceSpec.objectSource}
4848
* • `flow` → a flow, by name (`client.list('flow')`)
49-
* • `role` → a better-auth org-membership tier (`client.list('role')`)
49+
* • `org-membership-level`
50+
* → a better-auth org-membership tier. A FIXED three-value
51+
* enum (owner/admin/member), not a catalog: there is no
52+
* `role` metadata type to list (ADR-0090 D3 renamed
53+
* `sys_role` → `sys_position`), so this kind supplies its
54+
* own options rather than calling `client.list`.
5055
* • `position` → a position / 岗位 by machine name (`client.list('position')`);
5156
* holders resolve via `sys_user_position` (ADR-0090 D3)
5257
* • `node` → another node in *this* flow, by id (read from the draft)
@@ -62,7 +67,7 @@ export type ReferenceKind =
6267
| 'object'
6368
| 'object-field'
6469
| 'flow'
65-
| 'role'
70+
| 'org-membership-level'
6671
| 'position'
6772
| 'node'
6873
| 'user'
@@ -416,10 +421,14 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
416421
key: 'type',
417422
label: 'Type',
418423
kind: 'select',
424+
// `role` is deliberately absent: it is the deprecated spelling of
425+
// `org_membership_level` (ADR-0090 D3) and reads as "the old name for
426+
// position", which is the trap — a stored `role` row still renders
427+
// and resolves, but the designer never authors a new one.
419428
options: [
420429
{ value: 'user', label: 'User' },
421-
{ value: 'role', label: 'Role' },
422430
{ value: 'position', label: 'Position' },
431+
{ value: 'org_membership_level', label: 'Organization membership (owner/admin/member)' },
423432
{ value: 'team', label: 'Team' },
424433
{ value: 'department', label: 'Department' },
425434
{ value: 'manager', label: 'Manager' },
@@ -434,14 +443,19 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
434443
key: 'value',
435444
label: 'Value',
436445
kind: 'reference',
437-
placeholder: 'user id / role / position / field — per type',
446+
placeholder: 'user id / position / membership tier / field — per type',
438447
ref: {
439448
kindFrom: 'type',
440449
objectSource: '$trigger',
450+
// `role` maps to the same picker as `org_membership_level`: the
451+
// designer no longer offers it, but a flow authored on 15.x still
452+
// has stored rows, and they must keep rendering for the length of
453+
// the deprecation window.
441454
map: {
442455
user: 'user',
443-
role: 'role',
444456
position: 'position',
457+
org_membership_level: 'org-membership-level',
458+
role: 'org-membership-level',
445459
team: 'team',
446460
department: 'department',
447461
field: 'object-field',

packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,23 @@ const APPROVAL_CONFIG_SCHEMA = {
1818
items: {
1919
type: 'object',
2020
properties: {
21-
type: { type: 'string', enum: ['user', 'role', 'position', 'team', 'department', 'manager', 'field', 'queue'] },
21+
type: {
22+
type: 'string',
23+
enum: ['user', 'org_membership_level', 'role', 'position', 'team', 'department', 'manager', 'field', 'queue'],
24+
// `role` still parses (a 15.x flow must keep loading) but is not
25+
// offered for new authoring — ADR-0090 D3.
26+
xEnumDeprecated: ['role'],
27+
},
2228
value: {
2329
description: 'User id / membership tier / position / team / department / field / queue — per `type`',
2430
type: 'string',
2531
xRef: {
2632
kindFrom: 'type',
2733
objectSource: '$trigger',
28-
map: { user: 'user', role: 'role', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
34+
// Mirrors @objectstack/spec approval.zod.ts: both the canonical
35+
// spelling and its deprecated `role` alias point at the same
36+
// picker kind (ADR-0090 D3).
37+
map: { user: 'user', org_membership_level: 'org-membership-level', role: 'org-membership-level', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
2938
},
3039
},
3140
},
@@ -103,14 +112,21 @@ describe('jsonSchemaToFlowFields', () => {
103112
expect(colKeys).toEqual(['type', 'value']);
104113
const typeCol = approvers.columns!.find((c) => c.key === 'type')!;
105114
expect(typeCol.kind).toBe('select');
106-
expect(typeCol.options!.map((o) => o.value)).toEqual(['user', 'role', 'position', 'team', 'department', 'manager', 'field', 'queue']);
115+
// `role` is dropped from the OPTIONS (xEnumDeprecated) while staying in the
116+
// enum: the designer must not hand an author the deprecated spelling, which
117+
// reads as "the old name for position" and silently routes to nobody.
118+
expect(typeCol.options!.map((o) => o.value)).toEqual(['user', 'org_membership_level', 'position', 'team', 'department', 'manager', 'field', 'queue']);
119+
expect(typeCol.options!.map((o) => o.value)).not.toContain('role');
107120
const valueCol = approvers.columns!.find((c) => c.key === 'value')!;
108-
// Polymorphic reference: the picker follows the row's `type`.
121+
// Polymorphic reference: the picker follows the row's `type`. The
122+
// deprecated `role` discriminator survives and resolves to the SAME picker
123+
// kind as the canonical spelling — a flow authored on 15.x must keep
124+
// rendering for the length of its deprecation window (ADR-0090 D3).
109125
expect(valueCol.kind).toBe('reference');
110126
expect(valueCol.ref).toEqual({
111127
kindFrom: 'type',
112128
objectSource: '$trigger',
113-
map: { user: 'user', role: 'role', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
129+
map: { user: 'user', org_membership_level: 'org-membership-level', role: 'org-membership-level', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
114130
});
115131
expect(valueCol.placeholder).toBe('User id / membership tier / position / team / department / field / queue — per `type`');
116132
});

packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ import type { FlowConfigField, FlowConfigColumn, FlowConfigFieldKind, FlowRefere
3737
interface JsonSchemaNode {
3838
type?: string | string[];
3939
enum?: unknown[];
40+
/**
41+
* Enum members that still parse but must not be offered for new authoring
42+
* (`@objectstack/spec` `.meta({ xEnumDeprecated })`). We drop them from the
43+
* select's options while leaving them valid: a stored value keeps rendering,
44+
* the designer just stops handing authors the deprecated spelling. Without
45+
* this, a backward-compatible alias in the spec enum reappears in every
46+
* picker derived from it — e.g. the approver `role` trap (ADR-0090 D3).
47+
*/
48+
xEnumDeprecated?: unknown[];
4049
const?: unknown;
4150
default?: unknown;
4251
description?: string;
@@ -59,7 +68,7 @@ const REFERENCE_KINDS: ReadonlySet<string> = new Set<ReferenceKind>([
5968
'object',
6069
'object-field',
6170
'flow',
62-
'role',
71+
'org-membership-level',
6372
'position',
6473
'node',
6574
'user',
@@ -126,10 +135,15 @@ function schemaType(node: JsonSchemaNode): string | undefined {
126135
return undefined;
127136
}
128137

129-
/** Build `{ value, label }` options from a string enum. */
130-
function enumOptions(values: unknown[]): Array<{ value: string; label: string }> {
138+
/**
139+
* Build `{ value, label }` options from a string enum, minus any member the
140+
* schema marks deprecated ({@link JsonSchemaNode.xEnumDeprecated}).
141+
*/
142+
function enumOptions(values: unknown[], deprecated?: unknown[]): Array<{ value: string; label: string }> {
143+
const skip = new Set((deprecated ?? []).filter((v): v is string => typeof v === 'string'));
131144
return values
132145
.filter((v): v is string => typeof v === 'string')
146+
.filter((v) => !skip.has(v))
133147
.map((v) => ({ value: v, label: humanizeKey(v) }));
134148
}
135149

@@ -168,7 +182,7 @@ function columnsFor(item: JsonSchemaNode): FlowConfigColumn[] {
168182
kind = 'reference';
169183
} else if (Array.isArray(prop.enum)) {
170184
kind = 'select';
171-
options = enumOptions(prop.enum);
185+
options = enumOptions(prop.enum, prop.xEnumDeprecated);
172186
} else if (t === 'boolean') {
173187
kind = 'boolean';
174188
} else {
@@ -245,7 +259,7 @@ export function jsonSchemaToFlowFields(schema: unknown): FlowConfigField[] | nul
245259
// The gate adopts the parent group's label; siblings keep their own.
246260
...(isGate ? { label: prop.title || humanizeKey(key), ...(prop.description ? { help: prop.description } : {}), ...(defaultString(sp) ? { defaultValue: defaultString(sp) } : {}) } : meta(sp, subKey)),
247261
...(subRef ? { ref: subRef } : {}),
248-
...(kind === 'select' && Array.isArray(sp.enum) ? { options: enumOptions(sp.enum) } : {}),
262+
...(kind === 'select' && Array.isArray(sp.enum) ? { options: enumOptions(sp.enum, sp.xEnumDeprecated) } : {}),
249263
...(hasEnabled && !isGate ? { showWhen: { field: `${key}.enabled`, equals: ['true'] } } : {}),
250264
};
251265
fields.push(field);
@@ -268,7 +282,7 @@ export function jsonSchemaToFlowFields(schema: unknown): FlowConfigField[] | nul
268282
path: ['config', key],
269283
kind,
270284
...meta(prop, key),
271-
...(kind === 'select' && Array.isArray(prop.enum) ? { options: enumOptions(prop.enum) } : {}),
285+
...(kind === 'select' && Array.isArray(prop.enum) ? { options: enumOptions(prop.enum, prop.xEnumDeprecated) } : {}),
272286
});
273287
}
274288

0 commit comments

Comments
 (0)