Skip to content

Commit eea4391

Browse files
authored
fix(flow-designer): read approver value sources off the schema instead of mirroring them (framework#3508 follow-up) (#2910)
The approver Value picker decided WHERE its candidates live from a local table hand-mirrored from the spec's `APPROVER_VALUE_BINDINGS`. That mirror is what made framework#3508 possible: `xRef.map` names a picker KIND and nothing more, so this package had to choose a data source itself — and chose the metadata REGISTRY, which lists no directory rows. The spec now publishes the contract as `xRef.sources` (objectstack#3817). `json-schema-to-fields` carries it through, validating each entry, and `recordLookupFor()` prefers it over the local table. Presentation stays local, and the local table remains the fallback for a server that predates the annotation. Also corrects the approver `type` options comment in `flow-node-config.ts`: that list is the offline fallback, which is why the "indirect bindings lead" order stated only there never reached the live picker.
1 parent af705b9 commit eea4391

6 files changed

Lines changed: 369 additions & 15 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(flow-designer): read approver value sources off the schema instead of mirroring them (framework#3508 follow-up)
6+
7+
The approver Value picker decided *where its candidates live* from a local
8+
table, `KIND_TO_RECORD_LOOKUP`, hand-mirrored from the spec's
9+
`APPROVER_VALUE_BINDINGS`. That mirror is what made framework#3508 possible:
10+
`xRef.map` names a picker KIND (`'team'`) and nothing more, so this package had
11+
to pick a data source itself — and picked the metadata REGISTRY
12+
(`GET /api/v1/meta/:type`), which lists no `sys_user` / `sys_team` /
13+
`sys_business_unit` / `sys_position` ROWS. Candidates were always empty and the
14+
control degraded to a raw-id text box.
15+
16+
The spec now publishes the data contract as `xRef.sources` (one entry per
17+
approver type: `{ source: 'data', object, valueField }`, the closed enum
18+
inline, or a non-picker marker). `json-schema-to-fields` carries it through —
19+
validating each entry, dropping any that could not drive a picker — and
20+
`recordLookupFor()` prefers it over the local table. A new approver type can no
21+
longer leave a stale mirror behind here.
22+
23+
What did NOT move: presentation. Which field to display, whether to open the
24+
people picker, what subtitle to show under a row stay this package's calls, so
25+
the spec ships the data contract and not the look. The local table remains as
26+
the fallback for a server that predates the annotation, and a `data` source for
27+
a kind with no presentation entry still renders a lookup labelled by its
28+
committed column — better than degrading a resolvable reference to free text.
29+
30+
Also corrects the approver `type` options comment in `flow-node-config.ts`: that
31+
list is the OFFLINE fallback (`FlowNodeInspector` renders
32+
`serverFields ?? fieldsForNodeType(...)`, so a real backend's published
33+
configSchema wins). Its "indirect bindings lead, `user` last" ordering therefore
34+
never reached the live picker, which followed the spec enum with `user` first —
35+
the opposite of the intent. The ordering now lives in the spec's `ApproverType`
36+
enum, and the comment says which list is authoritative.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* framework #3508 follow-up — the approver Value picker now reads WHERE its
5+
* candidates live off the published schema (`xRef.sources`) instead of a local
6+
* mirror of the spec's `APPROVER_VALUE_BINDINGS`.
7+
*
8+
* The mirror is what made #3508 possible in the first place: `xRef.map` named
9+
* a picker KIND and nothing more, so this package had to decide the data
10+
* source itself — and decided wrong, wiring every directory kind to the
11+
* metadata registry, which holds no `sys_user` / `sys_team` rows.
12+
*
13+
* Load-bearing behaviours:
14+
* 1. a published `data` source WINS over the local table (that is what stops
15+
* the mirror from drifting from the engine again);
16+
* 2. no published source → the local table still drives the picker, so an
17+
* older server keeps working;
18+
* 3. a published NON-data source (enum / auto / unsupported) keeps the kind
19+
* off the record-lookup path entirely;
20+
* 4. presentation (display field, people-picker, subtitle) stays local — the
21+
* spec ships the data contract, not the look;
22+
* 5. the source is keyed by the DISCRIMINATOR, not the picker kind, so
23+
* `role` and `org_membership_level` stay distinguishable though they
24+
* share one kind.
25+
*/
26+
27+
import { describe, it, expect } from 'vitest';
28+
import { recordLookupFor, resolveRefKind, KIND_TO_RECORD_LOOKUP } from './FlowReferenceField';
29+
30+
describe('recordLookupFor — published source vs local mirror (#3508 follow-up)', () => {
31+
it('prefers the schema’s object and committed column over the local table', () => {
32+
// A server that renamed the directory object: the picker must follow the
33+
// SCHEMA, or it queries a table the engine does not resolve against.
34+
const lookup = recordLookupFor({
35+
kind: 'user',
36+
source: { source: 'data', object: 'sys_person', valueField: 'external_id' },
37+
});
38+
expect(lookup).toMatchObject({ object: 'sys_person', valueField: 'external_id' });
39+
// …while presentation still comes from here.
40+
expect(lookup?.picker).toBe('search');
41+
expect(lookup?.subtitle).toEqual(['email']);
42+
});
43+
44+
it('falls back to the local table when the server publishes no source', () => {
45+
expect(recordLookupFor({ kind: 'department' })).toEqual(KIND_TO_RECORD_LOOKUP.department);
46+
expect(recordLookupFor({ kind: 'position' })).toEqual(KIND_TO_RECORD_LOOKUP.position);
47+
});
48+
49+
it('keeps a published non-data source off the record path', () => {
50+
// These are not pickers over rows: a closed enum, a runtime-resolved
51+
// value, and a type the runtime never resolves at all.
52+
expect(recordLookupFor({ kind: 'org-membership-level', source: { source: 'enum', values: ['owner'] } })).toBeUndefined();
53+
expect(recordLookupFor({ kind: 'manager', source: { source: 'auto' } })).toBeUndefined();
54+
expect(recordLookupFor({ kind: 'queue', source: { source: 'unsupported' } })).toBeUndefined();
55+
});
56+
57+
it('renders a lookup for a data source this package has no presentation entry for', () => {
58+
// Better a working picker labelled by its committed column than degrading
59+
// a perfectly resolvable reference to free text.
60+
const lookup = recordLookupFor({
61+
kind: 'queue',
62+
source: { source: 'data', object: 'sys_queue', valueField: 'id' },
63+
});
64+
expect(lookup).toEqual({ object: 'sys_queue', valueField: 'id', displayField: 'id' });
65+
});
66+
67+
it('resolves nothing for an unresolved reference', () => {
68+
expect(recordLookupFor(undefined)).toBeUndefined();
69+
});
70+
});
71+
72+
describe('resolveRefKind — sources are keyed by the discriminator (#3508 follow-up)', () => {
73+
const ref = {
74+
kindFrom: 'type',
75+
map: { user: 'user', role: 'org-membership-level', org_membership_level: 'org-membership-level' },
76+
sources: {
77+
user: { source: 'data' as const, object: 'sys_user', valueField: 'id' },
78+
role: { source: 'enum' as const, values: ['owner', 'admin', 'member'] },
79+
org_membership_level: { source: 'enum' as const, values: ['owner', 'admin', 'member'] },
80+
},
81+
};
82+
83+
it('carries the source for the row’s own type', () => {
84+
const resolved = resolveRefKind(ref, () => 'user');
85+
expect(resolved).toMatchObject({ kind: 'user', source: { source: 'data', object: 'sys_user' } });
86+
});
87+
88+
it('distinguishes two discriminators that share one picker kind', () => {
89+
// `role` is the deprecated spelling of `org_membership_level`; both render
90+
// the same control, and a per-KIND lookup could not tell them apart.
91+
expect(resolveRefKind(ref, () => 'role')?.source).toEqual({ source: 'enum', values: ['owner', 'admin', 'member'] });
92+
expect(resolveRefKind(ref, () => 'org_membership_level')?.kind).toBe('org-membership-level');
93+
});
94+
95+
it('leaves the source undefined when the server published none', () => {
96+
const bare = { kindFrom: 'type', map: { user: 'user' as const } };
97+
expect(resolveRefKind(bare, () => 'user')).toEqual({
98+
kind: 'user', objectSource: undefined, connectorSource: undefined, source: undefined,
99+
});
100+
});
101+
102+
it('still returns undefined for a discriminator with no mapped kind', () => {
103+
expect(resolveRefKind(ref, () => 'nope')).toBeUndefined();
104+
});
105+
});

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

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
import { Pencil, Search } from 'lucide-react';
4242
import { useAdapter } from '@object-ui/react';
4343
import { LookupField } from '@object-ui/fields';
44-
import type { FlowReferenceSpec, ReferenceKind } from './flow-node-config';
44+
import type { FlowReferenceSpec, ReferenceKind, RefValueSource } from './flow-node-config';
4545
import { useMetadataClient } from '../useMetadata';
4646
import { useObjectFields } from '../previews/useObjectFields';
4747

@@ -127,6 +127,13 @@ export interface ResolvedRef {
127127
kind: ReferenceKind;
128128
objectSource?: string;
129129
connectorSource?: string;
130+
/**
131+
* The schema's own answer for where this kind's candidates live, when the
132+
* server published one (framework#3508 follow-up). Takes precedence over
133+
* {@link KIND_TO_RECORD_LOOKUP}, which stays as the fallback for a server
134+
* that predates the annotation.
135+
*/
136+
source?: RefValueSource;
130137
}
131138

132139
/**
@@ -140,15 +147,61 @@ export function resolveRefKind(
140147
sibling: (key: string) => unknown,
141148
): ResolvedRef | undefined {
142149
if (!ref) return undefined;
143-
if (ref.kind) return { kind: ref.kind, objectSource: ref.objectSource, connectorSource: ref.connectorSource };
150+
if (ref.kind) {
151+
return {
152+
kind: ref.kind,
153+
objectSource: ref.objectSource,
154+
connectorSource: ref.connectorSource,
155+
source: ref.sources?.[ref.kind],
156+
};
157+
}
144158
if (ref.kindFrom && ref.map) {
145159
const disc = sibling(ref.kindFrom);
146160
const k = typeof disc === 'string' ? ref.map[disc] : undefined;
147-
if (k) return { kind: k, objectSource: ref.objectSource, connectorSource: ref.connectorSource };
161+
// The source is keyed by the DISCRIMINATOR (the approver `type`), not by
162+
// the picker kind: `role` and `org_membership_level` share one kind but
163+
// are separate enum members upstream.
164+
if (k) {
165+
return {
166+
kind: k,
167+
objectSource: ref.objectSource,
168+
connectorSource: ref.connectorSource,
169+
source: typeof disc === 'string' ? ref.sources?.[disc] : undefined,
170+
};
171+
}
148172
}
149173
return undefined;
150174
}
151175

176+
/**
177+
* The record lookup to render for a resolved reference.
178+
*
179+
* The schema's `xRef.sources` wins when present — it is the spec's own
180+
* `APPROVER_VALUE_BINDINGS`, so it cannot drift from what the engine resolves.
181+
* {@link KIND_TO_RECORD_LOOKUP} supplies the fallback (older server) and, in
182+
* both paths, the PRESENTATION: which field to show, whether to open the
183+
* people picker, what subtitle to put under a row. Those are this package's
184+
* calls and deliberately stay out of the spec.
185+
*
186+
* A `data` source for a kind this package has no presentation entry for still
187+
* renders a lookup — display falls back to the committed column, which beats
188+
* degrading a resolvable reference to free text.
189+
*/
190+
export function recordLookupFor(resolved: ResolvedRef | undefined): RecordLookupBinding | undefined {
191+
if (!resolved) return undefined;
192+
const local = KIND_TO_RECORD_LOOKUP[resolved.kind];
193+
const published = resolved.source;
194+
if (published && published.source !== 'data') return undefined;
195+
if (!published) return local;
196+
return {
197+
object: published.object,
198+
valueField: published.valueField as RecordLookupBinding['valueField'],
199+
displayField: local?.displayField ?? published.valueField,
200+
...(local?.picker ? { picker: local.picker } : {}),
201+
...(local?.subtitle ? { subtitle: local.subtitle } : {}),
202+
};
203+
}
204+
152205
/** Read `node.config[key]` as a non-empty string, else undefined. */
153206
function configString(node: Record<string, unknown> | null | undefined, key: string): string | undefined {
154207
const cfg = node?.config;
@@ -518,7 +571,10 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect,
518571
// (All hooks above have already run — the branches below only render.)
519572

520573
// Directory-backed kinds → single-select record lookup (framework #3508).
521-
const lookup = kind ? KIND_TO_RECORD_LOOKUP[kind] : undefined;
574+
// The object + committed column come from the schema when the server
575+
// publishes them, so this package no longer decides where the engine's
576+
// approvers live — see `recordLookupFor`.
577+
const lookup = recordLookupFor(resolved);
522578
if (lookup) {
523579
return (
524580
<RecordLookupCell

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

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,41 @@ export interface FlowReferenceSpec {
127127
*/
128128
kindFrom?: string;
129129
map?: Record<string, ReferenceKind>;
130+
/**
131+
* Where each discriminator value's candidates actually live, keyed like
132+
* {@link map} — published by the spec as `xRef.sources` (framework #3508
133+
* follow-up) and carried through by `json-schema-to-fields`.
134+
*
135+
* {@link map} only ever named a picker KIND. It never said what backs that
136+
* picker, so this package had to keep its own copy of the data contract —
137+
* and the first copy pointed every directory kind at the metadata REGISTRY
138+
* (`GET /api/v1/meta/:type`), which cannot list `sys_user` / `sys_team` /
139+
* `sys_business_unit` / `sys_position` ROWS. Candidates came back empty and
140+
* the control silently degraded to free text (framework#3508). Reading the
141+
* source off the schema means a new approver type can no longer leave a
142+
* stale mirror behind here.
143+
*
144+
* Absent when the server predates the annotation — consumers keep a local
145+
* fallback for that case.
146+
*/
147+
sources?: Record<string, RefValueSource>;
130148
}
131149

150+
/**
151+
* How a polymorphic reference's candidates are sourced, per discriminator
152+
* value. Mirrors the spec's `APPROVER_VALUE_SOURCES` projection.
153+
*
154+
* `data` means the DATA API (`/api/v1/data/:object`) — named in deliberate
155+
* contrast to `meta`, the registry this used to query by mistake. The other
156+
* variants are not pickers at all: a closed `enum`, an `auto`-resolved value, a
157+
* `trigger-field` name, a CEL `expression`, or an `unsupported` type the
158+
* runtime never resolves.
159+
*/
160+
export type RefValueSource =
161+
| { source: 'data'; object: string; valueField: string }
162+
| { source: 'enum'; values: string[] }
163+
| { source: 'auto' | 'trigger-field' | 'expression' | 'unsupported' };
164+
132165
/** Column descriptor for an `objectList` repeater row. */
133166
export interface FlowConfigColumn {
134167
key: string;
@@ -537,14 +570,25 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
537570
key: 'type',
538571
label: 'Type',
539572
kind: 'select',
540-
// Mirrors the spec's NON_AUTHORABLE_APPROVER_TYPES (approval.zod.ts):
541-
// `role` is the deprecated spelling of `org_membership_level`
542-
// (ADR-0090 D3), and `queue` is declared-but-unenforced — the runtime
543-
// resolves it to nobody (framework #3508). A stored row of either
544-
// still renders (the select surfaces it flagged "(deprecated)"), but
545-
// the designer never authors a new one. Indirect bindings lead and
546-
// the literal `user` binding comes last: binding a specific person is
547-
// the least portable choice (env moves, people leave).
573+
// OFFLINE FALLBACK ONLY. `FlowNodeInspector` renders
574+
// `serverFields ?? fieldsForNodeType(...)`, so against a real backend
575+
// the approval node's fields come from the engine-published
576+
// configSchema and this list is never read — it covers the preview
577+
// gallery and any stack whose server publishes no schema.
578+
//
579+
// That is why the ordering below is ALSO carried by the spec's
580+
// `ApproverType` enum (framework#3508 follow-up): stating it only
581+
// here left the live picker in enum order with `user` first, the
582+
// exact opposite of the intent. Indirect bindings lead and the
583+
// literal `user` binding comes last — binding a specific person is
584+
// the least portable choice (env moves, people leave). Keep the two
585+
// in sync; the spec is the source of truth.
586+
//
587+
// Both paths also drop the spec's NON_AUTHORABLE_APPROVER_TYPES —
588+
// `role` (deprecated spelling of `org_membership_level`, ADR-0090 D3)
589+
// and `queue` (declared-but-unenforced; the runtime resolves it to
590+
// nobody, framework#3508). A stored row of either still renders,
591+
// flagged "(deprecated)", but neither is offered for new authoring.
548592
options: [
549593
{ value: 'manager', label: 'Manager' },
550594
{ value: 'position', label: 'Position' },

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,67 @@ describe('jsonSchemaToFlowFields', () => {
155155
expect(col.ref).toEqual({ kind: 'connector' });
156156
});
157157

158+
// framework#3508 follow-up: `map` names a picker KIND but never said what
159+
// backs it, so this package had to guess the data source — and guessed the
160+
// metadata registry, which lists no directory ROWS. `sources` carries the
161+
// answer, and an entry the designer cannot act on is dropped rather than
162+
// half-trusted (a `data` source with no committed column would query
163+
// nothing, which is the failure the annotation exists to end).
164+
it('carries xRef.sources through, dropping entries that could not drive a picker', () => {
165+
const fields = jsonSchemaToFlowFields({
166+
type: 'object',
167+
properties: {
168+
rows: {
169+
type: 'array',
170+
items: {
171+
type: 'object',
172+
properties: {
173+
value: {
174+
type: 'string',
175+
xRef: {
176+
kindFrom: 'type',
177+
map: { user: 'user', team: 'team', manager: 'manager' },
178+
sources: {
179+
user: { source: 'data', object: 'sys_user', valueField: 'id' },
180+
manager: { source: 'auto' },
181+
org_membership_level: { source: 'enum', values: ['owner', 'admin'] },
182+
// Unusable: a record source with no committed column.
183+
team: { source: 'data', object: 'sys_team' },
184+
// Unusable: not a source shape at all.
185+
bogus: { source: 'telepathy' },
186+
},
187+
},
188+
},
189+
},
190+
},
191+
},
192+
},
193+
})!;
194+
const col = fields.find((f) => f.id === 'rows')!.columns!.find((c) => c.key === 'value')!;
195+
expect(col.ref!.sources).toEqual({
196+
user: { source: 'data', object: 'sys_user', valueField: 'id' },
197+
manager: { source: 'auto' },
198+
org_membership_level: { source: 'enum', values: ['owner', 'admin'] },
199+
});
200+
});
201+
202+
it('omits sources entirely for a server that predates the annotation', () => {
203+
const fields = jsonSchemaToFlowFields({
204+
type: 'object',
205+
properties: {
206+
rows: {
207+
type: 'array',
208+
items: {
209+
type: 'object',
210+
properties: { value: { type: 'string', xRef: { kindFrom: 'type', map: { user: 'user' } } } },
211+
},
212+
},
213+
},
214+
})!;
215+
const col = fields.find((f) => f.id === 'rows')!.columns!.find((c) => c.key === 'value')!;
216+
expect(col.ref).toEqual({ kindFrom: 'type', map: { user: 'user' } });
217+
});
218+
158219
it('drops a polymorphic xRef whose map has no known kinds (column stays text)', () => {
159220
const fields = jsonSchemaToFlowFields({
160221
type: 'object',

0 commit comments

Comments
 (0)