From a8fc3be08b323b916942d03e291c86e2c2922639 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 01:53:59 +0000 Subject: [PATCH 1/3] fix(app-shell): a position approver is read through the spec's registry binding (objectui#7611 Q3b) The approval panel, the override dialog and the flow designer's approver Value cell read the spec's approver value bindings, but kept only the `data` member. objectstack#22710 binds `position` to a `registry` member, `{ source: 'registry', type }`, which reaches this console two ways: the console objectstack ships bundles the server's own spec, and a server on that spec publishes the member as the approval node's `xRef.sources`. - approverValueSources: the binding as published, with the registry member admitted by a type loosened for spec 17 (removed at spec 18; a pin says when). Which kinds are registry-bound is read off the binding, never listed here. - approverIdentity / useApproverDirectory: a registry-bound reference is a machine name (prettified, never truncated), is handed to the directory, and takes its label from the registry item through the metadata store. Staffing still reads sys_user_position. - json-schema-to-fields keeps a published registry source; FlowReferenceField renders a select over that type's items and commits the item's name, with a manual-entry toggle. - The data member is read exactly as before. Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR Co-authored-by: Claude --- .changeset/7611-approver-registry-read.md | 9 + .../src/hooks/useApproverDirectory.ts | 99 ++++++- ...overIdentity.registryBinding-7611.test.tsx | 151 +++++++++++ .../app-shell/src/utils/approverIdentity.ts | 62 +++-- .../src/utils/approverValueSources.test.ts | 78 ++++++ .../src/utils/approverValueSources.ts | 110 ++++++++ .../src/views/metadata-admin/i18n.ts | 2 + ...ield.injectedRegistryBinding-7611.test.tsx | 89 +++++++ ...eferenceField.registrySource-7611.test.tsx | 247 ++++++++++++++++++ .../inspectors/FlowReferenceField.tsx | 154 ++++++++++- .../inspectors/flow-node-config.ts | 14 +- .../inspectors/json-schema-to-fields.ts | 15 +- 12 files changed, 993 insertions(+), 37 deletions(-) create mode 100644 .changeset/7611-approver-registry-read.md create mode 100644 packages/app-shell/src/utils/approverIdentity.registryBinding-7611.test.tsx create mode 100644 packages/app-shell/src/utils/approverValueSources.test.ts create mode 100644 packages/app-shell/src/utils/approverValueSources.ts create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.injectedRegistryBinding-7611.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.registrySource-7611.test.tsx diff --git a/.changeset/7611-approver-registry-read.md b/.changeset/7611-approver-registry-read.md new file mode 100644 index 0000000000..653baa0253 --- /dev/null +++ b/.changeset/7611-approver-registry-read.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +A position approver is read through the spec's registry binding, `{ source: 'registry', type }` (objectstack#22710): its readable label and its designer picker come from the position registry, and the picker stores the position's name (objectui#7611, ruled objectstack#22865 Q3b → B). + +- **The approval panel and the override dialog.** When the bundled spec binds `position` to the registry (the console objectstack ships injects that spec), a pending `position:` approver now reads as the position's label, read through the console's metadata store (`ensureType('position')`), else as the prettified name. It used to fall through to the opaque-id truncation and read `positi…ager`. Who holds the seat is still read from `sys_user_position`. +- **The flow designer's approver Value cell.** A `registry` entry in the approval node's published `xRef.sources` is no longer dropped. A kind bound that way renders a select over that metadata type's items (`GET /api/v1/meta/`) and commits the item's name, with a manual-entry toggle; a stored name the registry does not list shows flagged `(not found)`, and an empty registry leaves the text box. It used to fall back to a lookup over `sys_position` rows, or to a plain text box on the console objectstack ships. +- **Unchanged.** A `data` binding (`user`, `team`, `department`, and `position` under the installed spec 17.7.0) still renders the record lookup and still resolves its label from its directory object. No authoring surface changes, so `objectui validate` and the strict faces are unchanged. The type that admits the registry member before the installed spec declares it is marked for removal when objectui moves to spec 18. diff --git a/packages/app-shell/src/hooks/useApproverDirectory.ts b/packages/app-shell/src/hooks/useApproverDirectory.ts index dec02df79d..28aa69cf5a 100644 --- a/packages/app-shell/src/hooks/useApproverDirectory.ts +++ b/packages/app-shell/src/hooks/useApproverDirectory.ts @@ -24,10 +24,19 @@ * * - one `find` per distinct directory OBJECT (`sys_position`, `sys_team`, * `sys_business_unit`, `sys_user`), batched with `$in` over that object's - * values — the label leg; + * values — the label leg for a kind the spec binds to a DATA row; + * - one registry read per metadata TYPE, for a kind the spec binds to the + * registry instead (`{ source: 'registry', type }`, objectui#7611 Q3b): + * the console's metadata store, `ensureType()` — the + * `GET /api/v1/meta/` read the Approval Center's position labels and + * the decision outputs already make — matched by the item's `name`. That is + * the label leg for a registry-bound kind; nothing is read from a mirror + * table for it; * - for `position` refs only, `sys_user_position` -> `sys_user` — the staffing * leg, which answers "who actually sits in this seat" and, when it comes - * back empty, the unstaffed state `77f846a8b` surfaces. + * back empty, the unstaffed state `77f846a8b` surfaces. The junction stores + * the position's machine NAME under either binding, so this leg does not + * depend on which one the spec declares. * * The two legs fail INDEPENDENTLY. A business-app reader may hold list access to * `sys_position` and none to `sys_user_position`; `find` rejects that with @@ -48,11 +57,13 @@ * tests, which must not inherit each other's answers. */ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useAdapter } from '@object-ui/react'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { MetadataCtx, useAdapter } from '@object-ui/react'; import { APPROVER_DIRECTORY_BINDINGS, + APPROVER_REGISTRY_TYPES, PERSON_DISPLAY_FIELDS, + approverKindIsResolvable, parseApproverRef, rowLabel, type ResolvedApprover, @@ -126,6 +137,37 @@ async function resolveLabels( } } +/** The one member of the console's metadata store the registry leg reads. */ +export interface ApproverRegistryReader { + ensureType: (type: string) => Promise; +} + +/** + * Resolve the label leg for every reference of one REGISTRY-bound kind + * (objectui#7611 Q3b): list the kind's metadata type once and label each + * reference by the item whose `name` it carries. An item with no string + * `label` adds nothing, so the prettified machine name still reads. + * Rejects propagate to {@link loadSlate}, which isolates each kind. + */ +async function resolveRegistryLabels( + reader: ApproverRegistryReader, + kind: string, + type: string, + values: string[], + into: Map, +): Promise { + const items = await reader.ensureType(type); + const wanted = new Set(values); + for (const item of Array.isArray(items) ? items : []) { + const name = (item as { name?: unknown } | null)?.name; + const label = (item as { label?: unknown } | null)?.label; + if (typeof name !== 'string' || !wanted.has(name)) continue; + if (typeof label !== 'string' || !label.trim()) continue; + const ref = `${kind}:${name}`; + into.set(ref, { ...into.get(ref), label: label.trim() }); + } +} + /** * Resolve the staffing leg for `position:` references — who sits in each seat. * A seat that the junction never mentions is recorded as `[]` (probed, empty), @@ -179,28 +221,46 @@ async function resolveStaffing( * position its label. Every reference asked about is written back even when * nothing resolved, so a second mount does not re-ask a question the directory * has already declined to answer. + * + * Which label leg a kind takes is the spec's published binding, read per kind: + * a `data` binding reads its directory object through the adapter, a + * `registry` binding reads its metadata type through the store. A leg whose + * reader is absent (no adapter, no ``) is skipped, which is + * the same answer as a leg that failed. */ -async function loadSlate(adapter: any, refs: string[]): Promise { +async function loadSlate( + adapter: any, + registry: ApproverRegistryReader | null, + refs: string[], +): Promise { const answers = new Map(); const valuesByKind = new Map(); for (const raw of refs) { const ref = parseApproverRef(raw); - if (!ref || !APPROVER_DIRECTORY_BINDINGS[ref.kind]) continue; + if (!ref || !approverKindIsResolvable(ref.kind)) continue; const bucket = valuesByKind.get(ref.kind) ?? []; if (!bucket.includes(ref.value)) bucket.push(ref.value); valuesByKind.set(ref.kind, bucket); } await Promise.all( - [...valuesByKind].map(([kind, values]) => - resolveLabels(adapter, kind, values, answers).catch(() => { + [...valuesByKind].map(([kind, values]) => { + const registryType = APPROVER_REGISTRY_TYPES[kind]; + const leg = registryType + ? registry + ? resolveRegistryLabels(registry, kind, registryType, values, answers) + : Promise.resolve() + : adapter + ? resolveLabels(adapter, kind, values, answers) + : Promise.resolve(); + return leg.catch(() => { /* label leg unavailable — the prettified machine name still reads */ - }), - ), + }); + }), ); const positions = valuesByKind.get('position'); - if (positions && positions.length > 0) { + if (adapter && positions && positions.length > 0) { await resolveStaffing(adapter, positions, answers).catch(() => { /* staffing unknown — NOT the same claim as "the seat is empty" */ }); @@ -216,6 +276,18 @@ async function loadSlate(adapter: any, refs: string[]): Promise { */ export function useApproverDirectory(refs: readonly string[]): ApproverDirectory { const adapter = useAdapter() as any; + // The console's metadata store, or `null` outside a `` — + // the reader of the registry label leg. Read raw rather than through + // `useMetadata()`, whose no-provider fallback answers every type with `[]`, + // and held in a ref: the store's context value is rebuilt whenever any type + // it caches settles, and no effect may key on that identity (AGENTS.md #10). + const metadataStore = useContext(MetadataCtx); + const registryRef = useRef(metadataStore); + // Synced in an effect, not during render; declared before the load effect + // below, so it runs first in the same commit. + useEffect(() => { + registryRef.current = metadataStore; + }); // Sorted + de-duplicated so a re-render that reorders the slate does not // re-run the effect, and so the in-flight key is stable across callers. const wanted = useMemo(() => [...new Set(refs)].filter(Boolean).sort(), [refs]); @@ -229,13 +301,14 @@ export function useApproverDirectory(refs: readonly string[]): ApproverDirectory }, []); useEffect(() => { - if (!adapter || wanted.length === 0) return; + const registry = registryRef.current; + if ((!adapter && !registry) || wanted.length === 0) return; const missing = wanted.filter((r) => !CACHE.has(r)); if (missing.length === 0) return; const batch = missing.join('|'); let pending = INFLIGHT.get(batch); if (!pending) { - pending = loadSlate(adapter, missing).finally(() => INFLIGHT.delete(batch)); + pending = loadSlate(adapter, registry, missing).finally(() => INFLIGHT.delete(batch)); INFLIGHT.set(batch, pending); } void pending.then(() => { if (alive.current) bump((n) => n + 1); }); diff --git a/packages/app-shell/src/utils/approverIdentity.registryBinding-7611.test.tsx b/packages/app-shell/src/utils/approverIdentity.registryBinding-7611.test.tsx new file mode 100644 index 0000000000..73a9bd92dc --- /dev/null +++ b/packages/app-shell/src/utils/approverIdentity.registryBinding-7611.test.tsx @@ -0,0 +1,151 @@ +/** + * 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. + */ + +/** + * objectui#7611 Q3b (ruled objectstack#22865 Q3b → B) — the record page's + * approval panel and the override dialog NAME a position approver when the + * bundled spec binds `position` to the registry, `{ source: 'registry', type }` + * (objectstack#22710). + * + * The console objectstack ships is bundled with objectstack's own spec + * injected, so its `APPROVER_VALUE_SOURCES.position` is that registry member. + * `approverIdentity` derived its bindings from the `data` member only, so + * `position` dropped out of every arm: `formatIdentity('position:manager')` + * fell through to the 14-character truncation and read `positi…ager`, and + * `useApproverDirectory` asked nothing about it. This file mocks the spec into + * the injected shape — the projection is the bundled spec's own object, so this + * is what the shipped bundle evaluates. + * + * Pinned, per arm: + * - registry: the machine name is prettified, never truncated; the ref is + * handed to the directory; its label comes from the registry item read + * through the console's metadata store (`ensureType('position')`), with no + * read of the `sys_position` mirror; staffing still comes from the junction; + * - record: a `team:` ref still resolves against `sys_team` through the + * adapter, and an opaque id still truncates. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor, cleanup } from '@testing-library/react'; + +vi.mock('@objectstack/spec/automation', async (importOriginal) => { + const actual = await importOriginal>(); + const sources = actual.APPROVER_VALUE_SOURCES as Record; + return { + ...actual, + // objectstack#22710's projection of `position` — the injected spec's shape. + APPROVER_VALUE_SOURCES: { ...sources, position: { source: 'registry', type: 'position' } }, + }; +}); + +import { AdapterCtx, MetadataCtx } from '@object-ui/react'; +import { + APPROVER_DIRECTORY_BINDINGS, + APPROVER_REGISTRY_TYPES, + DEFAULT_APPROVER_COPY, + approverDisplay, + formatIdentity, + unresolvedApproverRefs, +} from './approverIdentity'; +import { useApproverDirectory, resetApproverDirectoryCache } from '../hooks/useApproverDirectory'; + +/** 32 hex characters — an opaque row id, as a `team:` approver carries. */ +const TEAM_ID = '0123456789abcdef0123456789abcdef'; + +beforeEach(() => { + resetApproverDirectoryCache(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('approverIdentity under a registry-binding spec (objectui#7611 Q3b)', () => { + it('reads position as registry-bound and keeps it off the record arm', () => { + expect(APPROVER_REGISTRY_TYPES).toEqual({ position: 'position' }); + expect(APPROVER_DIRECTORY_BINDINGS.position).toBeUndefined(); + expect(APPROVER_DIRECTORY_BINDINGS.team).toMatchObject({ object: 'sys_team', valueField: 'id' }); + }); + + it('prettifies a position approver instead of truncating it', () => { + expect(formatIdentity('position:manager')).toBe('Manager'); + expect(formatIdentity('position:sales_manager')).toBe('Sales Manager'); + }); + + it('still truncates an opaque id on the record arm', () => { + expect(formatIdentity(`team:${TEAM_ID}`)).toBe('team:0…cdef'); + }); + + it('hands a server-unresolved position approver to the directory', () => { + const record = { + pending_approvers: ['position:manager', `team:${TEAM_ID}`, 'u_1'], + pending_approver_names: { u_1: 'Ada' }, + }; + expect(unresolvedApproverRefs(record)).toEqual(['position:manager', `team:${TEAM_ID}`]); + }); + + it('composes the registry label first, the prettified name second', () => { + expect( + approverDisplay('position:manager', { resolved: { label: 'Line Manager' }, copy: DEFAULT_APPROVER_COPY }).label, + ).toBe('Line Manager'); + expect(approverDisplay('position:manager', { copy: DEFAULT_APPROVER_COPY }).label).toBe('Manager'); + }); +}); + +describe('useApproverDirectory under a registry-binding spec (objectui#7611 Q3b)', () => { + function harness() { + const adapter = { + find: vi.fn(async (object: string) => { + if (object === 'sys_user_position') return { data: [{ position: 'manager', user_id: 'u_1' }] }; + if (object === 'sys_user') return { data: [{ id: 'u_1', full_name: 'Ada Holder' }] }; + if (object === 'sys_team') return { data: [{ id: TEAM_ID, label: 'Platform Team' }] }; + return { data: [] }; + }), + }; + const ensureType = vi.fn(async (type: string) => + type === 'position' + ? [ + { name: 'manager', label: 'Line Manager' }, + { name: 'exec', label: 'Executive' }, + ] + : [], + ); + const store = { ensureType } as unknown as React.ContextType; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + return { adapter, ensureType, wrapper }; + } + + it('labels a position from the registry item, never from the sys_position mirror', async () => { + const { adapter, ensureType, wrapper } = harness(); + const { result } = renderHook(() => useApproverDirectory(['position:manager']), { wrapper }); + await waitFor(() => expect(result.current['position:manager']).toBeDefined()); + + expect(result.current['position:manager']).toEqual({ label: 'Line Manager', holders: ['Ada Holder'] }); + expect(ensureType).toHaveBeenCalledWith('position'); + const objects = adapter.find.mock.calls.map(([object]) => object); + expect(objects).not.toContain('sys_position'); + // Staffing still reads the junction, which stores the position's name. + expect(objects).toContain('sys_user_position'); + }); + + it('keeps the record arm on the adapter', async () => { + const { adapter, ensureType, wrapper } = harness(); + const ref = `team:${TEAM_ID}`; + const { result } = renderHook(() => useApproverDirectory([ref]), { wrapper }); + await waitFor(() => expect(result.current[ref]).toBeDefined()); + + expect(result.current[ref]).toEqual({ label: 'Platform Team' }); + expect(adapter.find.mock.calls.map(([object]) => object)).toEqual(['sys_team']); + expect(ensureType).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-shell/src/utils/approverIdentity.ts b/packages/app-shell/src/utils/approverIdentity.ts index dff62f0e2b..58c88a1531 100644 --- a/packages/app-shell/src/utils/approverIdentity.ts +++ b/packages/app-shell/src/utils/approverIdentity.ts @@ -35,11 +35,15 @@ * 1. **The server's own resolution.** `pending_approver_names[ref]` is the * producer's id -> name mapping. When it answers, nothing here fires and no * request is made. This module never overrides it. - * 2. **The directory record.** A ref whose kind the spec declares as - * `source: 'data'` names a row in a system directory object - * ({@link APPROVER_DIRECTORY_BINDINGS}); its display column is the label a - * person recognizes (`sys_position.label` -> `Sales Manager` / `销售经理`). - * `useApproverDirectory` fetches those; this module only composes them. + * 2. **The directory record or the registry item.** A ref whose kind the + * spec declares as `source: 'data'` names a row in a system directory + * object ({@link APPROVER_DIRECTORY_BINDINGS}); its display column is the + * label a person recognizes (`sys_team.label` -> `Platform Team`). A ref + * whose kind the spec declares as `source: 'registry'` names a declared + * metadata item by its machine name ({@link APPROVER_REGISTRY_TYPES}, + * objectui#7611 Q3b); the item's `label` is the readable name + * (`position` `sales_manager` -> `Sales Manager` / `销售经理`). + * `useApproverDirectory` fetches both; this module only composes them. * 3. **The machine name, prettified.** With no adapter and no directory row, * `sales_manager` -> `Sales Manager` still beats `positi…ager`, and it is * derived rather than guessed. @@ -62,6 +66,9 @@ */ import { APPROVER_VALUE_SOURCES } from '@objectstack/spec/automation'; +import { APPROVER_REGISTRY_TYPES, PUBLISHED_APPROVER_VALUE_SOURCES } from './approverValueSources.js'; + +export { APPROVER_REGISTRY_TYPES }; /** A parsed `:` approver reference. */ export interface ApproverRef { @@ -119,7 +126,7 @@ export const PERSON_DISPLAY_FIELDS: readonly string[] = DISPLAY_FIELDS_BY_OBJECT */ export const APPROVER_DIRECTORY_BINDINGS: Record = Object.fromEntries( - Object.entries(APPROVER_VALUE_SOURCES).flatMap(([kind, source]) => { + Object.entries(PUBLISHED_APPROVER_VALUE_SOURCES).flatMap(([kind, source]) => { if (!source || source.source !== 'data') return []; const object = String(source.object); return [[ @@ -136,6 +143,28 @@ export const APPROVER_DIRECTORY_BINDINGS: Record:` into its parts, or `null` when the string is not an * approver reference at all. @@ -169,8 +198,9 @@ export function prettifyMachineName(raw: string | null | undefined): string { /** * Render an actor/approver identifier in a friendly form (mirrors the Approval - * Center): emails as-is, `role:` labeled, a directory reference whose - * binding commits a machine NAME prettified, and opaque 16+ char ids + * Center): emails as-is, `role:` labeled, a reference whose binding + * commits a machine NAME ({@link approverValueIsName} — a directory `name` + * column, or a registry item) prettified, and opaque 16+ char ids * middle-truncated. * * Moved here from `RecordApprovalsPanel` in `77f846a8b` so the panel, the @@ -185,7 +215,7 @@ export function formatIdentity(id: string | null | undefined): string { if (id.includes('@')) return id; if (id.startsWith('role:')) return `Role: ${id.slice(5)}`; const ref = parseApproverRef(id); - if (ref && APPROVER_DIRECTORY_BINDINGS[ref.kind]?.valueField === 'name') { + if (ref && approverValueIsName(ref.kind)) { return prettifyMachineName(ref.value); } if (id.length > 14) return `${id.slice(0, 6)}…${id.slice(-4)}`; @@ -216,7 +246,7 @@ export function unresolvedApproverRefs(record: unknown): string[] { const served = names[raw]; if (typeof served === 'string' && served) continue; const ref = parseApproverRef(raw); - if (!ref || !APPROVER_DIRECTORY_BINDINGS[ref.kind]) continue; + if (!ref || !approverKindIsResolvable(ref.kind)) continue; if (!out.includes(raw)) out.push(raw); } return out; @@ -264,10 +294,11 @@ export const MAX_INLINE_HOLDERS = 3; * Compose the one string a person reads for one pending approver. * * `serverName` first (the producer resolved it — never second-guess that), - * then the directory row's label, then the prettified machine name. Prettifying - * is restricted to references whose binding commits a NAME: `position` stores - * `sys_position.name` because the engine routes by machine name, so - * `sales_manager` prettifies into real prose. The id-valued kinds + * then the directory row's or registry item's label, then the prettified + * machine name. Prettifying is restricted to references whose binding commits a + * NAME ({@link approverValueIsName}): a position is referenced by machine name + * because the engine routes by it, so `sales_manager` prettifies into real + * prose. The id-valued kinds * (`user`/`team`/`department`) carry opaque row ids that prettify into * nonsense — those keep the caller's existing formatting, which is the right * treatment for an id and is not what `77f846a8b` fixes. @@ -282,11 +313,10 @@ export function approverDisplay( ): ApproverDisplay { const { serverName, resolved, copy } = opts; const ref = parseApproverRef(raw); - const binding = ref ? APPROVER_DIRECTORY_BINDINGS[ref.kind] : undefined; const resolvedLabel = resolved?.label?.trim() || ''; const serverLabel = typeof serverName === 'string' ? serverName.trim() : ''; - const derived = ref && binding?.valueField === 'name' + const derived = ref && approverValueIsName(ref.kind) ? prettifyMachineName(ref.value) : ''; diff --git a/packages/app-shell/src/utils/approverValueSources.test.ts b/packages/app-shell/src/utils/approverValueSources.test.ts new file mode 100644 index 0000000000..585eed188f --- /dev/null +++ b/packages/app-shell/src/utils/approverValueSources.test.ts @@ -0,0 +1,78 @@ +/** + * 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. + */ + +/** + * approverValueSources — reading the spec's registry binding, + * `{ source: 'registry', type }` (objectstack#22710), structurally + * (objectui#7611 Q3b). + * + * The readers' behaviour under that binding is pinned where it renders: + * `approverIdentity.registryBinding-7611.test.tsx` and the two + * `FlowReferenceField.*-7611.test.tsx` files. This file pins the reading + * itself, and carries the reminder the ruling asks for: the type loosening in + * `approverValueSources.ts` goes when objectui moves to spec 18. + */ + +import { describe, it, expect } from 'vitest'; +import { APPROVER_VALUE_SOURCES } from '@objectstack/spec/automation'; +import { + APPROVER_REGISTRY_TYPES, + PUBLISHED_APPROVER_VALUE_SOURCES, + registryTypesOf, + registryValueSourceOf, +} from './approverValueSources'; + +describe('registryValueSourceOf (objectui#7611 Q3b)', () => { + it('reads the registry member', () => { + expect(registryValueSourceOf({ source: 'registry', type: 'position' })).toEqual({ + source: 'registry', + type: 'position', + }); + }); + + it('refuses a registry entry with no usable type, and every other member', () => { + expect(registryValueSourceOf({ source: 'registry' })).toBeUndefined(); + expect(registryValueSourceOf({ source: 'registry', type: '' })).toBeUndefined(); + expect(registryValueSourceOf({ source: 'data', object: 'sys_position', valueField: 'name' })).toBeUndefined(); + expect(registryValueSourceOf({ source: 'enum', values: ['owner'] })).toBeUndefined(); + expect(registryValueSourceOf(undefined)).toBeUndefined(); + }); +}); + +describe('registryTypesOf (objectui#7611 Q3b)', () => { + it('maps every registry-bound kind to its type and nothing else', () => { + // The shape objectstack#22710 publishes: `position` on the registry, the + // directory kinds on data rows. + expect( + registryTypesOf({ + user: { source: 'data', object: 'sys_user', valueField: 'id' }, + position: { source: 'registry', type: 'position' }, + team: { source: 'data', object: 'sys_team', valueField: 'id' }, + manager: { source: 'auto' }, + }), + ).toEqual({ position: 'position' }); + }); +}); + +describe('the spec-17 loosening is still needed (objectui#7611 Q3b)', () => { + it('reads the bundled spec object itself, unchanged', () => { + expect(PUBLISHED_APPROVER_VALUE_SOURCES).toBe(APPROVER_VALUE_SOURCES); + }); + + it('the installed spec binds nothing to the registry yet', () => { + // When this goes red, objectui has moved to a spec that declares the + // registry member itself (objectstack#22710, spec 18): remove the widening + // in approverValueSources.ts — read APPROVER_VALUE_SOURCES directly — and + // revisit `FlowReferenceField.specDerivation.test.ts`, whose `position` + // record-lookup expectation moves with it. + expect( + APPROVER_REGISTRY_TYPES, + 'the installed spec now publishes a registry binding: drop the spec-17 loosening in approverValueSources.ts', + ).toEqual({}); + }); +}); diff --git a/packages/app-shell/src/utils/approverValueSources.ts b/packages/app-shell/src/utils/approverValueSources.ts new file mode 100644 index 0000000000..36307767a0 --- /dev/null +++ b/packages/app-shell/src/utils/approverValueSources.ts @@ -0,0 +1,110 @@ +/** + * 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. + */ + +/** + * approverValueSources — the approver value bindings as the SERVER publishes + * them, read the same way by both approval readers: the flow designer's + * approver value picker (`FlowReferenceField`) and the console's approver + * labels (`approverIdentity` / `useApproverDirectory`). + * + * ## The registry arm (objectui#7611 Q3b, ruled objectstack#22865 Q3b → B) + * + * The spec's `ApproverValueBinding` is a union, and its projection + * `APPROVER_VALUE_SOURCES` is what reaches a renderer. Its `data` member names a + * ROW of a directory object (`user` / `team` / `department`). objectstack#22710 + * added a `registry` member, `{ source: 'registry', type }`: the value is the + * machine NAME of an item of metadata `type`, its candidates are listed by + * `GET /api/v1/meta/`, and a picker commits the item's `name` — a catalog + * item has no row id (ADR-0131 D4). `position` is bound that way. + * + * objectui's installed spec predates that member, yet two consoles already + * receive it: + * - a console bundled with the server's own spec injected + * (`OBJECTSTACK_SPEC_DIST`, the console objectstack ships), where + * `APPROVER_VALUE_SOURCES.position` IS the registry member; and + * - a console talking to such a server, whose approval node schema publishes + * the same projection as `xRef.sources`. + * Both readers kept only the `data` member and dropped the rest, so a position + * approver lost its designer picker and its readable label. + * + * Nothing here says which kinds are registry-backed: every answer is read off + * the published binding, so a kind the spec moves is followed with no edit + * here, and the `data` member is read exactly as before. + * + * ## LOOSENED FOR SPEC 17 — remove when objectui moves to spec 18 + * + * {@link ApproverValueSource} widens the installed spec's type with + * {@link RegistryValueSource} so a reader can test for the member at all. + * Once the installed spec declares it, the widening is redundant: read + * `APPROVER_VALUE_SOURCES` directly and drop {@link PUBLISHED_APPROVER_VALUE_SOURCES}. + * `approverValueSources.test.ts` turns red on that bump to say so. This module + * only READS what the server publishes; no authoring surface is touched, so + * `objectui validate` and the strict faces are unchanged. + */ + +import { APPROVER_VALUE_SOURCES } from '@objectstack/spec/automation'; + +/** + * The registry member of the spec's approver value binding (objectstack#22710): + * the value is the machine name of an item of metadata `type`. + */ +export interface RegistryValueSource { + source: 'registry'; + /** The metadata type to list — singular, as the meta API spells it (`position`). */ + type: string; +} + +/** + * One entry of `APPROVER_VALUE_SOURCES`, widened with the registry member the + * installed spec's type predates. LOOSENED FOR SPEC 17 — see this module's + * docstring for when it goes. + */ +export type ApproverValueSource = + | (typeof APPROVER_VALUE_SOURCES)[keyof typeof APPROVER_VALUE_SOURCES] + | RegistryValueSource; + +/** + * `APPROVER_VALUE_SOURCES`, typed so the registry member is expressible. The + * VALUE is the bundled spec's own object, unchanged: under an injected spec it + * already carries the registry member, under the installed one it does not. + */ +export const PUBLISHED_APPROVER_VALUE_SOURCES: Readonly> = + APPROVER_VALUE_SOURCES; + +/** + * Read one binding as the registry member, or `undefined`. + * + * A registry entry with no usable `type` is not a registry entry: a picker that + * lists nothing would be the failure this binding exists to end, so the caller + * stays on whatever it would have done without it. + */ +export function registryValueSourceOf(raw: unknown): RegistryValueSource | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const s = raw as { source?: unknown; type?: unknown }; + return s.source === 'registry' && typeof s.type === 'string' && s.type + ? { source: 'registry', type: s.type } + : undefined; +} + +/** Approver kind → the metadata type its value names, for every registry-bound kind in `sources`. */ +export function registryTypesOf(sources: Readonly>): Record { + return Object.fromEntries( + Object.entries(sources).flatMap(([kind, source]) => { + const registry = registryValueSourceOf(source); + return registry ? [[kind, registry.type]] : []; + }), + ); +} + +/** + * The registry-bound approver kinds of the bundled spec, kind → metadata type. + * Empty under the installed spec 17.7.0; `{ position: 'position' }` under a + * spec carrying objectstack#22710. + */ +export const APPROVER_REGISTRY_TYPES: Readonly> = + registryTypesOf(PUBLISHED_APPROVER_VALUE_SOURCES); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index f7219b902a..d7c0cade4d 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1160,6 +1160,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.dataset.filterNeedsObject': 'Pick a base object to add filter conditions.', // Flow reference picker — ReferenceCombobox / FlowReferenceField (objectui#10586) 'engine.inspector.reference.pickFromRecords': 'Pick from records', + 'engine.inspector.reference.pickFromRegistry': 'Pick from declared items', 'engine.inspector.reference.enterManually': 'Enter value manually', 'engine.inspector.reference.managerPlaceholder': 'Resolved automatically', 'engine.inspector.reference.managerHint': "Resolved at runtime from the submitter's manager — no value needed.", @@ -4513,6 +4514,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.dataset.filterNeedsObject': '先选择基础对象,才能添加筛选条件。', // Flow reference picker — ReferenceCombobox / FlowReferenceField (objectui#10586) 'engine.inspector.reference.pickFromRecords': '从记录中选择', + 'engine.inspector.reference.pickFromRegistry': '从已声明的项中选择', 'engine.inspector.reference.enterManually': '手动输入值', 'engine.inspector.reference.managerPlaceholder': '自动解析', 'engine.inspector.reference.managerHint': '运行时根据提交人的上级自动解析 —— 无需填写。', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.injectedRegistryBinding-7611.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.injectedRegistryBinding-7611.test.tsx new file mode 100644 index 0000000000..5d345bacb5 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.injectedRegistryBinding-7611.test.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7611 Q3b — the designer's FALLBACK, under the spec the console + * objectstack ships is bundled with. + * + * objectstack builds its console with its own `packages/spec` injected + * (`OBJECTSTACK_SPEC_DIST`), and that spec binds `position` to the registry + * (objectstack#22710). Its `APPROVER_VALUE_SOURCES.position` is therefore + * `{ source: 'registry', type: 'position' }`, so the spec-derived + * `KIND_TO_RECORD_LOOKUP` carries no `position` — and with no published + * `xRef.sources` entry to read, the approver Value cell had nothing left but a + * plain text box. This file mocks the spec into that shape: the projection is + * the bundled spec's own object, so this is what the shipped bundle evaluates. + * + * Pinned: the fallback reads the bundled binding's registry member and renders + * the registry select, while the record kinds keep their lookups. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +const state = vi.hoisted(() => { + const metaList = vi.fn(async (_type: string) => [] as unknown[]); + return { metaList, metadataClient: { list: metaList } }; +}); + +vi.mock('@objectstack/spec/automation', async (importOriginal) => { + const actual = await importOriginal>(); + const sources = actual.APPROVER_VALUE_SOURCES as Record; + return { + ...actual, + // objectstack#22710's projection of `position` — the injected spec's shape. + APPROVER_VALUE_SOURCES: { ...sources, position: { source: 'registry', type: 'position' } }, + }; +}); +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ({ find: async () => ({ data: [] }) }), + // @object-ui/components wires this at module scope (related-count-store). + subscribeDataChanges: () => () => {}, +})); +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + LookupField: (props: { field?: { reference?: string } }) => ( +
+ ), +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [] }), +})); + +import { ReferenceCombobox, KIND_TO_RECORD_LOOKUP, KIND_TO_REGISTRY_TYPE } from './FlowReferenceField'; + +afterEach(() => { + cleanup(); + state.metaList.mockReset(); + state.metaList.mockImplementation(async () => []); +}); + +describe('the approver Value fallback under a registry-binding spec (objectui#7611 Q3b)', () => { + it('derives position into the registry table and out of the record table', () => { + expect(KIND_TO_REGISTRY_TYPE).toEqual({ position: 'position' }); + expect(KIND_TO_RECORD_LOOKUP.position).toBeUndefined(); + // The record arm is untouched. + expect(Object.keys(KIND_TO_RECORD_LOOKUP).sort()).toEqual(['department', 'team', 'user']); + }); + + it('renders the registry select for a position with no published source', async () => { + state.metaList.mockImplementation(async () => [{ name: 'manager', label: 'Manager' }]); + render(); + const cell = await screen.findByTestId('registry-pick'); + expect(cell.getAttribute('data-registry-type')).toBe('position'); + await vi.waitFor(() => expect(cell.getAttribute('data-mode')).toBe('select')); + expect(state.metaList).toHaveBeenCalledWith('position'); + expect(screen.getByRole('combobox').textContent).toContain('Manager (manager)'); + expect(screen.queryByTestId('record-lookup')).toBeNull(); + }); + + it('keeps the record lookup for a directory kind', () => { + render(); + expect(screen.getByTestId('record-lookup').getAttribute('data-object')).toBe('sys_team'); + expect(screen.queryByTestId('registry-pick')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.registrySource-7611.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.registrySource-7611.test.tsx new file mode 100644 index 0000000000..8d01906673 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.registrySource-7611.test.tsx @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7611 Q3b (ruled objectstack#22865 Q3b → B) — the approver Value + * picker reads the spec's REGISTRY binding, `{ source: 'registry', type }` + * (objectstack#22710), and lists that metadata type's items. + * + * A server on that spec publishes `xRef.sources.position` as the registry + * member. Before this, `json-schema-to-fields` dropped any source that was not + * `data` / `enum` / a bare marker, so the picker fell back to the installed + * spec's binding — a lookup over `sys_position` rows, which a fresh + * environment leaves empty — and the console objectstack ships (spec + * injected, so the fallback carries no `position` either) rendered a plain + * text box. + * + * Load-bearing behaviours, one pin each: + * 1. a published registry source survives `json-schema-to-fields`, and a + * registry source with no type is dropped like any unusable entry; + * 2. a published registry source renders a select over that type's items, + * read through the metadata client (`GET /api/v1/meta/`), and a + * choice commits the item's NAME — with no record lookup, even with an + * adapter present; + * 3. the record arm is unchanged: a published `data` source still renders + * the record lookup and never lists the registry; + * 4. never trap the author — a stored name the registry does not list is + * shown flagged, an empty registry leaves the text box, and a pencil + * toggle switches to manual entry. + * The no-published-source fallback under a spec that carries the registry + * member is pinned in `FlowReferenceField.injectedRegistryBinding-7611.test.tsx`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const state = vi.hoisted(() => { + const metaList = vi.fn(async (_type: string) => [] as unknown[]); + return { + adapter: null as unknown, + metaList, + // STABLE identity, like the real memoized client: `useMetadataListOptions` + // keys its effect on the client object. + metadataClient: { list: metaList }, + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => state.adapter, + // @object-ui/components wires this at module scope (related-count-store). + subscribeDataChanges: () => () => {}, +})); +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + LookupField: (props: { field?: { reference?: string; idField?: string } }) => ( +
+ ), +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [] }), +})); + +import { ReferenceCombobox, registryTypeFor, KIND_TO_REGISTRY_TYPE } from './FlowReferenceField'; +import { jsonSchemaToFlowFields } from './json-schema-to-fields'; +import type { RefValueSource } from './flow-node-config'; + +beforeAll(() => { + // Radix Select probes pointer-capture APIs the test DOM lacks. + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!Element.prototype[m]) { + // @ts-expect-error test shim + Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } +}); + +afterEach(() => { + cleanup(); + state.adapter = null; + state.metaList.mockReset(); + state.metaList.mockImplementation(async () => []); +}); + +/** The registry member, as a server carrying objectstack#22710 publishes `position`. */ +const REGISTRY: RefValueSource = { source: 'registry', type: 'position' }; + +/** Two declared positions, as `GET /api/v1/meta/position` serves them. */ +const POSITIONS = [ + { name: 'manager', label: 'Manager' }, + { name: 'exec', label: 'Executive' }, +]; + +function makeAdapter() { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => null), + getObjectSchema: vi.fn(async () => undefined), + }; +} + +function renderPosition( + source: RefValueSource | undefined, + props: Partial> = {}, +) { + return render( + , + ); +} + +describe('json-schema-to-fields carries the registry source (objectui#7611 Q3b)', () => { + function sourcesFor(sources: Record) { + const fields = jsonSchemaToFlowFields({ + type: 'object', + properties: { + approvers: { + type: 'array', + items: { + type: 'object', + properties: { + value: { + type: 'string', + xRef: { kindFrom: 'type', map: { position: 'position', user: 'user' }, sources }, + }, + }, + }, + }, + }, + })!; + return fields.find((f) => f.id === 'approvers')!.columns!.find((c) => c.key === 'value')!.ref!.sources; + } + + it('keeps a published registry source, beside the record arm', () => { + expect( + sourcesFor({ + position: { source: 'registry', type: 'position' }, + user: { source: 'data', object: 'sys_user', valueField: 'id' }, + }), + ).toEqual({ + position: { source: 'registry', type: 'position' }, + user: { source: 'data', object: 'sys_user', valueField: 'id' }, + }); + }); + + it('drops a registry source with no type, like any entry that could not drive a picker', () => { + expect( + sourcesFor({ + position: { source: 'registry' }, + user: { source: 'data', object: 'sys_user', valueField: 'id' }, + }), + ).toEqual({ user: { source: 'data', object: 'sys_user', valueField: 'id' } }); + }); +}); + +describe('registryTypeFor — the published binding decides (objectui#7611 Q3b)', () => { + it('answers the published registry type', () => { + expect(registryTypeFor({ kind: 'position', source: REGISTRY })).toBe('position'); + }); + + it('never answers for a published record binding — the record arm is unchanged', () => { + expect( + registryTypeFor({ kind: 'position', source: { source: 'data', object: 'sys_position', valueField: 'name' } }), + ).toBeUndefined(); + expect(registryTypeFor({ kind: 'org-membership-level', source: { source: 'enum', values: ['owner'] } })).toBeUndefined(); + }); + + it('falls back to the bundled spec only when nothing is published', () => { + expect(registryTypeFor({ kind: 'position' })).toBe(KIND_TO_REGISTRY_TYPE.position); + expect(registryTypeFor(undefined)).toBeUndefined(); + }); +}); + +describe('ReferenceCombobox — a registry-bound approver (objectui#7611 Q3b)', () => { + it('lists the registry type and commits the chosen item’s NAME, never a record lookup', async () => { + state.adapter = makeAdapter(); + state.metaList.mockImplementation(async () => POSITIONS); + const onSelect = vi.fn(); + renderPosition(REGISTRY, { onSelect }); + + const cell = await screen.findByTestId('registry-pick'); + await vi.waitFor(() => expect(cell.getAttribute('data-mode')).toBe('select')); + expect(state.metaList).toHaveBeenCalledWith('position'); + expect(screen.queryByTestId('record-lookup')).toBeNull(); + + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(await screen.findByRole('option', { name: 'Executive (exec)' })); + expect(onSelect).toHaveBeenCalledWith('exec'); + }); + + it('shows a stored name the registry does not list, flagged rather than blanked', async () => { + state.metaList.mockImplementation(async () => POSITIONS); + renderPosition(REGISTRY, { value: 'retired_role' }); + const cell = await screen.findByTestId('registry-pick'); + await vi.waitFor(() => expect(cell.getAttribute('data-mode')).toBe('select')); + expect(screen.getByRole('combobox').textContent).toContain('retired_role (not found)'); + }); + + it('leaves the text box when the registry lists nothing', async () => { + const onCommit = vi.fn(); + renderPosition(REGISTRY, { onCommit }); + const cell = await screen.findByTestId('registry-pick'); + await vi.waitFor(() => expect(screen.getByRole('textbox')).not.toBeDisabled()); + expect(cell.getAttribute('data-mode')).toBe('text'); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'manager' } }); + expect(onCommit).toHaveBeenCalledWith('manager'); + }); + + it('switches to manual entry and back', async () => { + state.metaList.mockImplementation(async () => POSITIONS); + const onCommit = vi.fn(); + renderPosition(REGISTRY, { onCommit }); + const cell = await screen.findByTestId('registry-pick'); + await vi.waitFor(() => expect(cell.getAttribute('data-mode')).toBe('select')); + + fireEvent.click(screen.getByTitle('Enter value manually')); + expect(screen.getByTestId('registry-pick').getAttribute('data-mode')).toBe('text'); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'not_declared_yet' } }); + expect(onCommit).toHaveBeenCalledWith('not_declared_yet'); + + fireEvent.click(screen.getByTitle('Pick from declared items')); + expect(screen.getByTestId('registry-pick').getAttribute('data-mode')).toBe('select'); + }); +}); + +describe('ReferenceCombobox — the record arm is unchanged (objectui#7611 Q3b)', () => { + it('a published data source still renders the record lookup and lists no registry', () => { + state.adapter = makeAdapter(); + renderPosition({ source: 'data', object: 'sys_position', valueField: 'name' }); + const cell = screen.getByTestId('record-lookup'); + expect(cell.getAttribute('data-object')).toBe('sys_position'); + expect(cell.getAttribute('data-value-field')).toBe('name'); + expect(screen.queryByTestId('registry-pick')).toBeNull(); + expect(state.metaList).not.toHaveBeenCalledWith('position'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index a32bf8ac6c..55314ebf16 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -18,7 +18,11 @@ * `position`) render a record lookup against the data API (with a manual-entry * escape hatch, so the never-trap rule still holds), `org-membership-level` is * a strict select over a closed enum, `manager` is auto-resolved (disabled - * cell), and `queue` warns that the runtime doesn't resolve it. + * cell), and `queue` warns that the runtime doesn't resolve it. A kind whose + * published binding is the REGISTRY instead (`{ source: 'registry', type }`, + * objectstack#22710 — `position` on a server carrying it) renders a select over + * that metadata type's items and commits the item's name (objectui#7611 Q3b), + * with the same manual-entry escape hatch. * * An `object` reference renders the shared {@link ObjectPicker} instead * (objectui#11783): still suggest-but-allow-anything, but the value is committed @@ -53,6 +57,7 @@ import { useObjectFields } from '../previews/useObjectFields.js'; import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js'; import { flagUnknownValue, RequiredMarker } from './_shared.js'; import { ObjectPicker } from './ObjectPicker.js'; +import { APPROVER_REGISTRY_TYPES } from '../../../utils/approverValueSources.js'; /** Context the reference picker needs to resolve dynamic option sources. */ export interface FlowReferenceContext { @@ -315,6 +320,33 @@ export function recordLookupFor(resolved: ResolvedRef | undefined): RecordLookup }; } +/** + * The older-server fallback for a REGISTRY-bound kind (objectui#7611 Q3b), + * derived from the bundled spec exactly as {@link KIND_TO_RECORD_LOOKUP} is: + * empty under the installed spec, `{ position: 'position' }` when the bundle + * carries objectstack#22710 (the console objectstack ships injects it). Never a + * list written here — which kinds are registry-backed is the spec's answer. + */ +export const KIND_TO_REGISTRY_TYPE: Readonly>> = APPROVER_REGISTRY_TYPES; + +/** + * The metadata type a resolved reference lists its candidates from, when its + * binding is the REGISTRY (`{ source: 'registry', type }`, objectstack#22710), + * else `undefined`. + * + * The same precedence as {@link recordLookupFor}: the schema's published + * `xRef.sources` entry wins — it is the server's own binding — and a kind the + * server publishes as anything else (a `data` row lookup above all, which keeps + * the record arm exactly as it was) never reaches the registry. Only with no + * published source does the spec-derived {@link KIND_TO_REGISTRY_TYPE} answer. + */ +export function registryTypeFor(resolved: ResolvedRef | undefined): string | undefined { + if (!resolved) return undefined; + const published = resolved.source; + if (published) return published.source === 'registry' ? published.type : undefined; + return KIND_TO_REGISTRY_TYPE[resolved.kind]; +} + /** Read `node.config[key]` as a non-empty string, else undefined. */ function configString(node: Record | null | undefined, key: string): string | undefined { const cfg = node?.config; @@ -613,6 +645,106 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, ); } +/** + * A single-select REGISTRY picker cell (objectui#7611 Q3b) for a reference + * whose binding is `{ source: 'registry', type }`: the value is the machine + * NAME of an item of metadata `type`, so the candidates are that type's items — + * listed through the designer's metadata client, `GET /api/v1/meta/`, + * which serves the code-declared and the environment-authored items alike + * (ADR-0131 D2) — and a choice commits the item's `name`, never anything else. + * + * "Never trap the author" holds as it does for {@link RecordLookupCell}: a + * pencil toggle switches to manual entry, and a registry that lists nothing + * (none declared yet, a failed read) leaves the plain text box rather than an + * empty select. A stored name the listed items do not carry still renders, + * flagged `(not found)`, so opening an old row never silently blanks it. + */ +function RegistryPickCell({ type, value, onPick, onCommit, onBlur, disabled, placeholder, locale }: { + /** The metadata type whose items are the candidates. */ + type: string; + value: unknown; + /** Immediate commit for a choice (there is no blur to flush on). */ + onPick: (value: string) => void; + /** Keystroke-level commit for the manual-entry input (flushed via onBlur). */ + onCommit: (value: unknown) => void; + onBlur?: () => void; + disabled?: boolean; + placeholder?: string; + /** The designer locale the toggle buttons and the flag are worded in. */ + locale: SupportedLocale; +}) { + const { options, loading } = useMetadataListOptions(type); + const [manual, setManual] = React.useState(false); + const current = value != null ? String(value) : ''; + const listed = !loading && options.length > 0; + + if (manual || !listed) { + return ( +
+ onCommit(e.target.value)} + onBlur={onBlur} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + placeholder={loading ? t('engine.form.loadingOptions', locale) : placeholder} + disabled={disabled || loading} + className="h-8 flex-1 text-sm" + /> + {listed && ( + + )} +
+ ); + } + + const shown = current && !options.some((o) => o.value === current) + ? [...options, { value: current, label: flagUnknownValue(current, t('engine.form.notFound', locale), locale) }] + : options; + return ( +
+
+ +
+ +
+ ); +} + export interface ReferenceComboboxProps { /** The resolved concrete reference, or undefined → plain free text. */ resolved: ResolvedRef | undefined; @@ -643,6 +775,7 @@ export interface ReferenceComboboxProps { * The bare reference combobox — suggestions for `resolved.kind`, always * free-text editable, except for the kinds with a stronger contract: * directory-backed kinds render a record lookup ({@link RecordLookupCell}), + * registry-bound kinds a select over the registry ({@link RegistryPickCell}), * `org-membership-level` a strict select (closed enum), `manager` a disabled * auto-resolved cell, and `queue` free text with a not-supported warning * (objectstack-ai/objectstack#3508). Hooks are called unconditionally (kind-gated args) so the @@ -730,6 +863,25 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, ); } + // Registry-bound kinds → a select over the metadata type the binding names + // (objectui#7611 Q3b). Read off the published binding like the lookup above, + // so this package never decides which kinds live in the registry. + const registryType = registryTypeFor(resolved); + if (registryType) { + return ( + + ); + } + // Closed enum → strict select, never free text: `sales_manager` typed into // a membership-tier box matches nobody at runtime. The vocabulary comes from // the SERVER when it publishes one and from the spec-derived fallback diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index d752d06b6d..ea1a47377e 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -31,6 +31,7 @@ import type { FlowTriggerKind } from '@objectstack/spec/automation'; import type { DeclaredConfigKey } from './json-schema-to-fields.js'; +import type { RegistryValueSource } from '../../../utils/approverValueSources.js'; import { flowFieldZh, isZhLocale } from '../i18n.js'; export type FlowConfigFieldKind = @@ -166,13 +167,18 @@ export interface FlowReferenceSpec { * value. Mirrors the spec's `APPROVER_VALUE_SOURCES` projection. * * `data` means the DATA API (`/api/v1/data/:object`) — named in deliberate - * contrast to `meta`, the registry this used to query by mistake. The other - * variants are not pickers at all: a closed `enum`, an `auto`-resolved value, a - * `trigger-field` name, a CEL `expression`, or an `unsupported` type the - * runtime never resolves. + * contrast to `meta`, the registry this used to query by mistake for ROWS. + * `registry` is the other half of that split (objectstack#22710, objectui#7611 + * Q3b): the value is the machine name of a declared item of metadata `type`, so + * its candidates ARE the registry, `GET /api/v1/meta/`, and the picker + * commits the item's `name` — `position` is bound this way (ADR-0131 D4). The + * other variants are not pickers at all: a closed `enum`, an `auto`-resolved + * value, a `trigger-field` name, a CEL `expression`, or an `unsupported` type + * the runtime never resolves. */ export type RefValueSource = | { source: 'data'; object: string; valueField: string } + | RegistryValueSource | { source: 'enum'; values: string[] } | { source: 'auto' | 'trigger-field' | 'expression' | 'unsupported' }; diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts index 2cc5b6958a..2d72993738 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts @@ -129,9 +129,15 @@ const REFERENCE_KINDS: ReadonlySet = new Set([ /** * Validate one `xRef.sources` entry. An entry the designer cannot act on is * DROPPED rather than half-trusted: a `data` source missing its object or - * committed column would produce a picker that queries nothing, which is the - * failure this annotation exists to end (framework#3508). A dropped entry - * simply leaves the consumer on its local fallback. + * committed column — or a `registry` source missing its metadata type — would + * produce a picker that queries nothing, which is the failure this annotation + * exists to end (framework#3508). A dropped entry simply leaves the consumer on + * its local fallback. + * + * `registry` (objectstack#22710, objectui#7611 Q3b) is read here, not dropped: + * a server on that spec binds `position` to it, and dropping it sent the + * picker back to the local fallback — under the installed spec a lookup over + * `sys_position` rows, a mirror a fresh environment leaves empty. */ function sourceOf(raw: unknown): RefValueSource | undefined { if (!raw || typeof raw !== 'object') return undefined; @@ -141,6 +147,9 @@ function sourceOf(raw: unknown): RefValueSource | undefined { ? { source: 'data', object: s.object, valueField: s.valueField } : undefined; } + if (s.source === 'registry') { + return typeof s.type === 'string' && s.type ? { source: 'registry', type: s.type } : undefined; + } if (s.source === 'enum') { const values = Array.isArray(s.values) ? s.values.filter((v): v is string => typeof v === 'string') : []; return values.length ? { source: 'enum', values } : undefined; From 61a2d3ed042ab17b10e5dfe7ac4eb475cf6f1f6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:06:45 +0000 Subject: [PATCH 2/3] docs(app-shell): the approver Value cell and the waiting-on chips follow the spec's binding (objectui#7611 Q3b) Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR Co-authored-by: Claude --- content/docs/guide/flow-designer.md | 8 ++++++++ packages/app-shell/README.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/content/docs/guide/flow-designer.md b/content/docs/guide/flow-designer.md index 489652bec3..4e8618016f 100644 --- a/content/docs/guide/flow-designer.md +++ b/content/docs/guide/flow-designer.md @@ -122,6 +122,14 @@ strings, numbers, or objects) becomes a **nested repeater** — so an engine-published nested-array config is editable inline rather than dropping to the Advanced JSON block. +An *Approval* node's approver **Value** cell takes its candidates from where the +schema's published binding for the row's **Type** says they live (`xRef.sources`): +a directory kind (`user`, `team`, `department`) is a lookup over its directory +object's records, and a kind bound to the metadata registry +(`{ source: 'registry', type }`, which is how `position` is bound on a server +carrying objectstack#22710) is a select over that type's declared items that +stores the item's name. Both offer a pencil toggle for typing a value by hand. + A **Decision** node's Branches editor defines each branch's label, CEL expression, **and target node** in one table: the **Target** column picks the downstream node, wiring (creating, retargeting, or detaching) the branch's diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 799ea548b1..768c4f035f 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -1006,6 +1006,14 @@ the record's requests (comments and attachments included), and an inline **Send reminder** button for the submitter. Records without requests carry no tab at all. +An approver the server leaves unnamed, such as a `position:` seat, is +named from where the spec's approver binding says its value lives: a +directory kind (`team`, `department`) by its row's label, and a kind bound to +the metadata registry (`{ source: 'registry', type }`, which is how a spec +carrying objectstack#22710 binds `position`) by the registry item's label, +read through the metadata store. Failing both, it reads as the prettified +machine name, never as the raw reference. + Visibility is gated by record READ access, not approver status — anyone who can open the record sees where its approval stands, without a trip to the Approval Center (a `setup`-app surface business roles typically cannot From 1da85463523c0885fe5936b882ec9bfdb788bbb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:31:25 +0000 Subject: [PATCH 3/3] docs(app-shell): the README names the prettified fallback only for references that carry a machine name (objectui#7611 Q3b) An id-valued directory kind (team, department) whose row label does not resolve keeps the caller's existing formatting; only a reference whose binding commits a name (a position, under either binding) is prettified. Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR Co-authored-by: Claude --- packages/app-shell/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 768c4f035f..991391a00e 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -1011,8 +1011,10 @@ named from where the spec's approver binding says its value lives: a directory kind (`team`, `department`) by its row's label, and a kind bound to the metadata registry (`{ source: 'registry', type }`, which is how a spec carrying objectstack#22710 binds `position`) by the registry item's label, -read through the metadata store. Failing both, it reads as the prettified -machine name, never as the raw reference. +read through the metadata store. When no label answers, a reference that +carries a machine name (a position, under either binding) reads as the +prettified name, never as the raw reference; an id-valued kind (`team`, +`department`) keeps the caller's existing formatting. Visibility is gated by record READ access, not approver status — anyone who can open the record sees where its approval stands, without a trip to the