diff --git a/.changeset/12126-ref-multi-permission-widget.md b/.changeset/12126-ref-multi-permission-widget.md new file mode 100644 index 0000000000..ed18694925 --- /dev/null +++ b/.changeset/12126-ref-multi-permission-widget.md @@ -0,0 +1,22 @@ +--- +'@object-ui/app-shell': minor +--- + +A Studio form field that declares the `ref-multi:permission` widget now renders a picker of the declared permission sets instead of the JSON editor fallback (objectui#12126). + +The position form's "Permission sets" row is moving from a free-text tag box to this widget +(objectstack#22794, ADR-0131 D4: a reference to a declared item is by machine name). The metadata +forms had no renderer for `ref-multi:permission`, so that row would have become a raw JSON box with a +"falling back to JSON" note. The new widget lists the permission sets the position names, each with a +remove button, and offers an "Add permission set" picker of the declared sets not yet picked; a pick +adds the set's name. The stored value stays a list of names. A stored name the permission-set +registry does not declare stays in the list and is marked "(not found)". While the list is loading +the add box is disabled; if it fails to load the field shows the load-failure notice beside a text +box that still adds a typed name. A form whose host supplies no permission-set list takes typed names. + +The metadata editor loads the permission-set list only for a form that declares the widget, so no +other editor sends the extra request. + +`SchemaForm`'s `widgetContext` prop gains one optional member, `permissionSets`, the list the new +widget reads. It is additive: a host that passes no `permissionSets` renders exactly as before, except +that a `ref-multi:permission` field now takes typed names instead of showing the JSON fallback. diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.permissionSetsFeed-12126.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.permissionSetsFeed-12126.test.tsx new file mode 100644 index 0000000000..a88beda464 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.permissionSetsFeed-12126.test.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12126 — the generic editor feeds the permission-set catalog to a + * form that declares `widget: 'ref-multi:permission'`, and to no other form. + * + * The position form arrives from `/meta/types` as the type entry's `form`, so + * `ResourceEditPage` is the host that has to hand the `ref-multi:permission` + * picker its catalog. Pinned here, on the real page with a stubbed client: + * + * - a form that declares the hint gets the registry's permission sets as the + * picker's options, and a pick stores the set's name in the draft's list; + * - the request is the whole registry list (`client.list('permission')`, no + * package scope): a position names sets from the environment catalog; + * - a FAILED list reaches the picker as a failure, not as an empty catalog; + * - a form that does not declare the hint sends no request at all — the + * control that makes the first case's request attributable to the hint. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { t } from './i18n'; + +const ADD = t('engine.form.addPermissionSetPlain', 'en-US'); + +const POSITION = { name: 'sales_lead', label: 'Sales lead', permissionSets: ['sales_rep', 'retired_set'] }; + +const listPermission = vi.fn<() => Promise>>>(); + +const mockClient = { + list: vi.fn(async (type: string) => (type === 'permission' ? listPermission() : [])), + listDrafts: vi.fn(async () => []), + get: vi.fn(async () => null), + getDraft: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => ({ + code: null, + overlay: POSITION, + overlayScope: null, + effective: POSITION, + provenance: 'org', + })), +}; + +const SCHEMA = { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + permissionSets: { type: 'array', items: { type: 'string' } }, + }, +}; + +/** The position form's row as objectstack#22794 declares it. */ +const POSITION_FORM = { + type: 'simple', + sections: [ + { + label: 'Position', + fields: [ + { field: 'name' }, + { field: 'label' }, + { field: 'permissionSets', label: 'Permission Sets', widget: 'ref-multi:permission' }, + ], + }, + ], +}; + +/** Today's row: a free-text tag box, which reads no catalog. */ +const TAGS_FORM = { + type: 'simple', + sections: [{ label: 'Position', fields: [{ field: 'name' }, { field: 'permissionSets', type: 'tags' }] }], +}; + +const form = { current: POSITION_FORM as Record }; + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { + type: 'position', + name: 'position', + label: 'Position', + allowOrgOverride: false, + allowRuntimeCreate: true, + schema: SCHEMA, + form: form.current, + }, + ], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; + +async function mountEditor() { + render( + + + , + ); + await waitFor(() => expect(mockClient.layered).toHaveBeenCalled()); +} + +const permissionCalls = () => mockClient.list.mock.calls.filter(([type]) => type === 'permission'); + +beforeEach(() => { + form.current = POSITION_FORM; + listPermission.mockImplementation(async () => [ + { name: 'sales_rep', label: 'Sales rep' }, + { name: 'admin_full_access', label: 'Full access' }, + { label: 'a row without a name' }, + ]); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('the permission-set catalog reaches a form that declares `ref-multi:permission` (objectui#12126)', () => { + it('offers the registry sets, flags a stored name it lacks, and a pick lands in the list', async () => { + await mountEditor(); + const list = await screen.findByRole('group', { name: /Permission Sets/ }); + await waitFor(() => expect(within(list).getAllByRole('listitem')[1]).toHaveTextContent('(not found)')); + expect(within(list).getAllByRole('listitem')[0]).not.toHaveTextContent('(not found)'); + + // A form-only type opens in view mode; either Edit button (header, or the + // view-mode notice) enters the editable form. + await userEvent.click(screen.getAllByRole('button', { name: t('engine.edit.edit', 'en-US') })[0]); + await userEvent.click(await screen.findByRole('combobox', { name: ADD })); + // Sorted by name; the stored `sales_rep` is not offered again; a nameless row is not an option. + expect((await screen.findAllByRole('option')).map((o) => o.textContent)).toEqual(['Full accessadmin_full_access']); + await userEvent.click(screen.getByRole('option', { name: /admin_full_access/ })); + await waitFor(() => + expect(within(list).getAllByRole('listitem').map((li) => li.querySelector('code')?.textContent)).toEqual([ + 'sales_rep', + 'retired_set', + 'admin_full_access', + ]), + ); + }); + + it('asks the whole registry once, with no package scope', async () => { + await mountEditor(); + await waitFor(() => expect(permissionCalls()).toHaveLength(1)); + expect(permissionCalls()[0]).toEqual(['permission']); + }); + + it('a FAILED list reaches the picker as a failure, not as an empty catalog', async () => { + listPermission.mockImplementation(async () => { + throw new Error('HTTP 503'); + }); + await mountEditor(); + expect(await screen.findByTestId('ref-multi-permission-load-failed')).toHaveTextContent('HTTP 503'); + expect(screen.queryByText('(not found)')).toBeNull(); + }); +}); + +describe('a form that does not declare `ref-multi:permission` sends no request (objectui#12126)', () => { + it('the tag-box row reads no catalog, so the permission list is never asked for', async () => { + form.current = TAGS_FORM; + await mountEditor(); + // The page has settled: the draft's name is on screen in the tag row's form. + await screen.findByDisplayValue('sales_lead'); + expect(permissionCalls()).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 39106b97f2..cda2f3c897 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -94,8 +94,10 @@ import { } from './SchemaForm.js'; import { collectPageComponentIds, + formDeclaresWidget, type ObjectActionOption, type ObjectFieldOption, + type RegistryItemOption, type WidgetContext, } from './widgets.js'; import { @@ -990,6 +992,33 @@ function MetadataResourceEditPageImpl({ ), ); + // The permission-set catalog — fuels the `ref-multi:permission` picker + // (objectui#12126), which the position form's `permissionSets` row declares + // (ADR-0131 D4: a reference to a declared item is by machine name, resolved + // registry-first). Loaded only when the form on screen declares that widget, + // so no other editor sends the request. The list is the whole registry, not + // one package's: a position names permission sets from the environment + // catalog, platform-shipped sets included (ADR-0131 D3). + const declaresPermissionSets = formDeclaresWidget( + createMode && config.createSchema ? undefined : entry?.form, + 'ref-multi:permission', + ); + const permissionSetsState = usePickerLoad( + React.useMemo( + () => + declaresPermissionSets + ? async () => { + const rows = (await client.list('permission')) as Array<{ name?: string; label?: string }>; + return rows + .filter((r): r is { name: string; label?: string } => typeof r?.name === 'string' && !!r.name) + .map((r) => ({ name: r.name, label: r.label })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + : null, + [client, declaresPermissionSets], + ), + ); + // Component ids placed on the page being edited — fuels the `ref:component` // picker so a page variable's `source` (the component that writes it) is // chosen from the real canvas components, not a free-text id. Derived from @@ -1043,8 +1072,9 @@ function MetadataResourceEditPageImpl({ objectActions: mapLoaded(objectCatalogState, (catalog) => catalog.actions), objectViews: objectViewsState, componentIds, + permissionSets: permissionSetsState, }), - [type, objectsState, objectCatalogState, objectViewsState, componentIds], + [type, objectsState, objectCatalogState, objectViewsState, componentIds, permissionSetsState], ); // Load layered view + initial draft. diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.refMultiPermission-12126.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.refMultiPermission-12126.test.tsx new file mode 100644 index 0000000000..efd9cd73b2 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.refMultiPermission-12126.test.tsx @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12126 — a spec-form field that declares + * `widget: 'ref-multi:permission'` renders a multi-picker of the declared + * permission sets, and stores a LIST of their names. + * + * ## The defect this lands ahead of + * + * objectstack#22794 moves the position form's `permissionSets` row from + * `type: 'tags'` to `widget: 'ref-multi:permission'` (ADR-0131 D4: a reference + * to a declared item is by machine name, resolved registry-first). `WIDGETS` + * had no entry for that key, so `resolveFieldFace` sent it down the announced + * raw-JSON face: a JSON `