From 41ede82b4a7fceac26f35586410f36819c2196fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 03:57:57 +0000 Subject: [PATCH 1/4] fix(fields): permission-ref / position-ref cells draw the registry label, the name on title Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- ui/packages/fields/src/index.tsx | 23 +++-- .../fields/src/widgets/RegistryRefCell.tsx | 75 +++++++++++++++ .../fields/src/widgets/RegistryRefField.tsx | 22 +---- .../fields/src/widgets/registryCatalog.ts | 92 +++++++++++++++++++ ui/packages/plugin-detail/src/RelatedList.tsx | 2 +- 5 files changed, 187 insertions(+), 27 deletions(-) create mode 100644 ui/packages/fields/src/widgets/RegistryRefCell.tsx create mode 100644 ui/packages/fields/src/widgets/registryCatalog.ts diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 1d683cfc478..c865263abf4 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -3497,16 +3497,21 @@ const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', '' /** * Field `widget` hints whose READ face is a cell of its own (objectui#12613), * registered under the widget's own key below. The spec's `widget` names the - * component a field is drawn with in place of its `type` default; for these two - * the stored text is a machine reference — an object name, a recipient id — - * and a host face registered under the key names what it references, as the - * edit widget's readonly branch does, instead of printing the api name. + * component a field is drawn with in place of its `type` default; for these + * the stored text is a machine reference — an object name, a recipient id, a + * position or permission-set name — and the face registered under the key + * names what it references instead of printing the api name. * * Only a widget listed here promotes, and only over a textual base type, the * same bound the `format` hints keep: a widget with no read face of its own - * (`filter-condition`, `position-ref`, …) still draws as its `type`. + * (`filter-condition`, …) still draws as its `type`. */ -const WIDGET_CELL_RENDERER_TYPES: ReadonlySet = new Set(['object-ref', 'recipient-picker']); +const WIDGET_CELL_RENDERER_TYPES: ReadonlySet = new Set([ + 'object-ref', + 'recipient-picker', + 'position-ref', + 'permission-ref', +]); /** * Resolve the canonical cell-renderer key for a field. Accepts either a @@ -4223,6 +4228,7 @@ export function getCellRenderer(fieldType: string): React.FC return standardMap[fieldType] || TextCellRenderer; } +import { PositionRefCell, PermissionRefCell } from './widgets/RegistryRefCell.js'; // Register standard renderers immediately registerFieldRenderer('lookup', LookupCellRenderer); registerFieldRenderer('master_detail', LookupCellRenderer); @@ -4243,6 +4249,11 @@ registerFieldRenderer(RECORD_POINTER_CARD_TYPE, RecordPointerTextCellRenderer); // the host loads its faces lazily, off the eager closure. registerFieldRenderer('object-ref', TextCellRenderer); registerFieldRenderer('recipient-picker', TextCellRenderer); +// The registry-name keys draw their label in this package: the reader is the +// edit widget's own `listMetadataItems` catalog, one shared read per data +// source and type, which `check:eager-closure` weighs with the rest of it. +registerFieldRenderer('position-ref', PositionRefCell); +registerFieldRenderer('permission-ref', PermissionRefCell); // `owner` was registered here to the same UserCellRenderer until objectui#4814 // retired the spelling — see the TOMBSTONE below. `getCellRenderer('owner')` // now reports the prescription and falls to the text cell. diff --git a/ui/packages/fields/src/widgets/RegistryRefCell.tsx b/ui/packages/fields/src/widgets/RegistryRefCell.tsx new file mode 100644 index 00000000000..7ff0f587def --- /dev/null +++ b/ui/packages/fields/src/widgets/RegistryRefCell.tsx @@ -0,0 +1,75 @@ +/** + * 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. + */ + +/** + * The READ face of the `position-ref` / `permission-ref` keys: a list or detail + * cell of a textual field declaring either widget (`resolveCellRendererType`) + * draws the registry LABEL of the stored name, with the name itself on + * `title`, so the api name stays one hover away. A user's Permission Sets tab + * reads "Organization Admin" where it read `organization_admin_no_bypass`. + * + * The label comes from the catalog the edit widget lists + * (`./registryCatalog.ts`), read once per data source and registry type for + * every cell that draws one. Until it answers, when it fails, when there is no + * data source that can list a registry, and for a name the catalog does not + * hold or holds without a label, the cell draws the stored name, exactly as the + * text cell does. + */ + +import React from 'react'; +import { EmptyValue } from '@object-ui/components'; +import { isEmptyValue } from '@object-ui/core'; +import { SchemaRendererContext } from '@object-ui/react'; +import { coerceToSafeValue } from '../coerceToSafeValue.js'; +import type { CellRendererProps } from '../index.js'; +import { loadRegistryLabels, registryLister, settledRegistryLabels } from './registryCatalog.js'; + +/** The label `type`'s catalog holds for `name`, or `undefined` (not read, not held). */ +function useRegistryLabel(type: string, name: string): string | undefined { + const source = registryLister(React.useContext(SchemaRendererContext)?.dataSource); + const settled = source ? settledRegistryLabels(source, type) : undefined; + const [, setLoaded] = React.useState(0); + + React.useEffect(() => { + if (!source || !name || settled) return; + let live = true; + loadRegistryLabels(source, type).then( + () => { + if (live) setLoaded((n) => n + 1); + }, + () => {}, + ); + return () => { + live = false; + }; + }, [source, type, name, settled]); + + return name ? settled?.get(name) : undefined; +} + +function RegistryRefCell({ value, registryType }: CellRendererProps & { registryType: string }) { + const safe = coerceToSafeValue(value); + const name = isEmptyValue(safe) ? '' : String(safe); + const label = useRegistryLabel(registryType, name); + if (!name) return ; + return ( + + {label ?? name} + + ); +} + +/** `position-ref`: a stored position name, drawn as the position's label. */ +export function PositionRefCell(props: CellRendererProps) { + return ; +} + +/** `permission-ref`: a stored permission-set name, drawn as the set's label. */ +export function PermissionRefCell(props: CellRendererProps) { + return ; +} diff --git a/ui/packages/fields/src/widgets/RegistryRefField.tsx b/ui/packages/fields/src/widgets/RegistryRefField.tsx index 8035f935102..ef231a6a8a4 100644 --- a/ui/packages/fields/src/widgets/RegistryRefField.tsx +++ b/ui/packages/fields/src/widgets/RegistryRefField.tsx @@ -11,8 +11,8 @@ 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 { registryLister, type RegistryItemHeader } from './registryCatalog.js'; import { toDomProps } from './toDomProps.js'; import { useFieldTranslation } from './useFieldTranslation.js'; @@ -65,11 +65,7 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * no id, no first-item default. */ -/** One listed item of a registry: the name stored, the label shown beside it. */ -export interface RegistryItemHeader { - name: string; - label?: string; -} +export type { RegistryItemHeader }; /** The catalog, in whichever of its four states the load is actually in. */ export type RegistryCatalogState = @@ -81,20 +77,6 @@ export type RegistryCatalogState = const IDLE: RegistryCatalogState = { status: 'idle' }; const LOADING: RegistryCatalogState = { status: 'loading' }; -type RegistryLister = Required>; - -/** - * The data source's registry list, when it has one. The widget contract keeps - * `dataSource` structural (`unknown`, see `FieldWidgetComponentProps`), so the - * member is narrowed here, by the name the `DataSource` contract declares. - */ -function registryLister(dataSource: unknown): RegistryLister | null { - const candidate = dataSource as Partial | null | undefined; - return candidate && typeof candidate.listMetadataItems === 'function' - ? (candidate as RegistryLister) - : null; -} - function failureMessage(err: unknown): string { if (err instanceof Error) return err.message; return typeof err === 'string' ? err : ''; diff --git a/ui/packages/fields/src/widgets/registryCatalog.ts b/ui/packages/fields/src/widgets/registryCatalog.ts new file mode 100644 index 00000000000..3df68b8cd44 --- /dev/null +++ b/ui/packages/fields/src/widgets/registryCatalog.ts @@ -0,0 +1,92 @@ +/** + * 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. + */ + +/** + * The one registry catalog the `position-ref` / `permission-ref` faces read: + * `DataSource.listMetadataItems(type)`, `GET /api/v1/meta/:type` (objectui#7611). + * The edit widget (`./RegistryRefField.tsx`) lists it to offer the items; the + * read cell (`./RegistryRefCell.tsx`) reads it to draw a stored name's label. + * + * The read cell's copy is SHARED: one request per data source and registry + * type, whatever number of cells draw a name from it, kept for the data + * source's lifetime (the page session). A label is a display string, not a + * decision input, so an item authored mid-session drawing its name until + * reload is the right trade. A failed read is not kept: the next cell that + * mounts asks again. + */ + +import type { DataSource } from '@object-ui/types'; + +/** One listed item of a registry: the name stored, the label shown beside it. */ +export interface RegistryItemHeader { + name: string; + label?: string; +} + +export type RegistryLister = Required>; + +/** + * The data source's registry list, when it has one. The widget contract keeps + * `dataSource` structural (`unknown`, see `FieldWidgetComponentProps`), so the + * member is narrowed here, by the name the `DataSource` contract declares. + */ +export function registryLister(dataSource: unknown): RegistryLister | null { + const candidate = dataSource as Partial | null | undefined; + return candidate && typeof candidate.listMetadataItems === 'function' + ? (candidate as RegistryLister) + : null; +} + +/** A registry type's labels by item name; an item with no label is absent. */ +export type RegistryLabels = ReadonlyMap; + +interface CatalogEntry { + promise: Promise; + settled?: RegistryLabels; +} + +const catalogs = new WeakMap>(); + +function entriesOf(source: RegistryLister): Map { + let byType = catalogs.get(source); + if (!byType) { + byType = new Map(); + catalogs.set(source, byType); + } + return byType; +} + +/** The labels already read for `type`, or `undefined` while none has settled. */ +export function settledRegistryLabels(source: RegistryLister, type: string): RegistryLabels | undefined { + return catalogs.get(source)?.get(type)?.settled; +} + +/** Read `type`'s labels, sharing the request with every other caller. */ +export function loadRegistryLabels(source: RegistryLister, type: string): Promise { + const byType = entriesOf(source); + const existing = byType.get(type); + if (existing) return existing.promise; + const entry: CatalogEntry = { + promise: source.listMetadataItems(type).then( + (items) => { + const labels = new Map(); + for (const item of items) { + if (item.label && item.label !== item.name) labels.set(item.name, item.label); + } + entry.settled = labels; + return labels; + }, + (err: unknown) => { + if (byType.get(type) === entry) byType.delete(type); + throw err; + }, + ), + }; + byType.set(type, entry); + return entry.promise; +} diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 6f2f777146a..a15b2c843c2 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -1906,7 +1906,7 @@ export const RelatedList: React.FC = ({ // returns a falsy renderer — measured over seven spellings, including // ones no producer can emit, all of which resolved to `TextCellRenderer`. if (!def?.type) return undefined; - const rendererType = resolveCellRendererType({ type: def.type, format: def.format }) || def.type; + const rendererType = resolveCellRendererType({ type: def.type, format: def.format, widget: def.widget }) || def.type; const CellRenderer = getCellRenderer(rendererType); if (!CellRenderer) return undefined; const isLookup = def.type === 'lookup' || def.type === 'master_detail'; From 3fdaf9a66841f2f9d8af975832a2cb0000a2b078 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:09:00 +0000 Subject: [PATCH 2/4] test(fields): pin the registry-name read face, its shared read and the name fallback Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- .changeset/27861-registry-ref-cell-label.md | 11 ++ .../RegistryRefCell.readFace.test.tsx | 165 ++++++++++++++++++ .../RelatedList.registryRefColumn.test.tsx | 109 ++++++++++++ .../summaryChip.badgeFitCensus-8464.test.tsx | 11 +- 4 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 .changeset/27861-registry-ref-cell-label.md create mode 100644 ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/RelatedList.registryRefColumn.test.tsx diff --git a/.changeset/27861-registry-ref-cell-label.md b/.changeset/27861-registry-ref-cell-label.md new file mode 100644 index 00000000000..cad438f9e06 --- /dev/null +++ b/.changeset/27861-registry-ref-cell-label.md @@ -0,0 +1,11 @@ +--- +'@object-ui/fields': patch +'@object-ui/plugin-detail': patch +--- + +fix(fields): a user's Permission Sets and Positions tabs show each set's and position's label, not its api name + +Clause-②: no +Metadata-protocol: none + +A text field declaring `widget: 'permission-ref'` or `widget: 'position-ref'` (`sys_user_permission_set.permission_set`, `sys_user_position.position`) now has a read face of its own in list and detail cells: it draws the registry label of the stored name (`Organization Admin` for `organization_admin_no_bypass`), with the name on the cell's `title`. The labels come from the catalog the edit picker already lists (`DataSource.listMetadataItems`, `GET /api/v1/meta/:type`), read once per data source and registry type for every cell. A name the catalog does not hold, or holds without a label, still draws as the name, as does every cell while the catalog loads, when it fails, or when no data source can list a registry. A record page's related list now hands a column's declared `widget` to the cell resolution, as the grid already did, so these columns reach the face there. diff --git a/ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx b/ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx new file mode 100644 index 00000000000..5d39ce35e0a --- /dev/null +++ b/ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx @@ -0,0 +1,165 @@ +/** + * 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. + */ + +/** + * objectstack#27861 — the READ face of `position-ref` / `permission-ref`. + * + * `sys_user_permission_set.permission_set` and `sys_user_position.position` + * are by-name text columns declaring those widgets. A list or detail cell of + * either drew the stored api name (`organization_admin_no_bypass`); it now + * draws the registry label, with the name on `title`, from the catalog the + * edit widget lists (`listMetadataItems`), read once for every cell. + * + * A name the catalog does not hold — or holds without a label — still draws as + * the name, and so does every state with no answer yet: no data source, a + * catalog still loading, a catalog that failed. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { getCellRenderer, resolveCellRendererType } from '../../index'; +import { PermissionRefCell, PositionRefCell } from '../RegistryRefCell'; + +const PERMISSION_SETS = [ + { name: 'organization_admin_no_bypass', label: 'Organization Admin' }, + { name: 'admin_full_access', label: 'Full Access' }, + // An item whose author wrote no label reads as its name alone. + { name: 'auditor' }, +]; +const POSITIONS = [ + { name: 'sales_rep', label: 'Sales Representative' }, + { name: 'sales_manager', label: 'Sales Manager' }, +]; + +function lister(answer: (type: string) => Promise>) { + return { listMetadataItems: vi.fn(answer), find: vi.fn() } as any; +} + +const FIELD = { name: 'permission_set', type: 'text', label: 'Permission Set', widget: 'permission-ref' } as any; + +function mountCells(Cell: typeof PermissionRefCell, values: unknown[], dataSource?: unknown) { + const cells = values.map((value, i) => ( +
+ +
+ )); + return render(dataSource ? {cells} : <>{cells}); +} + +function cellText(i: number): HTMLElement { + return screen.getByTestId(`cell-${i}`).firstElementChild as HTMLElement; +} + +afterEach(() => cleanup()); + +describe('objectstack#27861 — the declared widget picks the registry read face', () => { + it('promotes a textual field declaring `permission-ref` / `position-ref` to its own key and face', () => { + expect(resolveCellRendererType({ type: 'text', widget: 'permission-ref' })).toBe('permission-ref'); + expect(resolveCellRendererType({ type: 'text', widget: 'position-ref' })).toBe('position-ref'); + expect(getCellRenderer('permission-ref')).toBe(PermissionRefCell); + expect(getCellRenderer('position-ref')).toBe(PositionRefCell); + // Control: a non-textual base type keeps its own key. + expect(resolveCellRendererType({ type: 'select', widget: 'permission-ref' })).toBe('select'); + }); +}); + +describe('objectstack#27861 — `permission-ref` cells draw the set label', () => { + it("draws each set's label with its api name on title, from ONE catalog read shared by every cell", async () => { + const ds = lister(async () => PERMISSION_SETS); + mountCells(PermissionRefCell, ['organization_admin_no_bypass', 'admin_full_access', 'organization_admin_no_bypass'], ds); + + await waitFor(() => expect(cellText(0)).toHaveTextContent('Organization Admin')); + expect(cellText(0)).toHaveAttribute('title', 'organization_admin_no_bypass'); + expect(cellText(1)).toHaveTextContent('Full Access'); + expect(cellText(1)).toHaveAttribute('title', 'admin_full_access'); + expect(cellText(2)).toHaveTextContent('Organization Admin'); + + expect(ds.listMetadataItems).toHaveBeenCalledTimes(1); + expect(ds.listMetadataItems).toHaveBeenCalledWith('permission'); + }); + + it('a cell mounting after the catalog settled draws the label on its first render, with no second read', async () => { + const ds = lister(async () => PERMISSION_SETS); + mountCells(PermissionRefCell, ['admin_full_access'], ds); + await waitFor(() => expect(cellText(0)).toHaveTextContent('Full Access')); + cleanup(); + + mountCells(PermissionRefCell, ['organization_admin_no_bypass'], ds); + expect(cellText(0)).toHaveTextContent('Organization Admin'); + expect(ds.listMetadataItems).toHaveBeenCalledTimes(1); + }); +}); + +describe('objectstack#27861 — `position-ref` cells draw the position label', () => { + it("draws each position's label with its api name on title, reading the `position` registry", async () => { + const ds = lister(async () => POSITIONS); + mountCells(PositionRefCell, ['sales_manager', 'sales_rep'], ds); + + await waitFor(() => expect(cellText(0)).toHaveTextContent('Sales Manager')); + expect(cellText(0)).toHaveAttribute('title', 'sales_manager'); + expect(cellText(1)).toHaveTextContent('Sales Representative'); + expect(cellText(1)).toHaveAttribute('title', 'sales_rep'); + expect(ds.listMetadataItems).toHaveBeenCalledTimes(1); + expect(ds.listMetadataItems).toHaveBeenCalledWith('position'); + }); +}); + +describe('objectstack#27861 — THE FALLBACK: a name the catalog cannot label draws as the name', () => { + it('a name the catalog does not list renders the name itself, beside a listed one that resolves', async () => { + const ds = lister(async () => PERMISSION_SETS); + mountCells(PermissionRefCell, ['retired_set', 'admin_full_access', 'auditor'], ds); + + // The listed sibling resolving proves the catalog answered before the + // unknown name is judged. + await waitFor(() => expect(cellText(1)).toHaveTextContent('Full Access')); + expect(cellText(0)).toHaveTextContent(/^retired_set$/); + expect(cellText(0)).toHaveAttribute('title', 'retired_set'); + // Listed without a label: the name alone, too. + expect(cellText(2)).toHaveTextContent(/^auditor$/); + }); + + it('with no data source that can list a registry, the cell draws the name', () => { + mountCells(PermissionRefCell, ['admin_full_access']); + expect(cellText(0)).toHaveTextContent(/^admin_full_access$/); + expect(cellText(0)).toHaveAttribute('title', 'admin_full_access'); + }); + + it('while the catalog loads the cell draws the name', () => { + const ds = lister(() => new Promise(() => {})); + mountCells(PermissionRefCell, ['admin_full_access'], ds); + expect(cellText(0)).toHaveTextContent(/^admin_full_access$/); + }); + + it('a failed catalog draws the name, and is not kept: the next cell asks again', async () => { + let fail = true; + const ds = lister(async () => { + if (fail) throw new Error('403'); + return PERMISSION_SETS; + }); + mountCells(PermissionRefCell, ['admin_full_access'], ds); + await waitFor(() => expect(ds.listMetadataItems).toHaveBeenCalledTimes(1)); + await Promise.resolve(); + expect(cellText(0)).toHaveTextContent(/^admin_full_access$/); + cleanup(); + + fail = false; + mountCells(PermissionRefCell, ['admin_full_access'], ds); + await waitFor(() => expect(cellText(0)).toHaveTextContent('Full Access')); + expect(ds.listMetadataItems).toHaveBeenCalledTimes(2); + }); + + it('an empty value draws the shared No-value glyph and reads nothing', () => { + const ds = lister(async () => PERMISSION_SETS); + mountCells(PermissionRefCell, [null, ''], ds); + expect(cellText(0)).toHaveAttribute('data-slot', 'empty-value'); + expect(cellText(1)).toHaveAttribute('data-slot', 'empty-value'); + expect(ds.listMetadataItems).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.registryRefColumn.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.registryRefColumn.test.tsx new file mode 100644 index 00000000000..5c4f152607f --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.registryRefColumn.test.tsx @@ -0,0 +1,109 @@ +/** + * 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. + */ + +/** + * objectstack#27861 — a user's Permission Sets and Positions tabs draw each + * set's and position's LABEL, with the api name on `title`. + * + * Both tabs are `record:related_list`s over by-name text columns that declare + * `widget: 'permission-ref'` / `widget: 'position-ref'` + * (`sys_user_permission_set.permission_set`, `sys_user_position.position`). + * The related list draws its own cells (`makeCell` in `RelatedList.tsx`), and + * it resolved their key from `type` and `format` only, so the widget never + * reached `@object-ui/fields`' read face and the column printed the api name. + * + * The real data-table and cell renderers draw (nothing mocked); the catalog is + * the data source's `listMetadataItems`, read once for the whole column. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; + +import '@object-ui/components'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { RelatedList } from '../RelatedList'; + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); +afterEach(cleanup); + +const CATALOGS: Record> = { + permission: [ + { name: 'organization_admin_no_bypass', label: 'Organization Admin' }, + { name: 'admin_full_access', label: 'Full Access' }, + ], + position: [{ name: 'sales_manager', label: 'Sales Manager' }], +}; + +function makeDataSource(objectName: string, column: string, widget: string, values: string[]) { + const rows = values.map((value, i) => ({ id: `r${i}`, user_id: 'u1', [column]: value })); + return { + getObjectSchema: vi.fn(async () => ({ + name: objectName, + fields: { [column]: { type: 'text', label: 'Granted', widget } }, + })), + find: vi.fn(async () => ({ data: rows, total: rows.length })), + listMetadataItems: vi.fn(async (type: string) => CATALOGS[type] ?? []), + }; +} + +async function mountTab(objectName: string, column: string, widget: string, values: string[]) { + const ds = makeDataSource(objectName, column, widget, values); + const { container } = render( + + + , + ); + await waitFor(() => expect(container.querySelectorAll('tbody tr').length).toBe(values.length)); + const cells = () => + [...container.querySelectorAll('tbody tr')].map((tr) => tr.querySelector('td [title]') as HTMLElement | null); + return { ds, cells }; +} + +describe('objectstack#27861 — a related list draws a registry-name column through its read face', () => { + it("Permission Sets: each set's label, its api name on title, one catalog read for the column", async () => { + const { ds, cells } = await mountTab('sys_user_permission_set', 'permission_set', 'permission-ref', [ + 'organization_admin_no_bypass', + 'admin_full_access', + ]); + await waitFor(() => expect(cells()[0]).toHaveTextContent('Organization Admin')); + expect(cells()[0]).toHaveAttribute('title', 'organization_admin_no_bypass'); + expect(cells()[1]).toHaveTextContent('Full Access'); + expect(cells()[1]).toHaveAttribute('title', 'admin_full_access'); + expect(ds.listMetadataItems).toHaveBeenCalledTimes(1); + expect(ds.listMetadataItems).toHaveBeenCalledWith('permission'); + }); + + it("Positions: each position's label, its api name on title", async () => { + const { ds, cells } = await mountTab('sys_user_position', 'position', 'position-ref', ['sales_manager']); + await waitFor(() => expect(cells()[0]).toHaveTextContent('Sales Manager')); + expect(cells()[0]).toHaveAttribute('title', 'sales_manager'); + expect(ds.listMetadataItems).toHaveBeenCalledWith('position'); + }); + + it('a name the catalog does not hold still renders, as the name', async () => { + const { cells } = await mountTab('sys_user_permission_set', 'permission_set', 'permission-ref', [ + 'retired_set', + 'admin_full_access', + ]); + await waitFor(() => expect(cells()[1]).toHaveTextContent('Full Access')); + expect(cells()[0]).toHaveTextContent(/^retired_set$/); + expect(cells()[0]).toHaveAttribute('title', 'retired_set'); + }); +}); diff --git a/ui/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx b/ui/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx index c46a3a13bb3..d204e940dce 100644 --- a/ui/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx @@ -162,6 +162,10 @@ const CENSUS: ReadonlyArray { + it('THE FIT SIDE IS LOAD-BEARING — the 40 fitting kinds draw text and no artefact', async () => { // Refusing EVERY type also satisfies every refusal assertion above; this is // the half that is red for it. const fitting = CENSUS.filter(([, , v]) => v === 'fit'); // 36 → 38: `object-ref` and `recipient-picker` joined the fit side - // (objectui#12613). - expect(fitting.length, 'the fitting side is not empty').toBe(38); + // (objectui#12613); 38 → 40: `position-ref` and `permission-ref` + // (objectstack#27861). + expect(fitting.length, 'the fitting side is not empty').toBe(40); for (const [type] of fitting) { const { container } = renderChip(type); From 37f0260250b6146e1513ca5fb7db0b903ff8e6b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:01:58 +0000 Subject: [PATCH 3/4] test(fields): the object-literal census measures the two registry-name keys Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- .../__tests__/cellRenderers.objectLiteral-8596.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx b/ui/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx index c89a1b62fa2..b02607e26c2 100644 --- a/ui/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx +++ b/ui/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx @@ -297,6 +297,11 @@ const OBJECT_LITERAL_CENSUS: ReadonlyArray = ['record_pointer_card', { objectName: 'invoice', recordId: 'inv_1' }], ['object-ref', 'crm_account'], ['recipient-picker', 'sales_manager'], + ['position-ref', 'sales_manager'], + ['permission-ref', 'admin_full_access'], ]; describe('objectui#8596 — an object literal is not a cell value, and these renderers invented one', () => { From 8307533a8934e5f809a3cfd15a47b8b5ccf27c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:06:07 +0000 Subject: [PATCH 4/4] test(fields): type the stub data source handed to the provider Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- .../src/widgets/__tests__/RegistryRefCell.readFace.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx b/ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx index 5d39ce35e0a..d747f8aa7d7 100644 --- a/ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx +++ b/ui/packages/fields/src/widgets/__tests__/RegistryRefCell.readFace.test.tsx @@ -50,7 +50,7 @@ function mountCells(Cell: typeof PermissionRefCell, values: unknown[], dataSourc )); - return render(dataSource ? {cells} : <>{cells}); + return render(dataSource ? {cells} : <>{cells}); } function cellText(i: number): HTMLElement {