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
11 changes: 11 additions & 0 deletions .changeset/27861-registry-ref-cell-label.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ const OBJECT_LITERAL_CENSUS: ReadonlyArray<readonly [type: string, text: string,
// the text cell's face for it, exactly as `text` does.
['object-ref', '[Object]', false],
['recipient-picker', '[Object]', false],
// ── objectstack#27861: the registry-name read faces ─────────────────────
// `{}` names no item, so nothing is asked and both draw the text cell's
// face for it, exactly as `text` does.
['position-ref', '[Object]', false],
['permission-ref', '[Object]', false],
];

/** A real value per type — the POPULATED half, which refuses the caricature. */
Expand Down Expand Up @@ -357,6 +362,8 @@ const POPULATED_CENSUS: ReadonlyArray<readonly [type: string, value: unknown]> =
['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', () => {
Expand Down
23 changes: 17 additions & 6 deletions ui/packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(['object-ref', 'recipient-picker']);
const WIDGET_CELL_RENDERER_TYPES: ReadonlySet<string> = new Set([
'object-ref',
'recipient-picker',
'position-ref',
'permission-ref',
]);

/**
* Resolve the canonical cell-renderer key for a field. Accepts either a
Expand Down Expand Up @@ -4223,6 +4228,7 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
return standardMap[fieldType] || TextCellRenderer;
}

import { PositionRefCell, PermissionRefCell } from './widgets/RegistryRefCell.js';
// Register standard renderers immediately
registerFieldRenderer('lookup', LookupCellRenderer);
registerFieldRenderer('master_detail', LookupCellRenderer);
Expand All @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions ui/packages/fields/src/widgets/RegistryRefCell.tsx
Original file line number Diff line number Diff line change
@@ -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 <EmptyValue />;
return (
<span className="block max-w-full truncate" title={name}>
{label ?? name}
</span>
);
}

/** `position-ref`: a stored position name, drawn as the position's label. */
export function PositionRefCell(props: CellRendererProps) {
return <RegistryRefCell {...props} registryType="position" />;
}

/** `permission-ref`: a stored permission-set name, drawn as the set's label. */
export function PermissionRefCell(props: CellRendererProps) {
return <RegistryRefCell {...props} registryType="permission" />;
}
22 changes: 2 additions & 20 deletions ui/packages/fields/src/widgets/RegistryRefField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 =
Expand All @@ -81,20 +77,6 @@ export type RegistryCatalogState =
const IDLE: RegistryCatalogState = { status: 'idle' };
const LOADING: RegistryCatalogState = { status: 'loading' };

type RegistryLister = Required<Pick<DataSource, 'listMetadataItems'>>;

/**
* 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<RegistryLister> | 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 : '';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Array<{ name: string; label?: string }>>) {
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) => (
<div key={i} data-testid={`cell-${i}`}>
<Cell value={value} field={FIELD} />
</div>
));
return render(dataSource ? <SchemaRendererProvider dataSource={dataSource as any}>{cells}</SchemaRendererProvider> : <>{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();
});
});
Loading
Loading