Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/12200-position-picker-anchors.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion content/docs/guide/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<widget>`. 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
Expand Down
5 changes: 4 additions & 1 deletion packages/fields/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ A `text` field that stores the machine name of one registry item declares
`field:<widget>`). 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).
Expand Down
55 changes: 48 additions & 7 deletions packages/fields/src/widgets/RegistryRefField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -90,6 +100,30 @@ function failureMessage(err: unknown): string {
return typeof err === 'string' ? err : '';
}

const AUDIENCE_ANCHORS: ReadonlySet<string> = new Set(AUDIENCE_ANCHOR_POSITIONS);
const NO_EXCLUSIONS: ReadonlySet<string> = 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<string> {
return type === 'position' ? AUDIENCE_ANCHORS : NO_EXCLUSIONS;
}

/** A settled answer, with the inputs it answers for. */
interface SettledCatalog {
dataSource: unknown;
Expand Down Expand Up @@ -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}`;

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<Widget
value={value}
onChange={onChange}
readonly={readonly}
field={{ name: 'position', type: 'text', label: 'Position' } as any}
dataSource={dataSource}
id="f-position"
/>,
);
return { onChange };
}

/** The option texts of the open picker, in order. */
async function openOptions(): Promise<string[]> {
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');
});
});
Loading