diff --git a/.changeset/12200-position-picker-anchors.md b/.changeset/12200-position-picker-anchors.md new file mode 100644 index 0000000000..26010bb05e --- /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`, 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/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). diff --git a/packages/fields/src/widgets/RegistryRefField.tsx b/packages/fields/src/widgets/RegistryRefField.tsx index dca4ab2d4d..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'; @@ -45,11 +46,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,6 +100,30 @@ 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(); + +/** + * 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 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. + */ +function nonGrantableNames(type: string): ReadonlySet { + return type === 'position' ? AUDIENCE_ANCHORS : NO_EXCLUSIONS; +} + /** A settled answer, with the inputs it answers for. */ interface SettledCatalog { dataSource: unknown; @@ -156,6 +190,7 @@ export function RegistryRefField({ const { t } = useFieldTranslation(); const dataSource = props.dataSource ?? ctx?.dataSource ?? null; const catalog = useRegistryCatalog(dataSource, registryType); + const excluded = nonGrantableNames(registryType); const current = typeof value === 'string' ? value : ''; const testId = `registry-ref-${registryType}`; @@ -225,11 +260,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..a2d1f090fd --- /dev/null +++ b/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx @@ -0,0 +1,133 @@ +/** + * 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`; + * - 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: a permission set named like an anchor is + * an ordinary set. + */ +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'; +import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec'; +import { PositionRefField, PermissionRefField } from '../RegistryRefField'; + +/** 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 ?? ''); +} + +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('a registry that lists only the anchors offers no picker: the typed-name box', async () => { + 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(); + 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, and writes, a permission set named like an anchor', async () => { + 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'); + }); +});