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
24 changes: 24 additions & 0 deletions .changeset/approver-type-membership-tier-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@object-ui/app-shell": patch
---

fix(studio): approver Type is a real dropdown that drops the deprecated `role` spelling (framework #3133)

The flow designer's approver `Type` control silently rendered as free text:
`FlowObjectListField` had no `select` branch, so an objectList column of kind
`select` (which the approver type is, derived from the spec enum) fell through
to a plain `<Input>` and its computed options were never shown. Added the
missing branch — it renders a real dropdown from the column's `options`, and
keeps a **stored** value that is no longer offered (a deprecated enum member)
visible-but-flagged so editing a legacy row can't silently blank it.

With the dropdown live, it honors framework's new `xEnumDeprecated` schema
annotation (ADR-0090 D3): the deprecated `role` approver type is dropped from
the options while `org_membership_level` is offered, so Studio no longer walks
authors into the trap of picking `role` (which resolves against the better-auth
membership tier and silently matches nobody).

Also: the `org-membership-level` reference picker is a fixed three-value enum
(owner/admin/member) instead of the dead `client.list('role')` — the `role`
metadata type was removed by ADR-0090 D3, so that call returned nothing and the
Value box degraded to free text.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Regression: an objectList column of kind `select` must render a real
* dropdown from its `options`, not a free-text input. Before this branch
* existed the approver `Type` column (a select derived from the spec enum)
* fell through to a plain `<Input>`, so the computed options — including the
* ADR-0090 D3 filtering that drops the deprecated `role` spelling — were never
* shown, and Studio offered no guidance at all.
*
* Two behaviours are load-bearing:
* 1. the current option's label surfaces on the trigger, and
* 2. a STORED value that is no longer in `options` (a deprecated enum member)
* still renders — editing a legacy row must not silently blank it.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { FlowObjectListField } from './FlowObjectListField';
import type { FlowConfigColumn } from './flow-node-config';

afterEach(cleanup);

// Mirrors the approver objectList: a `select` Type column + a text Value.
const COLUMNS: FlowConfigColumn[] = [
{
key: 'type',
label: 'Type',
kind: 'select',
options: [
{ value: 'user', label: 'User' },
{ value: 'org_membership_level', label: 'Org Membership Level' },
{ value: 'position', label: 'Position' },
],
},
{ key: 'value', label: 'Value', kind: 'text' },
];

function renderList(rows: Array<Record<string, unknown>>) {
return render(
<FlowObjectListField
label="Approvers"
columns={COLUMNS}
value={rows}
onCommit={vi.fn()}
addLabel="Add"
removeLabel="Remove"
emptyLabel="None"
/>,
);
}

describe('FlowObjectListField — select column', () => {
it('renders a select column as a combobox (not free text)', () => {
renderList([{ type: 'position', value: 'finance' }]);
// The trigger surfaces the chosen option's LABEL, which a plain <Input>
// (value = the raw machine name) never would.
expect(screen.getByText('Position')).toBeInTheDocument();
expect(screen.queryByText('Role')).not.toBeInTheDocument();
});

it('offers only the non-deprecated options — `role` is absent', () => {
// The options a fresh row can pick from must match COLUMNS.options exactly;
// `role` was never an option here (it is filtered upstream by
// xEnumDeprecated), and the component must not reintroduce it.
expect(COLUMNS[0].options?.map((o) => o.value)).not.toContain('role');
});

it('still renders a STORED deprecated value, flagged, so a legacy row is not blanked', () => {
// `role` is not among the offered options, but a flow authored on 15.x has
// it stored. It must remain visible (and marked) rather than vanish.
renderList([{ type: 'role', value: 'admin' }]);
expect(screen.getByText('role (deprecated)')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@

import * as React from 'react';
import { Plus, X } from 'lucide-react';
import { Button, Input, Label, Checkbox } from '@object-ui/components';
import {
Button, Input, Label, Checkbox,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@object-ui/components';
import { uniqueId } from './_shared';
import type { FlowConfigColumn } from './flow-node-config';
import { ReferenceCombobox, resolveRefKind, type FlowReferenceContext } from './FlowReferenceField';
Expand Down Expand Up @@ -192,6 +195,51 @@ export function FlowObjectListField({
showHint={false}
/>
</div>
) : col.kind === 'select' ? (
(() => {
const current =
typeof row.values[col.key] === 'string' ? (row.values[col.key] as string) : '';
const opts = col.options ?? [];
// A stored value dropped from the options (a deprecated
// enum member, e.g. the `role` approver type per
// ADR-0090 D3) must still render, or editing a legacy row
// would silently blank it. Surface it as selectable but
// flag it — it is not offered to fresh rows.
const shown =
current && !opts.some((o) => o.value === current)
? [...opts, { value: current, label: `${current} (deprecated)` }]
: opts;
return (
<div className="flex-1">
<Select
value={current || undefined}
onValueChange={(v) =>
setRows((rs) => {
const next = rs.map((r) =>
r.id === row.id
? { ...r, values: { ...r.values, [col.key]: v } }
: r,
);
flush(next);
return next;
})
}
disabled={disabled}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder={col.placeholder ?? '—'} />
</SelectTrigger>
<SelectContent>
{shown.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})()
) : col.kind === 'expression' ? (
<div className="flex-1 space-y-1">
<VariableTextInput
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ interface Option {
const KIND_TO_META_TYPE: Partial<Record<ReferenceKind, string>> = {
object: 'object',
flow: 'flow',
role: 'role',
position: 'position',
user: 'user',
team: 'team',
Expand All @@ -64,6 +63,20 @@ const KIND_TO_META_TYPE: Partial<Record<ReferenceKind, string>> = {
'email-template': 'email_template',
};

/**
* better-auth org-membership tiers. `org-membership-level` is intentionally NOT
* in {@link KIND_TO_META_TYPE}: it used to map to `client.list('role')`, but
* ADR-0090 D3 removed the `role` metadata type, so that call returned nothing
* and the picker silently degraded to a free-text box — which is how
* `sales_manager` got typed into a field that only ever accepts these three.
* The tier is a closed enum, so the options are supplied here instead.
*/
const ORG_MEMBERSHIP_LEVEL_OPTIONS: Option[] = [
{ value: 'owner', label: 'Owner' },
{ value: 'admin', label: 'Admin' },
{ value: 'member', label: 'Member' },
];

/** A concrete (non-polymorphic) reference resolution. */
export interface ResolvedRef {
kind: ReferenceKind;
Expand Down Expand Up @@ -320,6 +333,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled,
}
if (kind === 'connector-action') return connectorActionOptions;
if (kind === 'connector') return connectorListOptions;
if (kind === 'org-membership-level') return ORG_MEMBERSHIP_LEVEL_OPTIONS;
if (kind === 'node') {
const nodes = Array.isArray(ctx.draft.nodes) ? (ctx.draft.nodes as Array<Record<string, unknown>>) : [];
const currentId = typeof ctx.node?.id === 'string' ? ctx.node.id : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ export type FlowConfigFieldKind =
* • `object-field` → a field of some object; the object is resolved via
* {@link FlowReferenceSpec.objectSource}
* • `flow` → a flow, by name (`client.list('flow')`)
* • `role` → a better-auth org-membership tier (`client.list('role')`)
* • `org-membership-level`
* → a better-auth org-membership tier. A FIXED three-value
* enum (owner/admin/member), not a catalog: there is no
* `role` metadata type to list (ADR-0090 D3 renamed
* `sys_role` → `sys_position`), so this kind supplies its
* own options rather than calling `client.list`.
* • `position` → a position / 岗位 by machine name (`client.list('position')`);
* holders resolve via `sys_user_position` (ADR-0090 D3)
* • `node` → another node in *this* flow, by id (read from the draft)
Expand All @@ -62,7 +67,7 @@ export type ReferenceKind =
| 'object'
| 'object-field'
| 'flow'
| 'role'
| 'org-membership-level'
| 'position'
| 'node'
| 'user'
Expand Down Expand Up @@ -416,10 +421,14 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
key: 'type',
label: 'Type',
kind: 'select',
// `role` is deliberately absent: it is the deprecated spelling of
// `org_membership_level` (ADR-0090 D3) and reads as "the old name for
// position", which is the trap — a stored `role` row still renders
// and resolves, but the designer never authors a new one.
options: [
{ value: 'user', label: 'User' },
{ value: 'role', label: 'Role' },
{ value: 'position', label: 'Position' },
{ value: 'org_membership_level', label: 'Organization membership (owner/admin/member)' },
{ value: 'team', label: 'Team' },
{ value: 'department', label: 'Department' },
{ value: 'manager', label: 'Manager' },
Expand All @@ -434,14 +443,19 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
key: 'value',
label: 'Value',
kind: 'reference',
placeholder: 'user id / role / position / field — per type',
placeholder: 'user id / position / membership tier / field — per type',
ref: {
kindFrom: 'type',
objectSource: '$trigger',
// `role` maps to the same picker as `org_membership_level`: the
// designer no longer offers it, but a flow authored on 15.x still
// has stored rows, and they must keep rendering for the length of
// the deprecation window.
map: {
user: 'user',
role: 'role',
position: 'position',
org_membership_level: 'org-membership-level',
role: 'org-membership-level',
team: 'team',
department: 'department',
field: 'object-field',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,23 @@ const APPROVAL_CONFIG_SCHEMA = {
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['user', 'role', 'position', 'team', 'department', 'manager', 'field', 'queue'] },
type: {
type: 'string',
enum: ['user', 'org_membership_level', 'role', 'position', 'team', 'department', 'manager', 'field', 'queue'],
// `role` still parses (a 15.x flow must keep loading) but is not
// offered for new authoring — ADR-0090 D3.
xEnumDeprecated: ['role'],
},
value: {
description: 'User id / membership tier / position / team / department / field / queue — per `type`',
type: 'string',
xRef: {
kindFrom: 'type',
objectSource: '$trigger',
map: { user: 'user', role: 'role', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
// Mirrors @objectstack/spec approval.zod.ts: both the canonical
// spelling and its deprecated `role` alias point at the same
// picker kind (ADR-0090 D3).
map: { user: 'user', org_membership_level: 'org-membership-level', role: 'org-membership-level', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
},
},
},
Expand Down Expand Up @@ -103,14 +112,21 @@ describe('jsonSchemaToFlowFields', () => {
expect(colKeys).toEqual(['type', 'value']);
const typeCol = approvers.columns!.find((c) => c.key === 'type')!;
expect(typeCol.kind).toBe('select');
expect(typeCol.options!.map((o) => o.value)).toEqual(['user', 'role', 'position', 'team', 'department', 'manager', 'field', 'queue']);
// `role` is dropped from the OPTIONS (xEnumDeprecated) while staying in the
// enum: the designer must not hand an author the deprecated spelling, which
// reads as "the old name for position" and silently routes to nobody.
expect(typeCol.options!.map((o) => o.value)).toEqual(['user', 'org_membership_level', 'position', 'team', 'department', 'manager', 'field', 'queue']);
expect(typeCol.options!.map((o) => o.value)).not.toContain('role');
const valueCol = approvers.columns!.find((c) => c.key === 'value')!;
// Polymorphic reference: the picker follows the row's `type`.
// Polymorphic reference: the picker follows the row's `type`. The
// deprecated `role` discriminator survives and resolves to the SAME picker
// kind as the canonical spelling — a flow authored on 15.x must keep
// rendering for the length of its deprecation window (ADR-0090 D3).
expect(valueCol.kind).toBe('reference');
expect(valueCol.ref).toEqual({
kindFrom: 'type',
objectSource: '$trigger',
map: { user: 'user', role: 'role', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
map: { user: 'user', org_membership_level: 'org-membership-level', role: 'org-membership-level', position: 'position', team: 'team', department: 'department', field: 'object-field', queue: 'queue' },
});
expect(valueCol.placeholder).toBe('User id / membership tier / position / team / department / field / queue — per `type`');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ import type { FlowConfigField, FlowConfigColumn, FlowConfigFieldKind, FlowRefere
interface JsonSchemaNode {
type?: string | string[];
enum?: unknown[];
/**
* Enum members that still parse but must not be offered for new authoring
* (`@objectstack/spec` `.meta({ xEnumDeprecated })`). We drop them from the
* select's options while leaving them valid: a stored value keeps rendering,
* the designer just stops handing authors the deprecated spelling. Without
* this, a backward-compatible alias in the spec enum reappears in every
* picker derived from it — e.g. the approver `role` trap (ADR-0090 D3).
*/
xEnumDeprecated?: unknown[];
const?: unknown;
default?: unknown;
description?: string;
Expand All @@ -59,7 +68,7 @@ const REFERENCE_KINDS: ReadonlySet<string> = new Set<ReferenceKind>([
'object',
'object-field',
'flow',
'role',
'org-membership-level',
'position',
'node',
'user',
Expand Down Expand Up @@ -126,10 +135,15 @@ function schemaType(node: JsonSchemaNode): string | undefined {
return undefined;
}

/** Build `{ value, label }` options from a string enum. */
function enumOptions(values: unknown[]): Array<{ value: string; label: string }> {
/**
* Build `{ value, label }` options from a string enum, minus any member the
* schema marks deprecated ({@link JsonSchemaNode.xEnumDeprecated}).
*/
function enumOptions(values: unknown[], deprecated?: unknown[]): Array<{ value: string; label: string }> {
const skip = new Set((deprecated ?? []).filter((v): v is string => typeof v === 'string'));
return values
.filter((v): v is string => typeof v === 'string')
.filter((v) => !skip.has(v))
.map((v) => ({ value: v, label: humanizeKey(v) }));
}

Expand Down Expand Up @@ -168,7 +182,7 @@ function columnsFor(item: JsonSchemaNode): FlowConfigColumn[] {
kind = 'reference';
} else if (Array.isArray(prop.enum)) {
kind = 'select';
options = enumOptions(prop.enum);
options = enumOptions(prop.enum, prop.xEnumDeprecated);
} else if (t === 'boolean') {
kind = 'boolean';
} else {
Expand Down Expand Up @@ -245,7 +259,7 @@ export function jsonSchemaToFlowFields(schema: unknown): FlowConfigField[] | nul
// The gate adopts the parent group's label; siblings keep their own.
...(isGate ? { label: prop.title || humanizeKey(key), ...(prop.description ? { help: prop.description } : {}), ...(defaultString(sp) ? { defaultValue: defaultString(sp) } : {}) } : meta(sp, subKey)),
...(subRef ? { ref: subRef } : {}),
...(kind === 'select' && Array.isArray(sp.enum) ? { options: enumOptions(sp.enum) } : {}),
...(kind === 'select' && Array.isArray(sp.enum) ? { options: enumOptions(sp.enum, sp.xEnumDeprecated) } : {}),
...(hasEnabled && !isGate ? { showWhen: { field: `${key}.enabled`, equals: ['true'] } } : {}),
};
fields.push(field);
Expand All @@ -268,7 +282,7 @@ export function jsonSchemaToFlowFields(schema: unknown): FlowConfigField[] | nul
path: ['config', key],
kind,
...meta(prop, key),
...(kind === 'select' && Array.isArray(prop.enum) ? { options: enumOptions(prop.enum) } : {}),
...(kind === 'select' && Array.isArray(prop.enum) ? { options: enumOptions(prop.enum, prop.xEnumDeprecated) } : {}),
});
}

Expand Down
Loading