From 8bd8a14dd40b09f01021b62c430cc4bbd52e4f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:06:02 +0000 Subject: [PATCH 1/4] fix(fields): the position-ref grant picker stops offering the audience anchors (objectui#12200) `position-ref` listed every position the registry serves, the `everyone` and `guest` audience anchors included, and the server refuses a `sys_user_position` row that names an anchor (ADR-0090 D5/D9). The picker now leaves out the spec's own `AUDIENCE_ANCHOR_POSITIONS`, read through a lazy `import('@objectstack/spec/identity')`: the console builds this widget into its eager `ui-components` chunk and that module is a lazy chunk of its own. The catalog is LOADED only once the anchor list has settled too, and a failure to read it is the FAILED arm, never an unfiltered list. A stored anchor still shows, by its label beside its name, as its own option and unflagged, since the registry does list it. `permission-ref` filters nothing and does not wait on the import. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- .../fields/src/widgets/RegistryRefField.tsx | 83 ++++++-- ...tryRefField.audienceAnchors-12200.test.tsx | 195 ++++++++++++++++++ ...egistryRefField.registryName-7611.test.tsx | 5 + 3 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx diff --git a/packages/fields/src/widgets/RegistryRefField.tsx b/packages/fields/src/widgets/RegistryRefField.tsx index dca4ab2d4d..556df04dfe 100644 --- a/packages/fields/src/widgets/RegistryRefField.tsx +++ b/packages/fields/src/widgets/RegistryRefField.tsx @@ -45,11 +45,20 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * - LOADING — the stored name in a disabled box; * - FAILED — a notice naming the failure and its cause, beside a text box that * stays enabled, so a failed catalog does not also block the edit; - * - LOADED — empty: the same text box as IDLE; otherwise a searchable picker. - * A stored name the catalog does not list stays selectable as its own - * option, flagged "not found", so it is neither dropped nor read as a - * listed item. The flag is a claim about the registry's answer, so it is - * made only once the catalog has answered. + * - LOADED — nothing a grant can name: the same text box as IDLE; otherwise + * a searchable picker. A stored name the catalog does not list stays + * selectable as its own option, flagged "not found", so it is neither + * dropped nor read as a listed item. The flag is a claim about the + * registry's answer, so it is made only once the catalog has answered. + * + * The picker OFFERS only what a grant can name (objectui#12200). A position + * grant cannot name an audience anchor — `everyone` or `guest` (ADR-0090 + * D5/D9), held implicitly by every authenticated or anonymous principal — and + * the server refuses a `sys_user_position` row that does. So `position-ref` + * leaves the anchors out of its options ({@link nonGrantableNames}). A stored + * anchor is still the row's value: it stays on screen as its own option, by its + * label beside its name, and it is not flagged "not found", because the + * registry does list it. * * ⛔ No arm writes anything but a name the person picked or typed: no label, * no id, no first-item default. @@ -90,11 +99,38 @@ function failureMessage(err: unknown): string { return typeof err === 'string' ? err : ''; } +const NO_EXCLUSIONS: ReadonlySet = new Set(); + +/** + * The names of a registry type that a grant cannot name, which the picker + * therefore does not offer. For `position`, the audience anchors: the list is + * the spec's own `AUDIENCE_ANCHOR_POSITIONS`, read and never copied, so an + * anchor the spec adds is left out with no change here. Every other type + * excludes nothing: a permission set named like an anchor is an ordinary set. + * + * Imported lazily, as the console's `audienceAnchorsOf` imports the same + * module: the console builds this widget into its eager `ui-components` chunk, + * and `@objectstack/spec/identity` is a lazy chunk of its own. + */ +async function nonGrantableNames(type: string): Promise> { + if (type !== 'position') return NO_EXCLUSIONS; + const { AUDIENCE_ANCHOR_POSITIONS } = await import('@objectstack/spec/identity'); + return new Set(AUDIENCE_ANCHOR_POSITIONS); +} + /** A settled answer, with the inputs it answers for. */ interface SettledCatalog { dataSource: unknown; type: string; state: RegistryCatalogState; + /** The listed names the picker does not offer ({@link nonGrantableNames}). */ + excluded: ReadonlySet; +} + +/** The catalog's arm, and the names of it a grant cannot name. */ +interface RegistryCatalog { + catalog: RegistryCatalogState; + excluded: ReadonlySet; } /** @@ -106,21 +142,30 @@ interface SettledCatalog { * answer for exactly these inputs has not settled is LOADING. So an answer to * an older data source or type can never be shown as the current one, and the * effect only ever writes a settled answer. + * + * The catalog is LOADED only once the names a grant cannot name have settled + * too, so the picker never offers an anchor it is about to withdraw. A failure + * to read them is the catalog's FAILED arm, never an unfiltered list. */ -function useRegistryCatalog(dataSource: unknown, type: string): RegistryCatalogState { +function useRegistryCatalog(dataSource: unknown, type: string): RegistryCatalog { const [settled, setSettled] = React.useState(null); React.useEffect(() => { const source = registryLister(dataSource); if (!source) return; let cancelled = false; - source.listMetadataItems(type).then( - (items) => { - if (!cancelled) setSettled({ dataSource, type, state: { status: 'loaded', items } }); + Promise.all([source.listMetadataItems(type), nonGrantableNames(type)]).then( + ([items, excluded]) => { + if (!cancelled) setSettled({ dataSource, type, state: { status: 'loaded', items }, excluded }); }, (err: unknown) => { if (!cancelled) { - setSettled({ dataSource, type, state: { status: 'error', message: failureMessage(err) } }); + setSettled({ + dataSource, + type, + state: { status: 'error', message: failureMessage(err) }, + excluded: NO_EXCLUSIONS, + }); } }, ); @@ -129,8 +174,10 @@ function useRegistryCatalog(dataSource: unknown, type: string): RegistryCatalogS }; }, [dataSource, type]); - if (!registryLister(dataSource)) return IDLE; - return settled && settled.dataSource === dataSource && settled.type === type ? settled.state : LOADING; + if (!registryLister(dataSource)) return { catalog: IDLE, excluded: NO_EXCLUSIONS }; + return settled && settled.dataSource === dataSource && settled.type === type + ? { catalog: settled.state, excluded: settled.excluded } + : { catalog: LOADING, excluded: NO_EXCLUSIONS }; } /** What one item reads as: its label beside its name, or the name alone. */ @@ -155,7 +202,7 @@ export function RegistryRefField({ const ctx = React.useContext(SchemaRendererContext); const { t } = useFieldTranslation(); const dataSource = props.dataSource ?? ctx?.dataSource ?? null; - const catalog = useRegistryCatalog(dataSource, registryType); + const { catalog, excluded } = useRegistryCatalog(dataSource, registryType); const current = typeof value === 'string' ? value : ''; const testId = `registry-ref-${registryType}`; @@ -225,11 +272,17 @@ export function RegistryRefField({ ); } - if (!listed || listed.length === 0) return typedName; + // What the picker offers: the listed items a grant can name. + const offered = (listed ?? []).filter((item) => !excluded.has(item.name)); + if (offered.length === 0) return typedName; - const options = [...listed] + const options = offered .sort((a, b) => a.name.localeCompare(b.name)) .map((item) => ({ value: item.name, label: itemText(item) })); + // A stored name the picker does not offer stays its own option, so it is + // neither dropped nor replaced: a listed name a grant cannot name by its + // label beside its name, a name the registry lacks flagged "not found". + if (entry && excluded.has(entry.name)) options.unshift({ value: current, label: itemText(entry) }); if (notFound) options.unshift({ value: current, label: notFoundText }); // DOM pass-through onto the combobox trigger, the widget's focusable control diff --git a/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx b/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx new file mode 100644 index 0000000000..0b8b715130 --- /dev/null +++ b/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx @@ -0,0 +1,195 @@ +/** + * 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. + */ + +/** + * `position-ref` is a grant picker, and a grant cannot name an audience anchor + * (objectui#12200). `everyone` and `guest` (ADR-0090 D5/D9) are held + * implicitly by every authenticated or anonymous principal, the server refuses + * a `sys_user_position` row that names one, and yet the position registry + * lists both beside the assignable positions. These pins hold: + * + * - the picker leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS`; + * - it offers nothing until that list has loaded, and a failure to load it is + * the FAILED arm, never an unfiltered list; + * - a stored anchor still shows, by its label beside its name, unflagged, and + * is not offered when the row holds another value; + * - `permission-ref` filters nothing, and does not wait on that list. + * + * The anchor list is the widget's lazy `import('@objectstack/spec/identity')`. + * Each test re-registers it with `vi.doMock` as the REAL module + * (`importOriginal`) behind a gate the test can hold shut or make fail, so the + * widget's next import runs that test's factory. The gate is not inert by + * construction: the LOADING and FAILED pins below fail when it is. + * + * The module-scope import of the same specifier keeps its cost in the import + * phase rather than inside a `findBy` window (AGENTS.md, test discipline). + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import React from 'react'; +import { act, render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec/identity'; +import { PositionRefField, PermissionRefField } from '../RegistryRefField'; + +/** What the next import of the anchor list waits on, or fails with. */ +const anchorImport = { + gate: null as Promise | null, + failure: null as Error | null, +}; + +/** The assignable positions, by label beside name, as the picker reads them. */ +const ASSIGNABLE = ['auditor', 'Sales Manager (sales_manager)', 'Sales Representative (sales_rep)']; + +const POSITIONS = [ + { name: 'sales_rep', label: 'Sales Representative' }, + { name: 'sales_manager', label: 'Sales Manager' }, + { name: 'auditor' }, + // The anchors, as the registry serves them: listed beside the positions. + { name: 'everyone', label: 'Everyone' }, + { name: 'guest', label: 'Guest' }, +]; + +function lister(items: Array<{ name: string; label?: string }>) { + return { listMetadataItems: vi.fn(async () => items), find: vi.fn() } as any; +} + +function mount( + Widget: typeof PositionRefField, + { dataSource, value = '', readonly = false }: { dataSource: unknown; value?: string; readonly?: boolean }, +) { + const onChange = vi.fn(); + render( + , + ); + return { onChange }; +} + +/** The option texts of the open picker, in order. */ +async function openOptions(): Promise { + fireEvent.click(await screen.findByRole('combobox')); + await screen.findAllByRole('option'); + return screen.getAllByRole('option').map((option) => option.textContent ?? ''); +} + +beforeEach(() => { + anchorImport.gate = null; + anchorImport.failure = null; + vi.doMock('@objectstack/spec/identity', async (importOriginal) => { + if (anchorImport.gate) await anchorImport.gate; + if (anchorImport.failure) throw anchorImport.failure; + return importOriginal(); + }); +}); + +afterEach(() => cleanup()); + +describe('position-ref leaves the audience anchors out of what it offers (objectui#12200)', () => { + it('offers the assignable positions and no anchor', async () => { + // The names the card asks for are the ones the spec publishes. + expect([...AUDIENCE_ANCHOR_POSITIONS].sort()).toEqual(['everyone', 'guest']); + + mount(PositionRefField, { dataSource: lister(POSITIONS) }); + + expect(await openOptions()).toEqual(ASSIGNABLE); + for (const anchor of AUDIENCE_ANCHOR_POSITIONS) { + expect(screen.queryByText(new RegExp(`\\(${anchor}\\)$`))).toBeNull(); + } + }); + + it('offers nothing until the anchor list has loaded, then the positions without the anchors', async () => { + let release!: () => void; + anchorImport.gate = new Promise((resolve) => { + release = resolve; + }); + const ds = lister(POSITIONS); + const { onChange } = mount(PositionRefField, { dataSource: ds, value: 'sales_rep' }); + + // The registry has answered, and every update its answer schedules is + // committed; the anchor list has not: still LOADING. + await waitFor(() => expect(ds.listMetadataItems).toHaveBeenCalledWith('position')); + await act(async () => { + await ds.listMetadataItems.mock.results[0].value; + }); + const box = screen.getByRole('textbox'); + expect(box).toBeDisabled(); + expect(box).toHaveValue('sales_rep'); + expect(screen.queryByRole('combobox')).toBeNull(); + expect(screen.queryByRole('option')).toBeNull(); + + release(); + expect(await openOptions()).toEqual(ASSIGNABLE); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('a failure to load the anchor list is the FAILED arm, never an unfiltered list', async () => { + anchorImport.failure = new Error('chunk load failed'); + const { onChange } = mount(PositionRefField, { dataSource: lister(POSITIONS), value: 'sales_rep' }); + + expect(await screen.findByTestId('registry-ref-position-load-failed')).toHaveAttribute('role', 'status'); + expect(screen.queryByRole('combobox')).toBeNull(); + const box = screen.getByRole('textbox'); + expect(box).toBeEnabled(); + expect(box).toHaveValue('sales_rep'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('a registry that lists only the anchors offers no picker: the typed-name box', async () => { + mount(PositionRefField, { dataSource: lister(POSITIONS.slice(3)) }); + + const box = await screen.findByRole('textbox'); + await waitFor(() => expect(box).toBeEnabled()); + expect(screen.queryByRole('combobox')).toBeNull(); + expect(screen.queryByTestId('registry-ref-position-load-failed')).toBeNull(); + }); +}); + +describe('a stored anchor still shows, and is not offered as a fresh choice (objectui#12200)', () => { + it('shows a stored `everyone` by its label beside its name, unflagged, and writes nothing', async () => { + const { onChange } = mount(PositionRefField, { dataSource: lister(POSITIONS), value: 'everyone' }); + + const trigger = await screen.findByRole('combobox'); + expect(trigger).toHaveTextContent('Everyone (everyone)'); + // The registry lists it, so it is not "not found". + expect(trigger).not.toHaveAttribute('data-not-found'); + expect(onChange).not.toHaveBeenCalled(); + + // Its own option, as the row's value; the other anchor is not offered. + expect(await openOptions()).toEqual(['Everyone (everyone)', ...ASSIGNABLE]); + }); + + it('read-only: a stored anchor reads as its label beside its name', async () => { + mount(PositionRefField, { dataSource: lister(POSITIONS), value: 'guest', readonly: true }); + + const shown = await screen.findByText('Guest (guest)'); + expect(shown).not.toHaveAttribute('data-not-found'); + }); +}); + +describe('permission-ref filters nothing (objectui#12200)', () => { + it('lists a permission set named like an anchor, without waiting on the anchor list', async () => { + // Held shut for the whole test: permission-ref must not depend on it. + anchorImport.gate = new Promise(() => {}); + const sets = lister([ + { name: 'everyone', label: 'Everyone Baseline' }, + { name: 'guest' }, + { name: 'sales_user', label: 'Sales User' }, + ]); + const { onChange } = mount(PermissionRefField, { dataSource: sets }); + + expect(await openOptions()).toEqual(['Everyone Baseline (everyone)', 'guest', 'Sales User (sales_user)']); + fireEvent.click(screen.getByText('Everyone Baseline (everyone)')); + expect(onChange).toHaveBeenCalledWith('everyone'); + }); +}); diff --git a/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx b/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx index eae2102c0e..e8c08e2512 100644 --- a/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx +++ b/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx @@ -28,6 +28,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import React from 'react'; import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; +// `position-ref`'s LOADED arm waits on the widget's dynamic import of this exact +// specifier, the audience anchors it leaves out (objectui#12200). Loaded here, +// its cost lands in the import phase rather than inside a `findBy` window +// (AGENTS.md, test discipline). +import '@objectstack/spec/identity'; import { PositionRefField, PermissionRefField } from '../RegistryRefField'; const POSITIONS = [ From 245337377579116cedb294eee5c8dfaddbeb2ea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:07:35 +0000 Subject: [PATCH 2/4] chore(changeset): declare the position-ref anchor filter as a @object-ui/fields patch (objectui#12200) Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- .changeset/12200-position-picker-anchors.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/12200-position-picker-anchors.md diff --git a/.changeset/12200-position-picker-anchors.md b/.changeset/12200-position-picker-anchors.md new file mode 100644 index 0000000000..e649156163 --- /dev/null +++ b/.changeset/12200-position-picker-anchors.md @@ -0,0 +1,5 @@ +--- +'@object-ui/fields': patch +--- + +The `position-ref` grant picker no longer offers the `everyone` and `guest` audience anchors (objectui#12200). Every authenticated or anonymous principal holds an anchor implicitly (ADR-0090 D5/D9), and the server refuses a `sys_user_position` row that names one, so a pick of either was refused on save. The picker leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS` from `@objectstack/spec/identity`, loaded lazily, and lists nothing until that list has loaded; a failure to load it shows the usual load-failure notice beside the text box, never an unfiltered list. A row that already stores an anchor still shows it, by its label beside its name, as its own option. `permission-ref` is unchanged: a permission set named like an anchor is listed as before. From aa16a9c080498aad111df1c901d3f37ffb282b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:25:03 +0000 Subject: [PATCH 3/4] fix(fields): read the audience anchors statically from the spec's root entry (objectui#12200) The lazy `import('@objectstack/spec/identity')` kept the eager closure flat, but `check:spec-floors`, which `changeset:publish` runs first, judges the `@objectstack/spec` floor from static imports only: it reported `@object-ui/fields` as reaching that subpath only where it cannot read a symbol (`opaque-subpath`), a finding on the publish lane. A static `/identity` import puts that module's chunk into the eager `ui-components` chunk and over its per-chunk ceiling. The root entry's constant is the one `@object-ui/auth` already loads eagerly, so a static import from there costs the eager closure next to nothing, and the floor is judged. The anchor set is now known on the first render, so the catalog arms are back to their pre-change shape: no LOADING hold and no FAILED arm for the anchor list. The 7611 suite's module-scope import of the identity subpath goes with it. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- .changeset/12200-position-picker-anchors.md | 2 +- .../fields/src/widgets/RegistryRefField.tsx | 56 +++++-------- ...tryRefField.audienceAnchors-12200.test.tsx | 82 +++---------------- ...egistryRefField.registryName-7611.test.tsx | 5 -- 4 files changed, 33 insertions(+), 112 deletions(-) diff --git a/.changeset/12200-position-picker-anchors.md b/.changeset/12200-position-picker-anchors.md index e649156163..26010bb05e 100644 --- a/.changeset/12200-position-picker-anchors.md +++ b/.changeset/12200-position-picker-anchors.md @@ -2,4 +2,4 @@ '@object-ui/fields': patch --- -The `position-ref` grant picker no longer offers the `everyone` and `guest` audience anchors (objectui#12200). Every authenticated or anonymous principal holds an anchor implicitly (ADR-0090 D5/D9), and the server refuses a `sys_user_position` row that names one, so a pick of either was refused on save. The picker leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS` from `@objectstack/spec/identity`, loaded lazily, and lists nothing until that list has loaded; a failure to load it shows the usual load-failure notice beside the text box, never an unfiltered list. A row that already stores an anchor still shows it, by its label beside its name, as its own option. `permission-ref` is unchanged: a permission set named like an anchor is listed as before. +The `position-ref` grant picker no longer offers the `everyone` and `guest` audience anchors (objectui#12200). Every authenticated or anonymous principal holds an anchor implicitly (ADR-0090 D5/D9), and the server refuses a `sys_user_position` row that names one, so a pick of either was refused on save. The picker leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS`, read from `@objectstack/spec`, so an anchor the spec adds is left out too. A registry that lists nothing but anchors gives the typed-name box, as an empty one does. A row that already stores an anchor still shows it, by its label beside its name, as its own option, unflagged. `permission-ref` is unchanged: a permission set named like an anchor is listed as before. diff --git a/packages/fields/src/widgets/RegistryRefField.tsx b/packages/fields/src/widgets/RegistryRefField.tsx index 556df04dfe..8035f93510 100644 --- a/packages/fields/src/widgets/RegistryRefField.tsx +++ b/packages/fields/src/widgets/RegistryRefField.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; import { Combobox, EmptyValue, Input, cn } from '@object-ui/components'; import { SchemaRendererContext } from '@object-ui/react'; +import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec'; import type { DataSource } from '@object-ui/types'; import type { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; @@ -99,6 +100,7 @@ function failureMessage(err: unknown): string { return typeof err === 'string' ? err : ''; } +const AUDIENCE_ANCHORS: ReadonlySet = new Set(AUDIENCE_ANCHOR_POSITIONS); const NO_EXCLUSIONS: ReadonlySet = new Set(); /** @@ -108,14 +110,18 @@ const NO_EXCLUSIONS: ReadonlySet = new Set(); * anchor the spec adds is left out with no change here. Every other type * excludes nothing: a permission set named like an anchor is an ordinary set. * - * Imported lazily, as the console's `audienceAnchorsOf` imports the same - * module: the console builds this widget into its eager `ui-components` chunk, - * and `@objectstack/spec/identity` is a lazy chunk of its own. + * Imported statically from the spec's ROOT entry, as `@object-ui/auth` imports + * `BUILTIN_MEMBERSHIP_ROLES`, and not from `@objectstack/spec/identity`. The + * console builds this widget into an eager chunk (`ui-components`), so a + * static `/identity` import would carry that subpath's whole module into every + * page load; `pnpm check:eager-closure` is the instrument that weighs it. A + * lazy `import()` keeps that module out of the eager closure, but out of + * `pnpm check:spec-floors` too: that gate judges the `@objectstack/spec` floor + * from static imports only, and on the publish lane it reports a subpath it + * cannot judge as a finding. */ -async function nonGrantableNames(type: string): Promise> { - if (type !== 'position') return NO_EXCLUSIONS; - const { AUDIENCE_ANCHOR_POSITIONS } = await import('@objectstack/spec/identity'); - return new Set(AUDIENCE_ANCHOR_POSITIONS); +function nonGrantableNames(type: string): ReadonlySet { + return type === 'position' ? AUDIENCE_ANCHORS : NO_EXCLUSIONS; } /** A settled answer, with the inputs it answers for. */ @@ -123,14 +129,6 @@ interface SettledCatalog { dataSource: unknown; type: string; state: RegistryCatalogState; - /** The listed names the picker does not offer ({@link nonGrantableNames}). */ - excluded: ReadonlySet; -} - -/** The catalog's arm, and the names of it a grant cannot name. */ -interface RegistryCatalog { - catalog: RegistryCatalogState; - excluded: ReadonlySet; } /** @@ -142,30 +140,21 @@ interface RegistryCatalog { * answer for exactly these inputs has not settled is LOADING. So an answer to * an older data source or type can never be shown as the current one, and the * effect only ever writes a settled answer. - * - * The catalog is LOADED only once the names a grant cannot name have settled - * too, so the picker never offers an anchor it is about to withdraw. A failure - * to read them is the catalog's FAILED arm, never an unfiltered list. */ -function useRegistryCatalog(dataSource: unknown, type: string): RegistryCatalog { +function useRegistryCatalog(dataSource: unknown, type: string): RegistryCatalogState { const [settled, setSettled] = React.useState(null); React.useEffect(() => { const source = registryLister(dataSource); if (!source) return; let cancelled = false; - Promise.all([source.listMetadataItems(type), nonGrantableNames(type)]).then( - ([items, excluded]) => { - if (!cancelled) setSettled({ dataSource, type, state: { status: 'loaded', items }, excluded }); + source.listMetadataItems(type).then( + (items) => { + if (!cancelled) setSettled({ dataSource, type, state: { status: 'loaded', items } }); }, (err: unknown) => { if (!cancelled) { - setSettled({ - dataSource, - type, - state: { status: 'error', message: failureMessage(err) }, - excluded: NO_EXCLUSIONS, - }); + setSettled({ dataSource, type, state: { status: 'error', message: failureMessage(err) } }); } }, ); @@ -174,10 +163,8 @@ function useRegistryCatalog(dataSource: unknown, type: string): RegistryCatalog }; }, [dataSource, type]); - if (!registryLister(dataSource)) return { catalog: IDLE, excluded: NO_EXCLUSIONS }; - return settled && settled.dataSource === dataSource && settled.type === type - ? { catalog: settled.state, excluded: settled.excluded } - : { catalog: LOADING, excluded: NO_EXCLUSIONS }; + if (!registryLister(dataSource)) return IDLE; + return settled && settled.dataSource === dataSource && settled.type === type ? settled.state : LOADING; } /** What one item reads as: its label beside its name, or the name alone. */ @@ -202,7 +189,8 @@ export function RegistryRefField({ const ctx = React.useContext(SchemaRendererContext); const { t } = useFieldTranslation(); const dataSource = props.dataSource ?? ctx?.dataSource ?? null; - const { catalog, excluded } = useRegistryCatalog(dataSource, registryType); + const catalog = useRegistryCatalog(dataSource, registryType); + const excluded = nonGrantableNames(registryType); const current = typeof value === 'string' ? value : ''; const testId = `registry-ref-${registryType}`; diff --git a/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx b/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx index 0b8b715130..a2d1f090fd 100644 --- a/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx +++ b/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx @@ -14,34 +14,19 @@ * lists both beside the assignable positions. These pins hold: * * - the picker leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS`; - * - it offers nothing until that list has loaded, and a failure to load it is - * the FAILED arm, never an unfiltered list; + * - a registry that lists nothing BUT anchors offers no picker; * - a stored anchor still shows, by its label beside its name, unflagged, and * is not offered when the row holds another value; - * - `permission-ref` filters nothing, and does not wait on that list. - * - * The anchor list is the widget's lazy `import('@objectstack/spec/identity')`. - * Each test re-registers it with `vi.doMock` as the REAL module - * (`importOriginal`) behind a gate the test can hold shut or make fail, so the - * widget's next import runs that test's factory. The gate is not inert by - * construction: the LOADING and FAILED pins below fail when it is. - * - * The module-scope import of the same specifier keeps its cost in the import - * phase rather than inside a `findBy` window (AGENTS.md, test discipline). + * - `permission-ref` filters nothing: a permission set named like an anchor is + * an ordinary set. */ -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import React from 'react'; -import { act, render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec/identity'; +import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec'; import { PositionRefField, PermissionRefField } from '../RegistryRefField'; -/** What the next import of the anchor list waits on, or fails with. */ -const anchorImport = { - gate: null as Promise | null, - failure: null as Error | null, -}; - /** The assignable positions, by label beside name, as the picker reads them. */ const ASSIGNABLE = ['auditor', 'Sales Manager (sales_manager)', 'Sales Representative (sales_rep)']; @@ -83,16 +68,6 @@ async function openOptions(): Promise { return screen.getAllByRole('option').map((option) => option.textContent ?? ''); } -beforeEach(() => { - anchorImport.gate = null; - anchorImport.failure = null; - vi.doMock('@objectstack/spec/identity', async (importOriginal) => { - if (anchorImport.gate) await anchorImport.gate; - if (anchorImport.failure) throw anchorImport.failure; - return importOriginal(); - }); -}); - afterEach(() => cleanup()); describe('position-ref leaves the audience anchors out of what it offers (objectui#12200)', () => { @@ -108,46 +83,11 @@ describe('position-ref leaves the audience anchors out of what it offers (object } }); - it('offers nothing until the anchor list has loaded, then the positions without the anchors', async () => { - let release!: () => void; - anchorImport.gate = new Promise((resolve) => { - release = resolve; - }); - const ds = lister(POSITIONS); - const { onChange } = mount(PositionRefField, { dataSource: ds, value: 'sales_rep' }); - - // The registry has answered, and every update its answer schedules is - // committed; the anchor list has not: still LOADING. - await waitFor(() => expect(ds.listMetadataItems).toHaveBeenCalledWith('position')); - await act(async () => { - await ds.listMetadataItems.mock.results[0].value; - }); - const box = screen.getByRole('textbox'); - expect(box).toBeDisabled(); - expect(box).toHaveValue('sales_rep'); - expect(screen.queryByRole('combobox')).toBeNull(); - expect(screen.queryByRole('option')).toBeNull(); - - release(); - expect(await openOptions()).toEqual(ASSIGNABLE); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('a failure to load the anchor list is the FAILED arm, never an unfiltered list', async () => { - anchorImport.failure = new Error('chunk load failed'); - const { onChange } = mount(PositionRefField, { dataSource: lister(POSITIONS), value: 'sales_rep' }); - - expect(await screen.findByTestId('registry-ref-position-load-failed')).toHaveAttribute('role', 'status'); - expect(screen.queryByRole('combobox')).toBeNull(); - const box = screen.getByRole('textbox'); - expect(box).toBeEnabled(); - expect(box).toHaveValue('sales_rep'); - expect(onChange).not.toHaveBeenCalled(); - }); - it('a registry that lists only the anchors offers no picker: the typed-name box', async () => { - mount(PositionRefField, { dataSource: lister(POSITIONS.slice(3)) }); + const ds = lister(POSITIONS.slice(3)); + mount(PositionRefField, { dataSource: ds }); + await waitFor(() => expect(ds.listMetadataItems).toHaveBeenCalledWith('position')); const box = await screen.findByRole('textbox'); await waitFor(() => expect(box).toBeEnabled()); expect(screen.queryByRole('combobox')).toBeNull(); @@ -178,9 +118,7 @@ describe('a stored anchor still shows, and is not offered as a fresh choice (obj }); describe('permission-ref filters nothing (objectui#12200)', () => { - it('lists a permission set named like an anchor, without waiting on the anchor list', async () => { - // Held shut for the whole test: permission-ref must not depend on it. - anchorImport.gate = new Promise(() => {}); + it('lists, and writes, a permission set named like an anchor', async () => { const sets = lister([ { name: 'everyone', label: 'Everyone Baseline' }, { name: 'guest' }, diff --git a/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx b/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx index e8c08e2512..eae2102c0e 100644 --- a/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx +++ b/packages/fields/src/widgets/__tests__/RegistryRefField.registryName-7611.test.tsx @@ -28,11 +28,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import React from 'react'; import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; -// `position-ref`'s LOADED arm waits on the widget's dynamic import of this exact -// specifier, the audience anchors it leaves out (objectui#12200). Loaded here, -// its cost lands in the import phase rather than inside a `findBy` window -// (AGENTS.md, test discipline). -import '@objectstack/spec/identity'; import { PositionRefField, PermissionRefField } from '../RegistryRefField'; const POSITIONS = [ From a9b00b0f4758fb24476bcefb9d8e8d663560c497 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:39:17 +0000 Subject: [PATCH 4/4] docs(fields): say that position-ref leaves out the audience anchors (objectui#12200) The fields guide and the @object-ui/fields README described the position-ref picker as listing the whole position registry. One sentence in each now says that it leaves out the spec's AUDIENCE_ANCHOR_POSITIONS (everyone, guest), because the server refuses an anchor as a grant target, and that a stored anchor still shows as its own option. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- content/docs/guide/fields.md | 5 ++++- packages/fields/README.md | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/content/docs/guide/fields.md b/content/docs/guide/fields.md index c147dccd00..2463f1f118 100644 --- a/content/docs/guide/fields.md +++ b/content/docs/guide/fields.md @@ -173,7 +173,10 @@ record form lists that registry instead of asking for a typed name: Like every `widget` value, the name is looked up as `field:`. Each item reads as its label beside its name, and the value written is the name alone: no label, no id. A stored name the registry does not list stays on screen, flagged -*not found*, so a renamed or removed item is visible rather than dropped. The +*not found*, so a renamed or removed item is visible rather than dropped. +`position-ref` leaves out the audience anchors, the spec's +`AUDIENCE_ANCHOR_POSITIONS` (`everyone`, `guest`), because the server refuses an +anchor as a grant target; a stored anchor still shows as its own option. The list comes from the data source's `listMetadataItems(type)`. When the adapter has none, the list is empty, or the load fails, the field is a text box that takes the typed name; a failure is shown with its cause, and the box stays diff --git a/packages/fields/README.md b/packages/fields/README.md index 1628768656..37223781c2 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -273,7 +273,10 @@ A `text` field that stores the machine name of one registry item declares `field:`). The widget lists that registry through the data source's `listMetadataItems(type)`, shows each item's label beside its name, and writes the name only. A stored name the loaded registry lacks stays, flagged *not -found*. When the adapter cannot list a registry, the list is empty, or the +found*. `position-ref` leaves out the audience anchors (the spec's +`AUDIENCE_ANCHOR_POSITIONS`: `everyone`, `guest`), because the server refuses +an anchor as a grant target; a stored anchor still shows as its own option. +When the adapter cannot list a registry, the list is empty, or the load fails, it is a text box that takes the typed name (a failure is shown with its cause). See [`content/docs/guide/fields.md`](../../content/docs/guide/fields.md).