From 6fa8183c7134c9bb532d80243c82b1db2227a299 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:58:03 +0000 Subject: [PATCH 1/5] feat(fields,plugin-grid): a declared object-ref / recipient-picker widget picks a read cell that names what the value references (objectui#12613) resolveCellRendererType promotes a textual field declaring widget 'object-ref' or 'recipient-picker' to a read cell registered under the widget key. The object-ref cell draws the object's label from the catalog ObjectRefField lists (one read per data source), with the picker's "Label (name)" spelling as its tooltip. The recipient cell reads the row's recipient_type through a new optional CellRendererProps.dependentValues and names the recipient with the picker's own kind table, batched per directory object with one $in query. A value nothing names draws muted with the shared unresolved-reference mark; a call site that passes no row draws the text it drew before. The grid passes the widget hint and the row on every column path. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .../__tests__/widgetRefCells-12613.test.tsx | 246 +++++++++++ packages/fields/src/index.tsx | 107 ++++- .../fields/src/widgets/ObjectRefField.tsx | 32 +- .../src/widgets/RecipientPickerField.tsx | 72 +--- packages/fields/src/widgets/objectCatalog.ts | 67 +++ packages/fields/src/widgets/recipientKinds.ts | 102 +++++ .../fields/src/widgets/refLabelDirectory.ts | 388 ++++++++++++++++++ packages/plugin-grid/src/ObjectGrid.tsx | 33 +- .../plugin-grid/src/cellRendererResolution.ts | 16 +- 9 files changed, 957 insertions(+), 106 deletions(-) create mode 100644 packages/fields/src/__tests__/widgetRefCells-12613.test.tsx create mode 100644 packages/fields/src/widgets/objectCatalog.ts create mode 100644 packages/fields/src/widgets/recipientKinds.ts create mode 100644 packages/fields/src/widgets/refLabelDirectory.ts diff --git a/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx new file mode 100644 index 0000000000..b238ade219 --- /dev/null +++ b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx @@ -0,0 +1,246 @@ +/** + * 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. + */ + +/** + * objectui#12613 — a field's declared `widget` picks its READ cell, so a list + * names what an `object-ref` / `recipient-picker` value references instead of + * printing the api name. + * + * The platform declares both hints on `sys_sharing_rule` (`object_name` is + * `Field.text({ widget: 'object-ref' })`, `recipient_id` is + * `Field.text({ widget: 'recipient-picker' })`), and before this change + * `resolveCellRendererType` read `type` and `format` only: both columns drew + * through the text cell, so the Sharing Rules list read `crm_account` and + * `sales_manager` where the edit form reads "Account" and "Sales Manager". + * + * Every case carries its control: + * - the promotion is driven by the DECLARED widget, never by the value: the + * same value under a plain `text` field still draws as text; + * - the reads are the edit widgets' reads, made once per page, not once per + * cell — each batching case counts the calls; + * - a value nothing could name draws muted, never blank, and a cell that + * could not ask (no row, no data source) draws the text it drew before. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MetadataCtx, SchemaRendererProvider, type MetadataContextValue } from '@object-ui/react'; +import { getCellRenderer, resolveCellRendererType, TextCellRenderer } from '../index'; + +afterEach(() => cleanup()); + +const POSITIONS = [ + { name: 'sales_manager', label: 'Sales Manager' }, + { name: 'eu_sales_lead', label: 'EU Sales Lead' }, +]; + +function metadataStore(items: unknown[]): MetadataContextValue & { ensureType: ReturnType } { + return { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: vi.fn().mockResolvedValue(items), + getItem: async () => null, + getItemsByType: () => [], + } as any; +} + +interface CellCase { + value: unknown; + dependentValues?: Record; +} + +/** Draw one cell per case, through the renderer the declared widget resolves to. */ +function renderCells( + widget: string, + cells: CellCase[], + { dataSource, store }: { dataSource?: any; store?: MetadataContextValue | null } = {}, +) { + const key = resolveCellRendererType({ type: 'text', widget }); + const Renderer = getCellRenderer(key); + const body = ( + + {cells.map((c, i) => ( +
+ +
+ ))} +
+ ); + return render(store ? {body} : body); +} + +const cell = (i: number) => screen.getByTestId(`cell-${i}`); +const unresolvedMark = (i: number) => cell(i).querySelector('[data-slot="unresolved-reference"]'); + +describe('objectui#12613 — the declared widget picks the cell', () => { + it('promotes a textual field declaring `object-ref` / `recipient-picker` to that cell, and nothing else', () => { + expect(resolveCellRendererType({ type: 'text', widget: 'object-ref' })).toBe('object-ref'); + expect(resolveCellRendererType({ type: 'text', widget: 'recipient-picker' })).toBe('recipient-picker'); + // Controls: no widget, a widget with no read cell of its own, and a + // non-textual base type keep the answer they had. + expect(resolveCellRendererType({ type: 'text' })).toBe('text'); + expect(resolveCellRendererType({ type: 'textarea', widget: 'filter-condition' })).toBe('textarea'); + expect(resolveCellRendererType({ type: 'select', widget: 'object-ref' })).toBe('select'); + // The widget wins over a `format` hint on the same text field: it names + // what the value IS, the hint only how plain text is drawn. + expect(resolveCellRendererType({ type: 'text', format: 'url', widget: 'object-ref' })).toBe('object-ref'); + expect(getCellRenderer('object-ref')).not.toBe(TextCellRenderer); + expect(getCellRenderer('recipient-picker')).not.toBe(TextCellRenderer); + }); +}); + +describe('objectui#12613 — the `object-ref` cell', () => { + function catalogSource() { + return { + getObjects: vi.fn(async () => [ + { name: 'crm_account', label: 'Account' }, + { name: 'crm_contact', label: 'Contact' }, + ]), + }; + } + + it("draws the object's label, with the api name in the tooltip, from ONE catalog read", async () => { + const ds = catalogSource(); + renderCells('object-ref', [{ value: 'crm_account' }, { value: 'crm_contact' }, { value: 'crm_account' }], { + dataSource: ds, + }); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); + expect(cell(0)).not.toHaveTextContent('crm_account'); + expect(cell(0).querySelector('[title]')?.getAttribute('title')).toBe('Account (crm_account)'); + expect(cell(1)).toHaveTextContent('Contact'); + expect(cell(2)).toHaveTextContent('Account'); + expect(ds.getObjects, 'one catalog read for every row, not one per cell').toHaveBeenCalledTimes(1); + }); + + it('draws a name the catalog does not list as the raw value, muted — never blank', async () => { + renderCells('object-ref', [{ value: 'crm_account' }, { value: 'crm_retired' }], { dataSource: catalogSource() }); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); + expect(cell(1)).toHaveTextContent('crm_retired'); + expect(unresolvedMark(1), 'the unknown name wears the unresolved mark').not.toBeNull(); + expect(unresolvedMark(0), 'the resolved one does not').toBeNull(); + }); + + it('draws the stored text exactly as before when there is no data source to ask', () => { + renderCells('object-ref', [{ value: 'crm_account' }]); + expect(cell(0)).toHaveTextContent('crm_account'); + expect(unresolvedMark(0)).toBeNull(); + }); + + it('control: the same value under a plain `text` field is not named', async () => { + const ds = catalogSource(); + const Text = getCellRenderer(resolveCellRendererType({ type: 'text' })); + render( + +
+ +
+
, + ); + expect(screen.getByTestId('plain')).toHaveTextContent('crm_account'); + expect(ds.getObjects).not.toHaveBeenCalled(); + }); +}); + +describe('objectui#12613 — the `recipient-picker` cell', () => { + it('names a position recipient through the registry: `sales_manager` reads "Sales Manager"', async () => { + const store = metadataStore(POSITIONS); + renderCells( + 'recipient-picker', + [ + { value: 'sales_manager', dependentValues: { recipient_type: 'position' } }, + { value: 'eu_sales_lead', dependentValues: { recipient_type: 'position' } }, + ], + { dataSource: { find: vi.fn() }, store }, + ); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Sales Manager')); + expect(cell(0)).not.toHaveTextContent('sales_manager'); + expect(cell(1)).toHaveTextContent('EU Sales Lead'); + expect(store.ensureType).toHaveBeenCalledTimes(1); + expect(store.ensureType).toHaveBeenCalledWith('position'); + }); + + it('names record recipients with ONE `$in` query per directory object, and marks the one it could not find', async () => { + const find = vi.fn(async (_object: string, _params: any) => ({ + data: [ + { id: 'team_eu', name: 'EU Sales' }, + { id: 'team_us', name: 'US Sales' }, + ], + })); + renderCells( + 'recipient-picker', + [ + { value: 'team_eu', dependentValues: { recipient_type: 'team' } }, + { value: 'team_us', dependentValues: { recipient_type: 'team' } }, + { value: 'team_gone', dependentValues: { recipient_type: 'team' } }, + ], + { dataSource: { find } }, + ); + + await waitFor(() => expect(cell(0)).toHaveTextContent('EU Sales')); + expect(cell(1)).toHaveTextContent('US Sales'); + await waitFor(() => expect(unresolvedMark(2)).not.toBeNull()); + expect(cell(2)).toHaveTextContent('team_gone'); + + expect(find, 'one query for the three rows, not one per cell').toHaveBeenCalledTimes(1); + const [object, params] = find.mock.calls[0]; + expect(object).toBe('sys_team'); + expect(params.$filter.id.$in.sort()).toEqual(['team_eu', 'team_gone', 'team_us']); + }); + + it("names a `field` recipient by the shared object's user column label", async () => { + const getObjectSchema = vi.fn(async () => ({ + name: 'crm_case', + fields: { + escalated_from: { type: 'user', label: 'Escalated from' }, + subject: { type: 'text', label: 'Subject' }, + }, + })); + renderCells( + 'recipient-picker', + [ + { value: 'escalated_from', dependentValues: { recipient_type: 'field', object_name: 'crm_case' } }, + // Not a user column: the evaluator grants nobody for it, so it is not + // named as one. + { value: 'subject', dependentValues: { recipient_type: 'field', object_name: 'crm_case' } }, + ], + { dataSource: { find: vi.fn(), getObjectSchema } }, + ); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Escalated from')); + await waitFor(() => expect(unresolvedMark(1)).not.toBeNull()); + expect(cell(1)).toHaveTextContent('subject'); + expect(getObjectSchema).toHaveBeenCalledTimes(1); + expect(getObjectSchema).toHaveBeenCalledWith('crm_case'); + }); + + it('draws a read the viewer is refused as the raw value, muted', async () => { + const find = vi.fn(async () => { + throw new Error('OBJECT_API_DISABLED'); + }); + renderCells('recipient-picker', [{ value: 'usr_1', dependentValues: { recipient_type: 'user' } }], { + dataSource: { find }, + }); + + await waitFor(() => expect(unresolvedMark(0)).not.toBeNull()); + expect(cell(0)).toHaveTextContent('usr_1'); + }); + + it('draws the stored text exactly as before when the call site passes no row', () => { + const find = vi.fn(); + renderCells('recipient-picker', [{ value: 'sales_manager' }], { dataSource: { find } }); + expect(cell(0)).toHaveTextContent('sales_manager'); + expect(unresolvedMark(0)).toBeNull(); + expect(find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 8d8a77e75a..41d9c158a1 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -15,7 +15,7 @@ import { ComponentRegistry, percentDisplayValue, type PercentScale, getRecordDis // `resolveFieldScale` is the same kind of ask: the width an ABSENT `scale` // means is the protocol's answer, not this package's (objectui#9843). import { valueSchemaFor, resolveFieldScale } from '@objectstack/spec/data'; -import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n'; +import { useLocalization, useDisplayLocale, formatDisplayNumber, useObjectLabel } from '@object-ui/i18n'; import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components'; import { Check, Copy, Phone as PhoneIcon, MapPin, CircleQuestionMark, Star } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/react'; @@ -367,6 +367,11 @@ import { readFileValue, readFileValues, type FileValueView } from './widgets/fil // Shared with `FileField` rather than copied into it; deliberately NOT // re-exported below, so this package's published surface is unchanged. import { FileValueAffordance, RestrictedFileAffordance } from './widgets/file-affordance.js'; +// The read cells of the two widget-hinted reference fields (objectui#12613) +// name a stored value from the reads their edit widgets list it from — see +// `./widgets/refLabelDirectory.ts`. Deliberately NOT re-exported below. +import { useObjectRefLabel, useRecipientLabel } from './widgets/refLabelDirectory.js'; +import { objectRefOptionLabel } from './widgets/objectCatalog.js'; /** * Cell renderer props @@ -376,6 +381,18 @@ export interface CellRendererProps { field: FieldMetadata; isEditing?: boolean; onChange?: (value: any) => void; + /** + * The row's values, for a renderer whose value only means something next to + * a sibling column — the cell-side counterpart of the edit widget's + * `FieldWidgetComponentProps.dependentValues` (objectui#12613). The + * `recipient-picker` cell reads the row's `recipient_type` from it, as the + * picker reads the form record's. + * + * A call site that holds the row passes it; a renderer that needs a sibling + * and gets none draws the stored value as the text cell does, so a call site + * that does not pass it changes nothing. + */ + dependentValues?: Record; } // `coerceToSafeValue` lives in `./coerceToSafeValue.ts` (objectui#8580) so @@ -685,6 +702,67 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme return ; } +/** + * The face of a stored reference the directory could NOT name (objectui#12613): + * the raw stored value, muted, never a blank — a deleted record, a renamed + * object, or a read the viewer has no access to. It is drawn with the shipped + * unresolved-reference presentation (`UnresolvedReferenceMark`), so this state + * looks the same here as on a lookup cell. Its tooltip is the stored value + * itself: the lookup family's sentence names a RECORD, which an object or a + * position is not, so it is not borrowed. + */ +function UnresolvedRefText({ text }: { text: string }): React.ReactElement { + return ; +} + +/** + * The read cell of a field declaring `widget: 'object-ref'` (objectui#12613): + * the stored object NAME, drawn as the object's label, with the picker's own + * `Label (name)` spelling as its tooltip so the api name stays one hover away. + * + * The label comes from the catalog `ObjectRefField` lists its options from + * (`loadObjectCatalog`), read once per data source for every row, and is then + * translated through the app bundle by `useObjectLabel` like every other + * object label the console draws. Pending and unaskable values draw as the + * text cell; an unknown name draws muted (`UnresolvedRefText`). + */ +function ObjectRefCellRenderer({ value, field }: CellRendererProps): React.ReactElement { + const ref = useObjectRefLabel(value); + const { objectLabel } = useObjectLabel(); + const safe = coerceToSafeValue(value); + if (isEmptyValue(safe)) return ; + if (ref.state === 'resolved' && typeof value === 'string') { + const label = objectLabel({ name: value, label: ref.label || value }); + return ( + + {label} + + ); + } + if (ref.state === 'unresolved') return ; + return ; +} + +/** + * The read cell of a field declaring `widget: 'recipient-picker'` + * (objectui#12613): the stored recipient id or name, drawn as the label of the + * user, team, business unit, position or user column it names — the label the + * picker shows for the same value. + * + * Which of those it names is the row's `recipient_type`, so the cell needs the + * row: it reads `dependentValues`, and without them it draws the stored text as + * the text cell does. The lookups are the picker's, batched across rows + * (`useRecipientLabel`). An unknown value draws muted (`UnresolvedRefText`). + */ +function RecipientCellRenderer({ value, field, dependentValues }: CellRendererProps): React.ReactElement { + const ref = useRecipientLabel(value, dependentValues); + const safe = coerceToSafeValue(value); + if (isEmptyValue(safe)) return ; + if (ref.state === 'resolved') return ; + if (ref.state === 'unresolved') return ; + return ; +} + /** * Number field cell renderer */ @@ -3268,16 +3346,35 @@ const FORMAT_TO_RENDERER: Record = { 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 the cell names what it references, as the edit widget's readonly branch + * does, 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`. + */ +const WIDGET_CELL_RENDERER_TYPES: ReadonlySet = new Set(['object-ref', 'recipient-picker']); + /** * Resolve the canonical cell-renderer key for a field. Accepts either a * raw type string (back-compat) or a field-metadata object so that * format hints (`format: 'phone'` etc.) can promote a plain `text` - * field to its richer renderer counterpart. + * field to its richer renderer counterpart, and a declared `widget` with + * a read cell of its own (`WIDGET_CELL_RENDERER_TYPES`) to that cell. */ -export function resolveCellRendererType(fieldOrType: string | { type?: string; format?: string } | null | undefined): string { +export function resolveCellRendererType(fieldOrType: string | { type?: string; format?: string; widget?: string } | null | undefined): string { if (!fieldOrType) return 'text'; if (typeof fieldOrType === 'string') return fieldOrType; const baseType = (fieldOrType.type || '').toLowerCase(); + const widget = fieldOrType.widget; + if (typeof widget === 'string' && WIDGET_CELL_RENDERER_TYPES.has(widget) && TEXTUAL_BASE_TYPES.has(baseType)) { + return widget; + } const formatRaw = fieldOrType.format; const format = typeof formatRaw === 'string' ? formatRaw.toLowerCase() : ''; if (format && FORMAT_TO_RENDERER[format] && TEXTUAL_BASE_TYPES.has(baseType)) { @@ -3987,6 +4084,10 @@ registerFieldRenderer('user', UserCellRenderer); // The pointer pair's default face (objectui#12045): the record id as text. A // host that can read records registers its own face under the same key. registerFieldRenderer(RECORD_POINTER_CARD_TYPE, RecordPointerTextCellRenderer); +// The read cells of the two widget-hinted reference fields (objectui#12613), +// under the widget keys `resolveCellRendererType` promotes them to. +registerFieldRenderer('object-ref', ObjectRefCellRenderer); +registerFieldRenderer('recipient-picker', RecipientCellRenderer); // `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/packages/fields/src/widgets/ObjectRefField.tsx b/packages/fields/src/widgets/ObjectRefField.tsx index d07261113d..5787e2e0f5 100644 --- a/packages/fields/src/widgets/ObjectRefField.tsx +++ b/packages/fields/src/widgets/ObjectRefField.tsx @@ -4,6 +4,7 @@ import { SchemaRendererContext } from '@object-ui/react'; import type { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; import { useFieldTranslation } from './useFieldTranslation.js'; +import { loadObjectCatalog, objectRefOptionLabel, type ObjectHeader } from './objectCatalog.js'; /** * ObjectRefField — object-name picker for form fields that store the machine @@ -14,18 +15,11 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * the platform's registered objects instead of a free-text input where the * admin has to remember a machine name. * - * Object catalog source, in order of preference: - * 1. `dataSource.getObjects()` — the metadata-registry list endpoint - * (`GET /api/v1/meta/object`), which merges code- and DB-defined objects. - * 2. Fallback: `dataSource.find('sys_metadata', { type: 'object' })` — the - * same source the Setup app's object grid browses. + * The object catalog is `loadObjectCatalog` (`./objectCatalog.ts`), the read + * the list cell names a stored object from as well (objectui#12613). * The stored value is always the object's `name` string. If the catalog can't * load, the current value is still shown, so the control degrades safely. */ -interface ObjectHeader { - name: string; - label?: string; -} export function ObjectRefField({ value, @@ -51,23 +45,7 @@ export function ObjectRefField({ let cancelled = false; (async () => { try { - let rows: ObjectHeader[] = []; - if (typeof dataSource.getObjects === 'function') { - rows = (await dataSource.getObjects()) ?? []; - } else if (typeof dataSource.find === 'function') { - const res = await dataSource.find('sys_metadata', { - $filter: { type: 'object' }, - $top: 1000, - }); - const list: any[] = - res?.data ?? res?.records ?? (Array.isArray(res) ? res : []); - rows = list - .map((r) => ({ - name: String(r?.name ?? ''), - label: r?.label != null ? String(r.label) : undefined, - })) - .filter((r) => r.name); - } + const rows = await loadObjectCatalog(dataSource); if (!cancelled) setObjects(rows); } catch { if (!cancelled) setObjects([]); @@ -81,7 +59,7 @@ export function ObjectRefField({ const options = React.useMemo(() => { const opts = (objects ?? []).map((o) => ({ value: o.name, - label: o.label ? `${o.label} (${o.name})` : o.name, + label: objectRefOptionLabel(o.name, o.label), })); // Keep the current value selectable even if the catalog didn't include it // (unknown / not-yet-published object), so editing never drops it. diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx index a7306a8b90..959ba0f4b7 100644 --- a/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/packages/fields/src/widgets/RecipientPickerField.tsx @@ -4,6 +4,14 @@ import { MetadataCtx, SchemaRendererContext } from '@object-ui/react'; import type { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; import { useFieldTranslation } from './useFieldTranslation.js'; +// The kind table is shared with the read cell (objectui#12613), so a kind +// reads the same in the list and in this picker. +import { + FIELD_RECIPIENT_TYPE, + TYPE_TO_OBJECT, + TYPE_TO_REGISTRY, + recipientCandidateLabel, +} from './recipientKinds.js'; /** * RecipientPickerField — dependent record picker for a polymorphic recipient @@ -37,65 +45,6 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * for one type is meaningless for another). Unknown types degrade to a plain * text input so nothing breaks. */ -interface RecipientMapping { - object: string; - /** Which record field to persist into recipient_id. */ - storeField: 'id' | 'name'; - /** Candidate display-label fields, in preference order. */ - labelFields: string[]; - /** - * i18n key for the "choose one" placeholder. Keyed per type rather than - * interpolating the enum value into an English sentence — "Select a - * business unit" and "选择业务单元" share no structure (objectstack#3821). - */ - placeholderKey: string; -} - -const TYPE_TO_OBJECT: Record = { - user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' }, - team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' }, - business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' }, - unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' }, -}; - -/** A recipient kind whose candidates are catalog items of one metadata type. */ -interface RegistryRecipientMapping { - /** The metadata type listed — the registry's `GET /api/v1/meta/`. */ - type: string; - /** Catalog items are referenced by machine name (ADR-0131 D4). */ - storeField: 'name'; - /** Candidate display-label keys, in preference order. */ - labelFields: string[]; - /** i18n key for the "choose one" placeholder (see {@link RecipientMapping}). */ - placeholderKey: string; -} - -/** - * The recipient kinds whose candidates come from the registry (objectui#7611). - * - * ADR-0131 D3 gives positions one home, the environment registry, and D7 says - * a picker LISTS that registry: one source, with no second list of - * `sys_position` rows merged in. The read goes through the console's metadata - * store (`useMetadata().ensureType`), the same reader that serves apps and - * objects, so the list is fetched once per session and shared with every - * other registry consumer. - */ -const TYPE_TO_REGISTRY: Record = { - position: { type: 'position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' }, -}; - -/** - * The RECORD-RELATIVE recipient kind (maintainer ruling objectstack#14103, - * executor objectstack#15072). It is deliberately absent from - * `TYPE_TO_OBJECT`: there is no target object to query and no record id to - * store. `recipient_id` holds the NAME of a user-valued column on the SHARED - * object, and the recipients are whoever that column names on each matched - * record — so this mode reads the object chosen in the sibling `object_name` - * field (the same dependency the `filter-condition` widget already reads) and - * offers that object's user-valued fields. - */ -const FIELD_RECIPIENT_TYPE = 'field'; - /** A user-valued column offered by the `field` recipient mode. */ interface UserFieldDef { /** The machine name — this is what gets stored in `recipient_id`. */ @@ -289,10 +238,7 @@ export function RecipientPickerField({ }; }, [dataSource, objectName, canListObjectFields]); - const labelOf = (r: any): string => { - for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]); - return String(r?.id ?? ''); - }; + const labelOf = (r: any): string => recipientCandidateLabel(mapping?.labelFields, r); const valueOf = (r: any): string => String(r?.[mapping?.storeField ?? 'id'] ?? ''); const options = React.useMemo(() => { diff --git a/packages/fields/src/widgets/objectCatalog.ts b/packages/fields/src/widgets/objectCatalog.ts new file mode 100644 index 0000000000..de173d2782 --- /dev/null +++ b/packages/fields/src/widgets/objectCatalog.ts @@ -0,0 +1,67 @@ +/** + * 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 registered-object catalog an `object-ref` field reads (objectui#12613). + * + * ONE read, two readers: `ObjectRefField` (the edit widget) lists the catalog as + * its options, and the read cell (`ObjectRefCellRenderer`, through + * `refLabelDirectory.ts`) names a stored object from it. Deliberately not + * re-exported from the package barrel, and free of React so the barrel can + * import it without pulling the widget in. + */ + +/** One registered object, as the catalog lists it. */ +export interface ObjectHeader { + name: string; + label?: string; +} + +/** + * Load the platform's registered objects, in order of preference: + * 1. `dataSource.getObjects()` — the metadata-registry list endpoint + * (`GET /api/v1/meta/object`), which merges code- and DB-defined objects. + * 2. Fallback: `dataSource.find('sys_metadata', { type: 'object' })` — the + * same source the Setup app's object grid browses. + * + * Resolves `[]` for a data source that offers neither read. A failed read + * REJECTS, and each caller decides what a failure means for its face. + */ +export async function loadObjectCatalog(dataSource: any): Promise { + if (typeof dataSource?.getObjects === 'function') { + return (await dataSource.getObjects()) ?? []; + } + if (typeof dataSource?.find === 'function') { + const res = await dataSource.find('sys_metadata', { + $filter: { type: 'object' }, + $top: 1000, + }); + const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []); + return list + .map((r) => ({ + name: String(r?.name ?? ''), + label: r?.label != null ? String(r.label) : undefined, + })) + .filter((r) => r.name); + } + return []; +} + +/** Whether `dataSource` offers one of the reads {@link loadObjectCatalog} makes. */ +export function canLoadObjectCatalog(dataSource: any): boolean { + return typeof dataSource?.getObjects === 'function' || typeof dataSource?.find === 'function'; +} + +/** + * An object's name as the picker spells it, `Label (name)`, or the bare name + * when the catalog gives it no label. The picker uses it for each option and + * the read cell for its tooltip, so the two faces spell an object one way. + */ +export function objectRefOptionLabel(name: string, label: string | undefined): string { + return label ? `${label} (${name})` : name; +} diff --git a/packages/fields/src/widgets/recipientKinds.ts b/packages/fields/src/widgets/recipientKinds.ts new file mode 100644 index 0000000000..39ae41338b --- /dev/null +++ b/packages/fields/src/widgets/recipientKinds.ts @@ -0,0 +1,102 @@ +/** + * 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 recipient kinds a `recipient-picker` field stores, and where each kind's + * candidates and labels come from (objectui#12613). + * + * ONE table, two readers: `RecipientPickerField` (the edit widget) lists a + * kind's candidates from it, and the read cell (`RecipientCellRenderer`, through + * `refLabelDirectory.ts`) names a stored recipient from it. They lived inside + * the widget until the cell needed the same answer; a second copy beside the + * cell is the drift this module exists to prevent, so a kind added here reaches + * both faces at once. Deliberately not re-exported from the package barrel: it + * is the two faces' shared rule, not a published surface. + * + * No React here, so the barrel can import it without pulling the widget in. + */ + +export interface RecipientMapping { + object: string; + /** Which record field to persist into recipient_id. */ + storeField: 'id' | 'name'; + /** Candidate display-label fields, in preference order. */ + labelFields: string[]; + /** + * i18n key for the "choose one" placeholder. Keyed per type rather than + * interpolating the enum value into an English sentence — "Select a + * business unit" and "选择业务单元" share no structure (objectstack#3821). + */ + placeholderKey: string; +} + +/** + * The recipient kinds whose candidates are RECORDS of one directory object, + * stored by the record field the evaluator matches on: + * + * user → sys_user, store `id` + * team → sys_team, store `id` + * business_unit → sys_business_unit, store `id` + * unit_and_subordinates → sys_business_unit, store `id` + */ +export const TYPE_TO_OBJECT: Readonly> = { + user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' }, + team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' }, + business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' }, + unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' }, +}; + +/** A recipient kind whose candidates are catalog items of one metadata type. */ +export interface RegistryRecipientMapping { + /** The metadata type listed — the registry's `GET /api/v1/meta/`. */ + type: string; + /** Catalog items are referenced by machine name (ADR-0131 D4). */ + storeField: 'name'; + /** Candidate display-label keys, in preference order. */ + labelFields: string[]; + /** i18n key for the "choose one" placeholder (see {@link RecipientMapping}). */ + placeholderKey: string; +} + +/** + * The recipient kinds whose candidates come from the registry (objectui#7611). + * + * ADR-0131 D3 gives positions one home, the environment registry, and D7 says + * a picker LISTS that registry: one source, with no second list of + * `sys_position` rows merged in. The read goes through the console's metadata + * store (`useMetadata().ensureType`), the same reader that serves apps and + * objects, so the list is fetched once per session and shared with every + * other registry consumer. + */ +export const TYPE_TO_REGISTRY: Readonly> = { + position: { type: 'position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' }, +}; + +/** + * The RECORD-RELATIVE recipient kind (maintainer ruling objectstack#14103, + * executor objectstack#15072). It is deliberately absent from + * `TYPE_TO_OBJECT`: there is no target object to query and no record id to + * store. `recipient_id` holds the NAME of a user-valued column on the SHARED + * object, and the recipients are whoever that column names on each matched + * record — so this mode reads the object chosen in the sibling `object_name` + * field (the same dependency the `filter-condition` widget already reads) and + * offers that object's user-valued fields. + */ +export const FIELD_RECIPIENT_TYPE = 'field'; + +/** + * The display label of one candidate: the first non-empty member of the + * kind's `labelFields`, then the record id. The picker labels its options with + * it and the read cell names a stored recipient with it, so a recipient reads + * the same in the list and in the open picker. + */ +export function recipientCandidateLabel(labelFields: readonly string[] | undefined, row: unknown): string { + const r = row as Record | null | undefined; + for (const f of labelFields ?? ['name']) if (r?.[f]) return String(r[f]); + return String(r?.id ?? ''); +} diff --git a/packages/fields/src/widgets/refLabelDirectory.ts b/packages/fields/src/widgets/refLabelDirectory.ts new file mode 100644 index 0000000000..7cb393b72c --- /dev/null +++ b/packages/fields/src/widgets/refLabelDirectory.ts @@ -0,0 +1,388 @@ +/** + * 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 cells' label directory for the two widget-hinted reference fields + * (objectui#12613): `object-ref` (a stored object name) and `recipient-picker` + * (a stored recipient id or name, whose kind a sibling `recipient_type` names). + * + * ## Same reads as the edit widgets, batched across rows + * + * The cells name a stored value from exactly what the edit widget lists: + * + * - `object-ref` → the registered-object catalog, `loadObjectCatalog` + * (`./objectCatalog.ts`), the read `ObjectRefField` makes; + * - a record kind (`user`, `team`, `business_unit`, `unit_and_subordinates`) + * → the kind's directory object and label fields from `TYPE_TO_OBJECT` + * (`./recipientKinds.ts`), the table `RecipientPickerField` lists from; + * - a registry kind (`position`) → the console's metadata store, + * `ensureType()`, the reader `RecipientPickerField` lists it with; + * - the `field` kind → the shared object's schema through + * `deriveUserFields`, the picker's own column derivation. + * + * A list draws one cell per row, so nothing here is read per cell. A catalog + * (the object list, a registry type, one object's user columns) is read once + * per data source; record kinds are collected for one microtask and read with + * one `$in` query per directory object. A grid page of fifty rules costs one + * catalog read and at most one query per directory object, not fifty. + * + * ## The four answers a cell gets + * + * - `unasked` — nothing can be asked: no data source, no sibling kind, a + * kind the picker does not know. The cell draws the stored text exactly as + * the text cell did before it existed, so a call site that cannot supply + * what the cell needs changes nothing. + * - `pending` — asked, not answered yet. Drawn the same as `unasked`. + * - `resolved` — the label. + * - `unresolved` — asked and not found (a deleted record, a renamed + * object), or the read failed (no read access). The cell draws the raw + * stored value, muted, never a blank. + * + * ## Cache lifetime + * + * Answers live for the data source's lifetime, which is the page session: a + * display label, not a decision input, so an item renamed mid-session reading + * its old label until reload is the right trade (the same one the approver + * directory in `@object-ui/app-shell` makes). Keyed by the data source object, + * so two hosts, and two tests, never share an answer. + */ + +import React from 'react'; +import { MetadataCtx, SchemaRendererContext } from '@object-ui/react'; +import { extractRecords } from '@object-ui/core'; +import { canLoadObjectCatalog, loadObjectCatalog } from './objectCatalog.js'; +import { + FIELD_RECIPIENT_TYPE, + TYPE_TO_OBJECT, + TYPE_TO_REGISTRY, + recipientCandidateLabel, +} from './recipientKinds.js'; +import { deriveUserFields } from './RecipientPickerField.js'; + +/** What a read cell knows about one stored reference. See the module doc. */ +export type RefLabel = + | { readonly state: 'unasked' } + | { readonly state: 'pending' } + | { readonly state: 'resolved'; readonly label: string } + | { readonly state: 'unresolved' }; + +const UNASKED: RefLabel = Object.freeze({ state: 'unasked' }); +const PENDING: RefLabel = Object.freeze({ state: 'pending' }); +const UNRESOLVED: RefLabel = Object.freeze({ state: 'unresolved' }); + +const resolved = (label: string): RefLabel => Object.freeze({ state: 'resolved', label }); + +/** At most this many ids go into one `$in` query. */ +const RECORD_QUERY_CHUNK = 100; + +/** A name → label list read once, answering every name asked of it. */ +interface Catalog { + map: ReadonlyMap | null; + failed: boolean; + /** Entry key → the name it asks, for the asks that arrived while loading. */ + waiting: Map; +} + +/** Every answer one data source has given, and the reads still in flight. */ +class LabelBook { + private readonly entries = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly catalogs = new Map(); + /** Record kind → id → entry key, waiting for the next flush. */ + private recordQueue = new Map>(); + private flushScheduled = false; + private publishScheduled = false; + + constructor(private readonly dataSource: any) {} + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + read(key: string): RefLabel | undefined { + return this.entries.get(key); + } + + private publish(): void { + for (const listener of [...this.listeners]) listener(); + } + + /** + * An answer written while a subscriber was registering is not seen by + * React's store hook until a listener fires, so it is published on the + * next microtask, once for every answer written in the same turn. + */ + private publishSoon(): void { + if (this.publishScheduled) return; + this.publishScheduled = true; + queueMicrotask(() => { + this.publishScheduled = false; + this.publish(); + }); + } + + /** Ask a catalog for `name`; it is loaded by `load` the first time. */ + askCatalog( + catalogId: string, + name: string, + key: string, + load: () => Promise>, + ): void { + if (this.entries.has(key)) return; + const known = this.catalogs.get(catalogId); + if (known && (known.map || known.failed)) { + this.entries.set(key, settleFromCatalog(known, name)); + this.publishSoon(); + return; + } + this.entries.set(key, PENDING); + if (known) { + known.waiting.set(key, name); + return; + } + const catalog: Catalog = { map: null, failed: false, waiting: new Map([[key, name]]) }; + this.catalogs.set(catalogId, catalog); + let loading: Promise>; + try { + loading = load(); + } catch (err) { + loading = Promise.reject(err); + } + loading + .then( + (map) => { + catalog.map = map; + }, + () => { + catalog.failed = true; + }, + ) + .then(() => { + for (const [waitingKey, waitingName] of catalog.waiting) { + this.entries.set(waitingKey, settleFromCatalog(catalog, waitingName)); + } + catalog.waiting.clear(); + this.publish(); + }); + } + + /** Ask for one record of a record kind; asks are batched per object. */ + askRecord(kind: string, id: string, key: string): void { + if (this.entries.has(key)) return; + this.entries.set(key, PENDING); + let ids = this.recordQueue.get(kind); + if (!ids) { + ids = new Map(); + this.recordQueue.set(kind, ids); + } + ids.set(id, key); + if (this.flushScheduled) return; + this.flushScheduled = true; + queueMicrotask(() => { + this.flushScheduled = false; + void this.flushRecords(); + }); + } + + private async flushRecords(): Promise { + const queue = this.recordQueue; + this.recordQueue = new Map(); + // One query per directory OBJECT: `business_unit` and + // `unit_and_subordinates` share `sys_business_unit`, so their ids go out + // together. + const byObject = new Map }>(); + for (const [kind, ids] of queue) { + const mapping = TYPE_TO_OBJECT[kind]; + if (!mapping) continue; + let group = byObject.get(mapping.object); + if (!group) { + group = { storeField: mapping.storeField, asks: [] }; + byObject.set(mapping.object, group); + } + for (const [id, key] of ids) group.asks.push({ kind, id, key }); + } + await Promise.all( + [...byObject].map(async ([object, group]) => { + const rowsById = new Map(); + let failed = false; + try { + const ids = [...new Set(group.asks.map((a) => a.id))]; + for (let i = 0; i < ids.length; i += RECORD_QUERY_CHUNK) { + const chunk = ids.slice(i, i + RECORD_QUERY_CHUNK); + const res = await this.dataSource.find(object, { + $filter: { [group.storeField]: { $in: chunk } }, + $top: chunk.length, + }); + for (const row of extractRecords(res)) { + const stored = (row as Record | null)?.[group.storeField]; + if (stored != null) rowsById.set(String(stored), row); + } + } + } catch { + failed = true; + } + for (const ask of group.asks) { + const row = failed ? undefined : rowsById.get(ask.id); + this.entries.set( + ask.key, + row === undefined + ? UNRESOLVED + : resolved(recipientCandidateLabel(TYPE_TO_OBJECT[ask.kind]?.labelFields, row)), + ); + } + this.publish(); + }), + ); + } +} + +function settleFromCatalog(catalog: Catalog, name: string): RefLabel { + if (catalog.failed || !catalog.map) return UNRESOLVED; + const label = catalog.map.get(name); + return label === undefined ? UNRESOLVED : resolved(label); +} + +const books = new WeakMap(); + +function bookFor(dataSource: object): LabelBook { + let book = books.get(dataSource); + if (!book) { + book = new LabelBook(dataSource); + books.set(dataSource, book); + } + return book; +} + +const keyOf = (...parts: string[]): string => JSON.stringify(parts); + +const noSubscription = (): (() => void) => () => {}; +const readUnasked = (): RefLabel => UNASKED; + +/** The registered object a stored `object-ref` value names. */ +export function useObjectRefLabel(value: unknown): RefLabel { + const ctx = React.useContext(SchemaRendererContext); + const dataSource: any = ctx?.dataSource ?? null; + const name = typeof value === 'string' ? value : ''; + const book = name && dataSource && canLoadObjectCatalog(dataSource) ? bookFor(dataSource) : null; + const key = keyOf('object', name); + + const subscribe = React.useCallback( + (listener: () => void) => { + if (!book) return noSubscription(); + const off = book.subscribe(listener); + book.askCatalog('object', name, key, async () => { + const rows = await loadObjectCatalog(dataSource); + return new Map(rows.map((row) => [row.name, row.label ?? ''])); + }); + return off; + }, + [book, dataSource, name, key], + ); + const getSnapshot = React.useCallback(() => (book ? book.read(key) ?? PENDING : UNASKED), [book, key]); + return React.useSyncExternalStore(book ? subscribe : noSubscription, book ? getSnapshot : readUnasked, book ? getSnapshot : readUnasked); +} + +/** One ask a stored recipient turns into, or `null` when nothing can be asked. */ +type RecipientAsk = + | { kind: 'record'; recipientType: string; id: string; key: string } + | { kind: 'registry'; recipientType: string; name: string; key: string } + | { kind: 'field'; objectName: string; name: string; key: string }; + +function recipientAskOf( + value: unknown, + dependentValues: Readonly> | undefined, + dataSource: any, + hasMetadataStore: boolean, +): RecipientAsk | null { + if (!dependentValues || typeof value !== 'string' || value === '') return null; + // The same sibling reads the edit widget makes: `recipient_type` picks the + // kind, and in `field` mode `object_name` names the object whose columns + // are offered — both declared in the field's own `dependsOn`. + const recipientType = String(dependentValues.recipient_type ?? ''); + const record = TYPE_TO_OBJECT[recipientType]; + if (record) { + if (!dataSource || typeof dataSource.find !== 'function') return null; + return { kind: 'record', recipientType, id: value, key: keyOf('record', recipientType, value) }; + } + const registry = TYPE_TO_REGISTRY[recipientType]; + if (registry) { + // Outside a metadata store the picker degrades to a text input, and the + // cell to the text it drew before. + if (!hasMetadataStore) return null; + return { kind: 'registry', recipientType, name: value, key: keyOf('registry', registry.type, value) }; + } + if (recipientType === FIELD_RECIPIENT_TYPE) { + const objectName = String(dependentValues.object_name ?? ''); + if (!objectName || !dataSource || typeof dataSource.getObjectSchema !== 'function') return null; + return { kind: 'field', objectName, name: value, key: keyOf('field', objectName, value) }; + } + return null; +} + +/** + * The recipient a stored `recipient-picker` value names. `dependentValues` is + * the row's sibling values, as the edit widget gets them; without them the kind + * is unknown and nothing is asked. + */ +export function useRecipientLabel( + value: unknown, + dependentValues: Readonly> | undefined, +): RefLabel { + const ctx = React.useContext(SchemaRendererContext); + const dataSource: any = ctx?.dataSource ?? null; + // Read raw, as the picker reads it: an absent store and an empty registry + // are different facts. Through a ref, because the store's context value is + // rebuilt whenever any type it caches settles (AGENTS.md #10). + const metadataStore = React.useContext(MetadataCtx); + const metadataStoreRef = React.useRef(metadataStore); + metadataStoreRef.current = metadataStore; + + const ask = recipientAskOf(value, dependentValues, dataSource, !!metadataStore); + const book = ask && dataSource ? bookFor(dataSource) : null; + const key = ask?.key ?? ''; + // The ask's primitives, so the subscription is keyed on data, not on the + // `ask` object this render built. + const askKind = ask?.kind ?? ''; + const askTarget = ask ? (ask.kind === 'field' ? ask.objectName : ask.recipientType) : ''; + const askValue = ask ? (ask.kind === 'record' ? ask.id : ask.name) : ''; + + const subscribe = React.useCallback( + (listener: () => void) => { + if (!book) return noSubscription(); + const off = book.subscribe(listener); + if (askKind === 'record') { + book.askRecord(askTarget, askValue, key); + } else if (askKind === 'registry') { + const registry = TYPE_TO_REGISTRY[askTarget]; + book.askCatalog(`registry:${registry.type}`, askValue, key, async () => { + const store = metadataStoreRef.current; + const items = store ? await store.ensureType(registry.type) : []; + const map = new Map(); + for (const item of Array.isArray(items) ? items : []) { + if (typeof item?.name === 'string' && item.name) { + map.set(item.name, recipientCandidateLabel(registry.labelFields, item)); + } + } + return map; + }); + } else if (askKind === 'field') { + book.askCatalog(`field:${askTarget}`, askValue, key, async () => { + const schema = await dataSource.getObjectSchema(askTarget); + return new Map(deriveUserFields(schema).map((f) => [f.name, f.label])); + }); + } + return off; + }, + [book, dataSource, key, askKind, askTarget, askValue], + ); + const getSnapshot = React.useCallback(() => (book ? book.read(key) ?? PENDING : UNASKED), [book, key]); + return React.useSyncExternalStore(book ? subscribe : noSubscription, book ? getSnapshot : readUnasked, book ? getSnapshot : readUnasked); +} diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 1b079a10bb..accea7f104 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -3783,7 +3783,14 @@ export const ObjectGrid: React.FC = ({ // Both answers, from the one shared resolve (objectui#8920): // `baseInferredType` is the DECLARED type the inline editor reads, // `inferredType` the renderer key it promotes to. - const { rendererType: inferredType, Renderer } = resolveGridCellRendering({ type: baseInferredType, format: formatHint }); + // The `widget` hint promotes the same way (objectui#12613): an + // `object-ref` / `recipient-picker` text column draws the label of + // what it references, through the cell its widget names. + const { rendererType: inferredType, Renderer } = resolveGridCellRendering({ + type: baseInferredType, + format: formatHint, + widget: objectDefField?.widget, + }); const CellRenderer = withheld ? MaskedCellRenderer : inferredType ? Renderer : null; // Build field metadata for cell renderers with objectDef enrichment @@ -3851,7 +3858,7 @@ export const ObjectGrid: React.FC = ({ // Both link and action: link takes priority for navigation, action executes on secondary interaction cellRenderer = (value: any, row: any) => { const displayContent = LinkContentRenderer - ? + ? : (value != null && value !== '' ? String(value) : ); return ( = ({ // Link column: clicking navigates to the record detail cellRenderer = (value: any, row: any) => { const displayContent = LinkContentRenderer - ? + ? : (value != null && value !== '' ? String(value) : ); return ( = ({ ); }; } else if (CellRenderer) { - // Type-only cell renderer (no link/action) - cellRenderer = (value: any) => ( - + // Type-only cell renderer (no link/action). The row rides as + // `dependentValues` for a cell whose value only means something + // beside a sibling column (objectui#12613). + cellRenderer = (value: any, row: any) => ( + ); } else { // Default renderer with empty value handling @@ -4037,6 +4046,7 @@ export const ObjectGrid: React.FC = ({ const { declaredType, rendererType, Renderer } = resolveGridCellRendering({ type: withheld ? null : fieldDef?.type || inferColumnType({ field: fieldName }), format: fieldDef?.format, + widget: fieldDef?.widget, }); const CellRenderer = withheld ? MaskedCellRenderer : rendererType ? Renderer : null; @@ -4082,7 +4092,7 @@ export const ObjectGrid: React.FC = ({ if (isPrimaryField && LinkContentRenderer) { cellRenderer = (value: any, row: any) => { - const displayContent = ; + const displayContent = ; return ( = ({ ); } else if (CellRenderer) { - cellRenderer = (value: any) => ; + cellRenderer = (value: any, row: any) => ; } return { @@ -4217,6 +4227,7 @@ export const ObjectGrid: React.FC = ({ const { declaredType, rendererType, Renderer } = resolveGridCellRendering({ type: withheld ? null : fieldDef?.type || inferColumnType({ field: fieldName }), format: fieldDef?.format, + widget: fieldDef?.widget, }); const CellRenderer = withheld ? MaskedCellRenderer : rendererType ? Renderer : null; const header = fieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '); @@ -4264,7 +4275,7 @@ export const ObjectGrid: React.FC = ({ ...(declaredType && { type: declaredType }), ...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }), ...(inferredAlign && { align: inferredAlign }), - ...(CellRenderer && { cell: (value: any) => }), + ...(CellRenderer && { cell: (value: any, row: any) => }), sortable: fieldDef?.sortable !== false, }; }); @@ -4369,7 +4380,7 @@ export const ObjectGrid: React.FC = ({ // Aligned on the RENDERER key, like paths A-C: a `text` column with // `format: 'currency'` renders as currency, so it aligns as currency. ...(!!rendererType && numericTypes.includes(rendererType) && { align: 'right' as const }), - cell: (value: any) => , + cell: (value: any, row: any) => , sortable: field.sortable !== false, }); }); @@ -6317,7 +6328,7 @@ export const ObjectGrid: React.FC = ({ // type, so the dead branch goes with it. const { rendererType, Renderer } = resolveGridCellRendering(fieldDef); if (rendererType) { - return ; + return ; } // Fallback: infer from value and key name diff --git a/packages/plugin-grid/src/cellRendererResolution.ts b/packages/plugin-grid/src/cellRendererResolution.ts index aeb945d862..0ef88d9e0f 100644 --- a/packages/plugin-grid/src/cellRendererResolution.ts +++ b/packages/plugin-grid/src/cellRendererResolution.ts @@ -65,6 +65,13 @@ export interface GridCellFieldLike { type?: string | null; /** The field's `format` hint, as the object definition declares it. */ format?: string | null; + /** + * The field's `widget` hint, as the object definition declares it. A widget + * with a read cell of its own (`object-ref`, `recipient-picker`) promotes a + * textual type to that cell, the same second step `format` takes + * (objectui#12613); the inline editor keeps reading `declaredType`. + */ + widget?: string | null; } /** Everything the grid needs to know about one column's renderer. */ @@ -93,7 +100,8 @@ export interface GridCellRendering { } /** - * Resolve one column's renderer from its declared type and `format` hint. + * Resolve one column's renderer from its declared type and its `format` and + * `widget` hints. * * ⚠️ A field with NO declared type does not promote, even when it carries a * `format`. `resolveCellRendererType` would promote it — `''` is a member of @@ -111,7 +119,11 @@ export function resolveGridCellRendering( const rendererType: string | null = declaredType === null ? null - : resolveCellRendererType({ type: declaredType, format: field?.format ?? undefined }); + : resolveCellRendererType({ + type: declaredType, + format: field?.format ?? undefined, + widget: field?.widget ?? undefined, + }); return { declaredType, rendererType, From 264c38029c857808f8f619c0cf07f24b79fdd637 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:28:48 +0000 Subject: [PATCH 2/5] test(fields,plugin-grid,plugin-detail): pin the widget-hinted read cells on every grid path and classify them in the cell censuses (objectui#12613) The grid case draws a neutral object declaring the same two widget hints through column paths A-D, with a plain text control column holding the same strings. The object-literal and Badge-fit censuses name the two new registered types. The directory and catalog modules read the DataSource type instead of any; README and changeset describe the new cells and the optional dependentValues prop. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12613-widget-ref-read-cells.md | 14 ++ packages/fields/README.md | 18 +- .../cellRenderers.objectLiteral-8596.test.tsx | 7 + packages/fields/src/widgets/objectCatalog.ts | 21 +- .../fields/src/widgets/refLabelDirectory.ts | 19 +- .../summaryChip.badgeFitCensus-8464.test.tsx | 9 +- .../widgetHintedColumns-12613.test.tsx | 211 ++++++++++++++++++ 7 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 .changeset/12613-widget-ref-read-cells.md create mode 100644 packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx diff --git a/.changeset/12613-widget-ref-read-cells.md b/.changeset/12613-widget-ref-read-cells.md new file mode 100644 index 0000000000..aeb5aecbae --- /dev/null +++ b/.changeset/12613-widget-ref-read-cells.md @@ -0,0 +1,14 @@ +--- +'@object-ui/fields': minor +'@object-ui/plugin-grid': minor +--- + +A list names what an `object-ref` or `recipient-picker` field references, instead of printing its api name (objectui#12613). The Sharing Rules list read `crm_account` and `sales_manager` where the edit form reads "Account" and "Sales Manager". + +- **The declared `widget` picks the read cell.** `resolveCellRendererType` now promotes a textual field declaring `widget: 'object-ref'` or `widget: 'recipient-picker'` to a read cell registered under the widget's key, the same second step a `format` hint takes. A widget with no read cell of its own (`filter-condition`, `position-ref`, …) still draws as its `type`, and nothing is keyed on an object or field name. +- **`object-ref`** draws the object's label, translated through the app bundle like every other object label, with the picker's `Label (name)` spelling as its tooltip. The catalog is the one `ObjectRefField` lists its options from, read once per data source for the whole list. +- **`recipient-picker`** draws the label of the user, team, business unit, position or user column the stored value names, read with the picker's own kind table: one `$in` query per directory object for the whole page, the metadata store for positions, and the shared object's schema for the `field` kind. +- **A value nothing names** (a deleted record, a renamed object, a read the viewer is refused) draws as the raw stored value, muted, with the shared unresolved-reference mark. It is never blank. +- **`CellRendererProps` gains one optional member, `dependentValues`**: the row's values, the cell-side counterpart of the edit widget's `dependentValues`. The recipient cell reads the row's `recipient_type` from it. `ObjectGrid` passes the row on every column path; a call site that passes nothing draws the stored text exactly as before. + +Stored values do not change. The edit widgets list and store exactly what they did: their catalog read and kind table moved into modules both faces share. diff --git a/packages/fields/README.md b/packages/fields/README.md index 5af8b09c84..da3e25b155 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -42,18 +42,28 @@ View components use `getCellRenderer` to resolve the correct component for a fie ```tsx import { getCellRenderer, resolveCellRendererType, type CellRendererProps } from '@object-ui/fields'; -const MyGridCell = ({ field, value }: CellRendererProps) => { +const MyGridCell = ({ field, value, row }: CellRendererProps & { row: Record }) => { // Resolve the renderer KEY first, then the renderer. A field's declared // `type` is not always the renderer's key: a textual field carrying a // format hint (`Field.text({ format: 'phone' })`) resolves to the richer - // renderer. Passing `field.type` raw skips that mapping and draws such a + // renderer, and one declaring a widget with a read cell of its own + // (`widget: 'object-ref'`, `widget: 'recipient-picker'`) resolves to that + // cell. Passing `field.type` raw skips that mapping and draws such a // column as bare text, silently. Resolving first is never worse: with no - // format hint the resolver returns the declared `type` unchanged. + // hint the resolver returns the declared `type` unchanged. const Renderer = getCellRenderer(resolveCellRendererType(field)); - return ; + // `dependentValues` is the row, for a cell whose value only means something + // beside a sibling column: the `recipient-picker` cell reads the row's + // `recipient_type` from it. Without it, that cell draws the stored text. + return ; }; ``` +The `object-ref` and `recipient-picker` cells name what the stored value references +with the same reads their edit widgets list it from, made once per data source for the +whole list rather than once per cell. A value nothing names draws as the raw stored +value, muted. + ## Standard Field Types Supported types out of the box: diff --git a/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx b/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx index 39ae0b9097..c89a1b62fa 100644 --- a/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx +++ b/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx @@ -292,6 +292,11 @@ const OBJECT_LITERAL_CENSUS: ReadonlyArray = ['vector', [1, 2, 3]], ['grid', [[1]]], ['record_pointer_card', { objectName: 'invoice', recordId: 'inv_1' }], + ['object-ref', 'crm_account'], + ['recipient-picker', 'sales_manager'], ]; describe('objectui#8596 — an object literal is not a cell value, and these renderers invented one', () => { diff --git a/packages/fields/src/widgets/objectCatalog.ts b/packages/fields/src/widgets/objectCatalog.ts index de173d2782..9fee61d3ef 100644 --- a/packages/fields/src/widgets/objectCatalog.ts +++ b/packages/fields/src/widgets/objectCatalog.ts @@ -16,6 +16,14 @@ * import it without pulling the widget in. */ +import type { DataSource } from '@object-ui/types'; + +/** The two reads the catalog can be loaded through. */ +export type ObjectCatalogSource = Partial>; + +/** One `sys_metadata` row, as far as the catalog reads it. */ +type CatalogRow = { name?: unknown; label?: unknown } | null | undefined; + /** One registered object, as the catalog lists it. */ export interface ObjectHeader { name: string; @@ -32,16 +40,19 @@ export interface ObjectHeader { * Resolves `[]` for a data source that offers neither read. A failed read * REJECTS, and each caller decides what a failure means for its face. */ -export async function loadObjectCatalog(dataSource: any): Promise { +export async function loadObjectCatalog( + dataSource: ObjectCatalogSource | null | undefined, +): Promise { if (typeof dataSource?.getObjects === 'function') { return (await dataSource.getObjects()) ?? []; } if (typeof dataSource?.find === 'function') { - const res = await dataSource.find('sys_metadata', { + // The rows ladder `ObjectRefField` has always read here, moved verbatim. + const res = (await dataSource.find('sys_metadata', { $filter: { type: 'object' }, $top: 1000, - }); - const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []); + })) as unknown as { data?: CatalogRow[]; records?: CatalogRow[] } | CatalogRow[] | null | undefined; + const list: CatalogRow[] = Array.isArray(res) ? res : (res?.data ?? res?.records ?? []); return list .map((r) => ({ name: String(r?.name ?? ''), @@ -53,7 +64,7 @@ export async function loadObjectCatalog(dataSource: any): Promise void): (() => void) => { this.listeners.add(listener); @@ -252,7 +253,7 @@ function settleFromCatalog(catalog: Catalog, name: string): RefLabel { const books = new WeakMap(); -function bookFor(dataSource: object): LabelBook { +function bookFor(dataSource: DataSource): LabelBook { let book = books.get(dataSource); if (!book) { book = new LabelBook(dataSource); @@ -269,7 +270,7 @@ const readUnasked = (): RefLabel => UNASKED; /** The registered object a stored `object-ref` value names. */ export function useObjectRefLabel(value: unknown): RefLabel { const ctx = React.useContext(SchemaRendererContext); - const dataSource: any = ctx?.dataSource ?? null; + const dataSource: DataSource | null = ctx?.dataSource ?? null; const name = typeof value === 'string' ? value : ''; const book = name && dataSource && canLoadObjectCatalog(dataSource) ? bookFor(dataSource) : null; const key = keyOf('object', name); @@ -299,7 +300,7 @@ type RecipientAsk = function recipientAskOf( value: unknown, dependentValues: Readonly> | undefined, - dataSource: any, + dataSource: DataSource | null, hasMetadataStore: boolean, ): RecipientAsk | null { if (!dependentValues || typeof value !== 'string' || value === '') return null; @@ -337,13 +338,17 @@ export function useRecipientLabel( dependentValues: Readonly> | undefined, ): RefLabel { const ctx = React.useContext(SchemaRendererContext); - const dataSource: any = ctx?.dataSource ?? null; + const dataSource: DataSource | null = ctx?.dataSource ?? null; // Read raw, as the picker reads it: an absent store and an empty registry // are different facts. Through a ref, because the store's context value is // rebuilt whenever any type it caches settles (AGENTS.md #10). const metadataStore = React.useContext(MetadataCtx); const metadataStoreRef = React.useRef(metadataStore); - metadataStoreRef.current = metadataStore; + // Kept current from an effect, which runs before the store subscription's + // own effect below, so an ask always reads the store of the latest commit. + React.useEffect(() => { + metadataStoreRef.current = metadataStore; + }); const ask = recipientAskOf(value, dependentValues, dataSource, !!metadataStore); const book = ask && dataSource ? bookFor(dataSource) : null; @@ -356,7 +361,7 @@ export function useRecipientLabel( const subscribe = React.useCallback( (listener: () => void) => { - if (!book) return noSubscription(); + if (!book || !dataSource) return noSubscription(); const off = book.subscribe(listener); if (askKind === 'record') { book.askRecord(askTarget, askValue, key); diff --git a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx index bc98a8d6d1..c46a3a13bb 100644 --- a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx +++ b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx @@ -157,6 +157,11 @@ const CENSUS: ReadonlyArray v === 'fit'); - expect(fitting.length, 'the fitting side is not empty').toBe(36); + // 36 → 38: `object-ref` and `recipient-picker` joined the fit side + // (objectui#12613). + expect(fitting.length, 'the fitting side is not empty').toBe(38); for (const [type] of fitting) { const { container } = renderChip(type); diff --git a/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx b/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx new file mode 100644 index 0000000000..b13c6d7d7f --- /dev/null +++ b/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx @@ -0,0 +1,211 @@ +/** + * 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. + */ + +/** + * objectui#12613 — a grid column whose field declares `widget: 'object-ref'` + * or `widget: 'recipient-picker'` names what the stored value references, on + * EVERY column path `ObjectGrid` can pick. + * + * The Sharing Rules list is the measured case: the platform's `sys_sharing_rule` + * declares `object_name` with `widget: 'object-ref'` and `recipient_id` with + * `widget: 'recipient-picker'` (`dependsOn: ['recipient_type', 'object_name']`), + * and its list views name those columns as strings. The grid resolved each + * column from `type` and `format` alone, so both drew through the text cell and + * the list read `crm_account` / `sales_manager` where the edit form reads + * "Account" / "Sales Manager". + * + * The object here is NOT `sys_sharing_rule`, on purpose: the cell is chosen by + * the declared widget, so a neutral object declaring the same two hints is the + * honest subject, and nothing in the grid may key on an object or field name. + * + * ## The control + * + * `note` is a plain `text` column holding the SAME strings the two hinted + * columns hold (`sales_manager`, `crm_account`). It must still print them raw: + * the naming is driven by the declaration, not by the value. And the recipient + * cell needs the ROW (its `recipient_type`), which only a call site holding the + * row can hand it — so each path below is its own case (the paths are listed in + * `formatHintedColumnRenderer-8920.test.tsx`). + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { + assertNoOtherNetworkEscape, + installRecordSecurityExplainDouble, +} from '@object-ui/test-support'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, MetadataCtx, SchemaRendererProvider } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = vi.fn(() => false) as any; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +// The record overlay's explain call is served from a double, as in +// `formatHintedColumnRenderer-8920.test.tsx`; nothing here reads it. +beforeEach(() => installRecordSecurityExplainDouble(vi)); +afterEach(() => { + assertNoOtherNetworkEscape(expect); + cleanup(); + vi.unstubAllGlobals(); +}); + +const RULE_SCHEMA = { + name: 'access_rule', + label: 'Access Rule', + fields: { + label: { type: 'text', label: 'Label' }, + object_name: { type: 'text', label: 'Object', widget: 'object-ref' }, + recipient_type: { + type: 'select', + label: 'Recipient Type', + options: [ + { value: 'position', label: 'Position' }, + { value: 'team', label: 'Team' }, + ], + }, + recipient_id: { + type: 'text', + label: 'Recipient', + widget: 'recipient-picker', + dependsOn: ['recipient_type', 'object_name'], + }, + note: { type: 'text', label: 'Note' }, + }, +}; + +const ROWS = [ + { + id: 'r-1', + label: 'Account managers', + object_name: 'crm_account', + recipient_type: 'position', + recipient_id: 'sales_manager', + note: 'sales_manager', + }, + { + id: 'r-2', + label: 'EU team', + object_name: 'crm_account', + recipient_type: 'team', + recipient_id: 'team_eu', + note: 'crm_account', + }, +]; + +function makeDataSource() { + return { + find: vi.fn(async (object: string) => { + if (object === 'sys_team') return { data: [{ id: 'team_eu', name: 'EU Sales' }] }; + return { data: ROWS, total: ROWS.length }; + }), + getObjectSchema: vi.fn(async () => RULE_SCHEMA), + getObjects: vi.fn(async () => [{ name: 'crm_account', label: 'Account' }]), + } as any; +} + +function metadataStore() { + return { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: vi.fn(async (type: string) => + type === 'position' ? [{ name: 'sales_manager', label: 'Sales Manager' }] : [], + ), + getItem: async () => null, + getItemsByType: () => [], + } as any; +} + +const COLUMN_NAMES = ['label', 'object_name', 'recipient_type', 'recipient_id', 'note']; + +function renderGrid(schemaOverrides: Record, props: Record = {}) { + const dataSource = makeDataSource(); + const utils = render( + + + + + + + , + ); + return { ...utils, dataSource }; +} + +/** + * Both hinted columns named on both rows, and the control printing the same + * strings raw. `getAllByText` counts TEXT nodes, not attributes, so the + * tooltips carrying the api name do not count toward the raw-string tallies. + */ +async function expectReferencesNamed(dataSource: any) { + await waitFor(() => expect(screen.getByText('Sales Manager')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('EU Sales')).toBeInTheDocument()); + await waitFor(() => expect(screen.getAllByText('Account')).toHaveLength(2)); + + const account = screen.getAllByText('Account')[0]; + expect(account.getAttribute('title'), 'the api name stays one hover away').toBe('Account (crm_account)'); + + // The control column: the same strings, undeclared, print raw — once each. + expect(screen.getAllByText('sales_manager')).toHaveLength(1); + expect(screen.getAllByText('crm_account')).toHaveLength(1); + expect(screen.queryByText('team_eu'), 'the team id is named, not printed').toBeNull(); + + // One catalog read and one team query for the whole grid, not one per row. + expect(dataSource.getObjects).toHaveBeenCalledTimes(1); + expect(dataSource.find.mock.calls.filter(([object]: [string]) => object === 'sys_team')).toHaveLength(1); +} + +describe('objectui#12613 — every ObjectGrid path names an `object-ref` / `recipient-picker` value', () => { + it('A: object `columns`', async () => { + const { dataSource } = renderGrid({ + columns: COLUMN_NAMES.map((field) => ({ field })), + data: { provider: 'value', items: ROWS }, + }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectReferencesNamed(dataSource); + }); + + it('B: string `columns` — the shape the platform list views declare', async () => { + const { dataSource } = renderGrid({ + columns: COLUMN_NAMES, + data: { provider: 'value', items: ROWS }, + }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectReferencesNamed(dataSource); + }); + + it('C: inline-data projection (`fields`, no `columns`)', async () => { + const { dataSource } = renderGrid({ + fields: COLUMN_NAMES, + data: { provider: 'value', items: ROWS }, + }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectReferencesNamed(dataSource); + }); + + it('D: object-schema default columns (rows handed down, no projection)', async () => { + const { dataSource } = renderGrid({}, { data: ROWS }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectReferencesNamed(dataSource); + }); +}); From 9e586c5e0c9feb50eee518fa2b4512f24b670c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:03:20 +0000 Subject: [PATCH 3/5] perf(fields): the read cells name a recipient from the picker's own list, through one catalog mechanism (objectui#12613) Every packages/fields module rides the console's eager ui-components chunk, and the batched $in reader for the record kinds put that chunk over its per-chunk ceiling. The record kinds now read the picker's one candidate query (moved into recipientKinds as loadRecipientCandidates and shared with the picker), so all five lists go through one catalog-per-data-source mechanism and the directory is half its minified size. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12613-widget-ref-read-cells.md | 2 +- .../__tests__/widgetRefCells-12613.test.tsx | 7 +- .../src/widgets/RecipientPickerField.tsx | 11 +- packages/fields/src/widgets/objectCatalog.ts | 5 - packages/fields/src/widgets/recipientKinds.ts | 25 ++ .../fields/src/widgets/refLabelDirectory.ts | 377 +++++------------- 6 files changed, 143 insertions(+), 284 deletions(-) diff --git a/.changeset/12613-widget-ref-read-cells.md b/.changeset/12613-widget-ref-read-cells.md index aeb5aecbae..497391a240 100644 --- a/.changeset/12613-widget-ref-read-cells.md +++ b/.changeset/12613-widget-ref-read-cells.md @@ -7,7 +7,7 @@ A list names what an `object-ref` or `recipient-picker` field references, instea - **The declared `widget` picks the read cell.** `resolveCellRendererType` now promotes a textual field declaring `widget: 'object-ref'` or `widget: 'recipient-picker'` to a read cell registered under the widget's key, the same second step a `format` hint takes. A widget with no read cell of its own (`filter-condition`, `position-ref`, …) still draws as its `type`, and nothing is keyed on an object or field name. - **`object-ref`** draws the object's label, translated through the app bundle like every other object label, with the picker's `Label (name)` spelling as its tooltip. The catalog is the one `ObjectRefField` lists its options from, read once per data source for the whole list. -- **`recipient-picker`** draws the label of the user, team, business unit, position or user column the stored value names, read with the picker's own kind table: one `$in` query per directory object for the whole page, the metadata store for positions, and the shared object's schema for the `field` kind. +- **`recipient-picker`** draws the label of the user, team, business unit, position or user column the stored value names, from the very list the picker offers: its candidate query for the record kinds, the metadata store for positions, and the shared object's schema for the `field` kind. Each list is read once per data source for the whole page. - **A value nothing names** (a deleted record, a renamed object, a read the viewer is refused) draws as the raw stored value, muted, with the shared unresolved-reference mark. It is never blank. - **`CellRendererProps` gains one optional member, `dependentValues`**: the row's values, the cell-side counterpart of the edit widget's `dependentValues`. The recipient cell reads the row's `recipient_type` from it. `ObjectGrid` passes the row on every column path; a call site that passes nothing draws the stored text exactly as before. diff --git a/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx index b238ade219..32ba00faf4 100644 --- a/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx +++ b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx @@ -22,7 +22,7 @@ * - the promotion is driven by the DECLARED widget, never by the value: the * same value under a plain `text` field still draws as text; * - the reads are the edit widgets' reads, made once per page, not once per - * cell — each batching case counts the calls; + * cell — each case counts the calls; * - a value nothing could name draws muted, never blank, and a cell that * could not ask (no row, no data source) draws the text it drew before. */ @@ -170,7 +170,7 @@ describe('objectui#12613 — the `recipient-picker` cell', () => { expect(store.ensureType).toHaveBeenCalledWith('position'); }); - it('names record recipients with ONE `$in` query per directory object, and marks the one it could not find', async () => { + it("names record recipients from ONE read of the picker's own candidate query, and marks the one it does not list", async () => { const find = vi.fn(async (_object: string, _params: any) => ({ data: [ { id: 'team_eu', name: 'EU Sales' }, @@ -195,7 +195,8 @@ describe('objectui#12613 — the `recipient-picker` cell', () => { expect(find, 'one query for the three rows, not one per cell').toHaveBeenCalledTimes(1); const [object, params] = find.mock.calls[0]; expect(object).toBe('sys_team'); - expect(params.$filter.id.$in.sort()).toEqual(['team_eu', 'team_gone', 'team_us']); + // The picker's query, so the list names exactly what the open picker offers. + expect(params).toEqual({ $top: 500, $orderby: { name: 'asc' } }); }); it("names a `field` recipient by the shared object's user column label", async () => { diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx index 959ba0f4b7..c603ad9909 100644 --- a/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/packages/fields/src/widgets/RecipientPickerField.tsx @@ -10,6 +10,7 @@ import { FIELD_RECIPIENT_TYPE, TYPE_TO_OBJECT, TYPE_TO_REGISTRY, + loadRecipientCandidates, recipientCandidateLabel, } from './recipientKinds.js'; @@ -172,13 +173,9 @@ export function RecipientPickerField({ let cancelled = false; (async () => { try { - // Object form, not the 'name asc' clause string: the clause string is - // supported again (objectstack#3821 fixed ApiDataSource walking it - // character by character), but the structured form can't regress that - // way for any data source. - const res = await dataSource.find(recordMapping.object, { $top: 500, $orderby: { name: 'asc' } }); - const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []); - if (!cancelled) setRecords(Array.isArray(list) ? list : []); + // The one candidate query, shared with the read cell (objectui#12613). + const list = await loadRecipientCandidates(dataSource, recordMapping); + if (!cancelled) setRecords(list); } catch { if (!cancelled) setRecords([]); } diff --git a/packages/fields/src/widgets/objectCatalog.ts b/packages/fields/src/widgets/objectCatalog.ts index 9fee61d3ef..c5721ef6b9 100644 --- a/packages/fields/src/widgets/objectCatalog.ts +++ b/packages/fields/src/widgets/objectCatalog.ts @@ -63,11 +63,6 @@ export async function loadObjectCatalog( return []; } -/** Whether `dataSource` offers one of the reads {@link loadObjectCatalog} makes. */ -export function canLoadObjectCatalog(dataSource: ObjectCatalogSource | null | undefined): boolean { - return typeof dataSource?.getObjects === 'function' || typeof dataSource?.find === 'function'; -} - /** * An object's name as the picker spells it, `Label (name)`, or the bare name * when the catalog gives it no label. The picker uses it for each option and diff --git a/packages/fields/src/widgets/recipientKinds.ts b/packages/fields/src/widgets/recipientKinds.ts index 39ae41338b..ecaff9da31 100644 --- a/packages/fields/src/widgets/recipientKinds.ts +++ b/packages/fields/src/widgets/recipientKinds.ts @@ -21,6 +21,8 @@ * No React here, so the barrel can import it without pulling the widget in. */ +import type { DataSource } from '@object-ui/types'; + export interface RecipientMapping { object: string; /** Which record field to persist into recipient_id. */ @@ -89,6 +91,29 @@ export const TYPE_TO_REGISTRY: Readonly */ export const FIELD_RECIPIENT_TYPE = 'field'; +/** + * The candidate records of one record kind: the picker's one query, at most 500 + * rows ordered by name. The picker lists them and the read cell names a stored + * recipient from them, so the cell names exactly what the open picker offers. + * + * Object form, not the 'name asc' clause string: the clause string is + * supported again (objectstack#3821 fixed ApiDataSource walking it character + * by character), but the structured form can't regress that way for any data + * source. A failed read REJECTS; each caller decides what that means. + */ +export async function loadRecipientCandidates( + dataSource: Pick, + mapping: RecipientMapping, +): Promise { + const res = (await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } })) as unknown as + | { data?: unknown[]; records?: unknown[] } + | unknown[] + | null + | undefined; + const list = Array.isArray(res) ? res : (res?.data ?? res?.records ?? []); + return Array.isArray(list) ? list : []; +} + /** * The display label of one candidate: the first non-empty member of the * kind's `labelFields`, then the record id. The picker labels its options with diff --git a/packages/fields/src/widgets/refLabelDirectory.ts b/packages/fields/src/widgets/refLabelDirectory.ts index 2859435041..7da8616c93 100644 --- a/packages/fields/src/widgets/refLabelDirectory.ts +++ b/packages/fields/src/widgets/refLabelDirectory.ts @@ -11,37 +11,38 @@ * (objectui#12613): `object-ref` (a stored object name) and `recipient-picker` * (a stored recipient id or name, whose kind a sibling `recipient_type` names). * - * ## Same reads as the edit widgets, batched across rows + * ## The edit widgets' own lists, read once per page * - * The cells name a stored value from exactly what the edit widget lists: + * A cell names a stored value from the very list its edit widget offers: * * - `object-ref` → the registered-object catalog, `loadObjectCatalog` * (`./objectCatalog.ts`), the read `ObjectRefField` makes; * - a record kind (`user`, `team`, `business_unit`, `unit_and_subordinates`) - * → the kind's directory object and label fields from `TYPE_TO_OBJECT` - * (`./recipientKinds.ts`), the table `RecipientPickerField` lists from; + * → `loadRecipientCandidates` (`./recipientKinds.ts`), the picker's one + * candidate query, labelled with the kind's `labelFields`; * - a registry kind (`position`) → the console's metadata store, * `ensureType()`, the reader `RecipientPickerField` lists it with; * - the `field` kind → the shared object's schema through * `deriveUserFields`, the picker's own column derivation. * - * A list draws one cell per row, so nothing here is read per cell. A catalog - * (the object list, a registry type, one object's user columns) is read once - * per data source; record kinds are collected for one microtask and read with - * one `$in` query per directory object. A grid page of fifty rules costs one - * catalog read and at most one query per directory object, not fifty. + * Each list is a CATALOG: read the first time any cell asks it, once per data + * source, and every cell of every row answered from it. A grid page of fifty + * rules costs one read per list it names, not fifty — and a value reads in the + * list exactly as it reads in the open picker, including the picker's bound (a + * record past the candidate query's first 500 is not offered, and not named). * * ## The four answers a cell gets * - * - `unasked` — nothing can be asked: no data source, no sibling kind, a - * kind the picker does not know. The cell draws the stored text exactly as - * the text cell did before it existed, so a call site that cannot supply - * what the cell needs changes nothing. + * - `unasked` — the edit widget could not list it either: no data source, no + * sibling kind, a kind it does not know, no metadata store for a registry + * kind. The cell draws the stored text exactly as the text cell did before + * it existed, so a call site that cannot supply what the cell needs + * changes nothing. * - `pending` — asked, not answered yet. Drawn the same as `unasked`. * - `resolved` — the label. - * - `unresolved` — asked and not found (a deleted record, a renamed - * object), or the read failed (no read access). The cell draws the raw - * stored value, muted, never a blank. + * - `unresolved` — the list was read and does not hold the value (a deleted + * record, a renamed object), or the read failed (no read access). The cell + * draws the raw stored value, muted, never a blank. * * ## Cache lifetime * @@ -50,17 +51,26 @@ * its old label until reload is the right trade (the same one the approver * directory in `@object-ui/app-shell` makes). Keyed by the data source object, * so two hosts, and two tests, never share an answer. + * + * ## Why this module is small + * + * Every `packages/fields` module rides the console's eager `ui-components` + * chunk, whatever imports it lazily (`scripts/vite-ineffective-dynamic-imports.ts` + * says why), and that chunk has a ceiling of its own in + * `scripts/check-eager-closure-budget.mjs`. One catalog mechanism serves all + * five lists for that reason: a second one (a batched `$in` reader for the + * record kinds) cost a page-load payload the budget refused. */ import React from 'react'; import { MetadataCtx, SchemaRendererContext } from '@object-ui/react'; -import { extractRecords } from '@object-ui/core'; import type { DataSource } from '@object-ui/types'; -import { canLoadObjectCatalog, loadObjectCatalog } from './objectCatalog.js'; +import { loadObjectCatalog } from './objectCatalog.js'; import { FIELD_RECIPIENT_TYPE, TYPE_TO_OBJECT, TYPE_TO_REGISTRY, + loadRecipientCandidates, recipientCandidateLabel, } from './recipientKinds.js'; import { deriveUserFields } from './RecipientPickerField.js'; @@ -76,30 +86,15 @@ const UNASKED: RefLabel = Object.freeze({ state: 'unasked' }); const PENDING: RefLabel = Object.freeze({ state: 'pending' }); const UNRESOLVED: RefLabel = Object.freeze({ state: 'unresolved' }); -const resolved = (label: string): RefLabel => Object.freeze({ state: 'resolved', label }); - -/** At most this many ids go into one `$in` query. */ -const RECORD_QUERY_CHUNK = 100; - -/** A name → label list read once, answering every name asked of it. */ -interface Catalog { - map: ReadonlyMap | null; - failed: boolean; - /** Entry key → the name it asks, for the asks that arrived while loading. */ - waiting: Map; -} +/** A list's name → label pairs, or `null` when its read failed. */ +type Catalog = ReadonlyMap | null; +type CatalogLoad = () => Promise>; /** Every answer one data source has given, and the reads still in flight. */ class LabelBook { private readonly entries = new Map(); private readonly listeners = new Set<() => void>(); - private readonly catalogs = new Map(); - /** Record kind → id → entry key, waiting for the next flush. */ - private recordQueue = new Map>(); - private flushScheduled = false; - private publishScheduled = false; - - constructor(private readonly dataSource: DataSource) {} + private readonly catalogs = new Map>(); readonly subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); @@ -112,220 +107,76 @@ class LabelBook { return this.entries.get(key); } - private publish(): void { - for (const listener of [...this.listeners]) listener(); - } - /** - * An answer written while a subscriber was registering is not seen by - * React's store hook until a listener fires, so it is published on the - * next microtask, once for every answer written in the same turn. + * Ask catalog `catalogId` for `name`, loading it with `load` the first time + * any cell asks it. The answer is published from a promise callback, so a + * subscriber registering right now always hears it. */ - private publishSoon(): void { - if (this.publishScheduled) return; - this.publishScheduled = true; - queueMicrotask(() => { - this.publishScheduled = false; - this.publish(); - }); - } - - /** Ask a catalog for `name`; it is loaded by `load` the first time. */ - askCatalog( - catalogId: string, - name: string, - key: string, - load: () => Promise>, - ): void { + ask(catalogId: string, name: string, key: string, load: CatalogLoad): void { if (this.entries.has(key)) return; - const known = this.catalogs.get(catalogId); - if (known && (known.map || known.failed)) { - this.entries.set(key, settleFromCatalog(known, name)); - this.publishSoon(); - return; - } this.entries.set(key, PENDING); - if (known) { - known.waiting.set(key, name); - return; + let catalog = this.catalogs.get(catalogId); + if (!catalog) { + catalog = Promise.resolve() + .then(load) + .catch(() => null); + this.catalogs.set(catalogId, catalog); } - const catalog: Catalog = { map: null, failed: false, waiting: new Map([[key, name]]) }; - this.catalogs.set(catalogId, catalog); - let loading: Promise>; - try { - loading = load(); - } catch (err) { - loading = Promise.reject(err); - } - loading - .then( - (map) => { - catalog.map = map; - }, - () => { - catalog.failed = true; - }, - ) - .then(() => { - for (const [waitingKey, waitingName] of catalog.waiting) { - this.entries.set(waitingKey, settleFromCatalog(catalog, waitingName)); - } - catalog.waiting.clear(); - this.publish(); - }); - } - - /** Ask for one record of a record kind; asks are batched per object. */ - askRecord(kind: string, id: string, key: string): void { - if (this.entries.has(key)) return; - this.entries.set(key, PENDING); - let ids = this.recordQueue.get(kind); - if (!ids) { - ids = new Map(); - this.recordQueue.set(kind, ids); - } - ids.set(id, key); - if (this.flushScheduled) return; - this.flushScheduled = true; - queueMicrotask(() => { - this.flushScheduled = false; - void this.flushRecords(); + void catalog.then((map) => { + const label = map?.get(name); + this.entries.set(key, label === undefined ? UNRESOLVED : Object.freeze({ state: 'resolved', label })); + for (const listener of [...this.listeners]) listener(); }); } - - private async flushRecords(): Promise { - const queue = this.recordQueue; - this.recordQueue = new Map(); - // One query per directory OBJECT: `business_unit` and - // `unit_and_subordinates` share `sys_business_unit`, so their ids go out - // together. - const byObject = new Map }>(); - for (const [kind, ids] of queue) { - const mapping = TYPE_TO_OBJECT[kind]; - if (!mapping) continue; - let group = byObject.get(mapping.object); - if (!group) { - group = { storeField: mapping.storeField, asks: [] }; - byObject.set(mapping.object, group); - } - for (const [id, key] of ids) group.asks.push({ kind, id, key }); - } - await Promise.all( - [...byObject].map(async ([object, group]) => { - const rowsById = new Map(); - let failed = false; - try { - const ids = [...new Set(group.asks.map((a) => a.id))]; - for (let i = 0; i < ids.length; i += RECORD_QUERY_CHUNK) { - const chunk = ids.slice(i, i + RECORD_QUERY_CHUNK); - const res = await this.dataSource.find(object, { - $filter: { [group.storeField]: { $in: chunk } }, - $top: chunk.length, - }); - for (const row of extractRecords(res)) { - const stored = (row as Record | null)?.[group.storeField]; - if (stored != null) rowsById.set(String(stored), row); - } - } - } catch { - failed = true; - } - for (const ask of group.asks) { - const row = failed ? undefined : rowsById.get(ask.id); - this.entries.set( - ask.key, - row === undefined - ? UNRESOLVED - : resolved(recipientCandidateLabel(TYPE_TO_OBJECT[ask.kind]?.labelFields, row)), - ); - } - this.publish(); - }), - ); - } -} - -function settleFromCatalog(catalog: Catalog, name: string): RefLabel { - if (catalog.failed || !catalog.map) return UNRESOLVED; - const label = catalog.map.get(name); - return label === undefined ? UNRESOLVED : resolved(label); } -const books = new WeakMap(); +const books = new WeakMap(); function bookFor(dataSource: DataSource): LabelBook { let book = books.get(dataSource); if (!book) { - book = new LabelBook(dataSource); + book = new LabelBook(); books.set(dataSource, book); } return book; } -const keyOf = (...parts: string[]): string => JSON.stringify(parts); - const noSubscription = (): (() => void) => () => {}; -const readUnasked = (): RefLabel => UNASKED; - -/** The registered object a stored `object-ref` value names. */ -export function useObjectRefLabel(value: unknown): RefLabel { - const ctx = React.useContext(SchemaRendererContext); - const dataSource: DataSource | null = ctx?.dataSource ?? null; - const name = typeof value === 'string' ? value : ''; - const book = name && dataSource && canLoadObjectCatalog(dataSource) ? bookFor(dataSource) : null; - const key = keyOf('object', name); +/** + * The one store subscription both cells make: `name` asked of the catalog + * `catalogId` names (`''` = nothing can be asked), loaded by `load`. `load` is + * read through a ref so a render's fresh closure never re-subscribes a cell. + */ +function useCatalogLabel(dataSource: DataSource | null, catalogId: string, name: string, load: CatalogLoad): RefLabel { + const book = catalogId && dataSource ? bookFor(dataSource) : null; + const key = JSON.stringify([catalogId, name]); + const loadRef = React.useRef(load); + // An effect, so it runs before the subscription's own effect below. + React.useEffect(() => { + loadRef.current = load; + }); const subscribe = React.useCallback( (listener: () => void) => { if (!book) return noSubscription(); const off = book.subscribe(listener); - book.askCatalog('object', name, key, async () => { - const rows = await loadObjectCatalog(dataSource); - return new Map(rows.map((row) => [row.name, row.label ?? ''])); - }); + book.ask(catalogId, name, key, () => loadRef.current()); return off; }, - [book, dataSource, name, key], + [book, catalogId, name, key], ); const getSnapshot = React.useCallback(() => (book ? book.read(key) ?? PENDING : UNASKED), [book, key]); - return React.useSyncExternalStore(book ? subscribe : noSubscription, book ? getSnapshot : readUnasked, book ? getSnapshot : readUnasked); + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } -/** One ask a stored recipient turns into, or `null` when nothing can be asked. */ -type RecipientAsk = - | { kind: 'record'; recipientType: string; id: string; key: string } - | { kind: 'registry'; recipientType: string; name: string; key: string } - | { kind: 'field'; objectName: string; name: string; key: string }; - -function recipientAskOf( - value: unknown, - dependentValues: Readonly> | undefined, - dataSource: DataSource | null, - hasMetadataStore: boolean, -): RecipientAsk | null { - if (!dependentValues || typeof value !== 'string' || value === '') return null; - // The same sibling reads the edit widget makes: `recipient_type` picks the - // kind, and in `field` mode `object_name` names the object whose columns - // are offered — both declared in the field's own `dependsOn`. - const recipientType = String(dependentValues.recipient_type ?? ''); - const record = TYPE_TO_OBJECT[recipientType]; - if (record) { - if (!dataSource || typeof dataSource.find !== 'function') return null; - return { kind: 'record', recipientType, id: value, key: keyOf('record', recipientType, value) }; - } - const registry = TYPE_TO_REGISTRY[recipientType]; - if (registry) { - // Outside a metadata store the picker degrades to a text input, and the - // cell to the text it drew before. - if (!hasMetadataStore) return null; - return { kind: 'registry', recipientType, name: value, key: keyOf('registry', registry.type, value) }; - } - if (recipientType === FIELD_RECIPIENT_TYPE) { - const objectName = String(dependentValues.object_name ?? ''); - if (!objectName || !dataSource || typeof dataSource.getObjectSchema !== 'function') return null; - return { kind: 'field', objectName, name: value, key: keyOf('field', objectName, value) }; - } - return null; +/** The registered object a stored `object-ref` value names. */ +export function useObjectRefLabel(value: unknown): RefLabel { + const dataSource = React.useContext(SchemaRendererContext)?.dataSource ?? null; + const canList = typeof dataSource?.getObjects === 'function' || typeof dataSource?.find === 'function'; + return useCatalogLabel(dataSource, typeof value === 'string' && value && canList ? 'object' : '', String(value), async () => { + const rows = await loadObjectCatalog(dataSource); + return new Map(rows.map((row) => [row.name, row.label ?? ''])); + }); } /** @@ -337,57 +188,47 @@ export function useRecipientLabel( value: unknown, dependentValues: Readonly> | undefined, ): RefLabel { - const ctx = React.useContext(SchemaRendererContext); - const dataSource: DataSource | null = ctx?.dataSource ?? null; + const dataSource = React.useContext(SchemaRendererContext)?.dataSource ?? null; // Read raw, as the picker reads it: an absent store and an empty registry // are different facts. Through a ref, because the store's context value is // rebuilt whenever any type it caches settles (AGENTS.md #10). const metadataStore = React.useContext(MetadataCtx); - const metadataStoreRef = React.useRef(metadataStore); - // Kept current from an effect, which runs before the store subscription's - // own effect below, so an ask always reads the store of the latest commit. + const storeRef = React.useRef(metadataStore); React.useEffect(() => { - metadataStoreRef.current = metadataStore; + storeRef.current = metadataStore; + }); + // The same sibling reads the edit widget makes: `recipient_type` picks the + // kind, and in `field` mode `object_name` names the object whose columns + // are offered — both declared in the field's own `dependsOn`. + const kind = String(dependentValues?.recipient_type ?? ''); + const objectName = String(dependentValues?.object_name ?? ''); + const record = TYPE_TO_OBJECT[kind]; + const registry = TYPE_TO_REGISTRY[kind]; + const catalogId = + !dependentValues || typeof value !== 'string' || !value + ? '' + : record + ? typeof dataSource?.find === 'function' ? `record:${kind}` : '' + : registry + ? metadataStore ? `registry:${registry.type}` : '' + : kind === FIELD_RECIPIENT_TYPE && objectName && typeof dataSource?.getObjectSchema === 'function' + ? `field:${objectName}` + : ''; + return useCatalogLabel(dataSource, catalogId, String(value), async () => { + // `catalogId` is '' without a data source, so no load runs without one. + if (!dataSource) return new Map(); + if (record) { + const rows = await loadRecipientCandidates(dataSource, record); + return new Map(rows.map((row) => [String((row as Record)?.[record.storeField] ?? ''), recipientCandidateLabel(record.labelFields, row)])); + } + if (registry) { + const items = (await storeRef.current?.ensureType(registry.type)) ?? []; + return new Map( + (Array.isArray(items) ? items : []) + .filter((item) => typeof item?.name === 'string' && item.name) + .map((item) => [item.name as string, recipientCandidateLabel(registry.labelFields, item)]), + ); + } + return new Map(deriveUserFields(await dataSource.getObjectSchema(objectName)).map((f) => [f.name, f.label])); }); - - const ask = recipientAskOf(value, dependentValues, dataSource, !!metadataStore); - const book = ask && dataSource ? bookFor(dataSource) : null; - const key = ask?.key ?? ''; - // The ask's primitives, so the subscription is keyed on data, not on the - // `ask` object this render built. - const askKind = ask?.kind ?? ''; - const askTarget = ask ? (ask.kind === 'field' ? ask.objectName : ask.recipientType) : ''; - const askValue = ask ? (ask.kind === 'record' ? ask.id : ask.name) : ''; - - const subscribe = React.useCallback( - (listener: () => void) => { - if (!book || !dataSource) return noSubscription(); - const off = book.subscribe(listener); - if (askKind === 'record') { - book.askRecord(askTarget, askValue, key); - } else if (askKind === 'registry') { - const registry = TYPE_TO_REGISTRY[askTarget]; - book.askCatalog(`registry:${registry.type}`, askValue, key, async () => { - const store = metadataStoreRef.current; - const items = store ? await store.ensureType(registry.type) : []; - const map = new Map(); - for (const item of Array.isArray(items) ? items : []) { - if (typeof item?.name === 'string' && item.name) { - map.set(item.name, recipientCandidateLabel(registry.labelFields, item)); - } - } - return map; - }); - } else if (askKind === 'field') { - book.askCatalog(`field:${askTarget}`, askValue, key, async () => { - const schema = await dataSource.getObjectSchema(askTarget); - return new Map(deriveUserFields(schema).map((f) => [f.name, f.label])); - }); - } - return off; - }, - [book, dataSource, key, askKind, askTarget, askValue], - ); - const getSnapshot = React.useCallback(() => (book ? book.read(key) ?? PENDING : UNASKED), [book, key]); - return React.useSyncExternalStore(book ? subscribe : noSubscription, book ? getSnapshot : readUnasked, book ? getSnapshot : readUnasked); } From 0458f1a95bd37e4ac92bfe09ed05bd632c8e6d76 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:29:34 +0000 Subject: [PATCH 4/5] perf(fields,app-shell): the widget reference faces load lazily from app-shell, so the eager ui-components chunk keeps only the rule and the keys (objectui#12613) Every packages/fields module rides the console's eager ui-components chunk whatever imports it lazily, and that chunk's per-chunk ceiling refused the label directory. @object-ui/fields now keeps the widget promotion, the two keys with a text default face (the RECORD_POINTER_CARD_TYPE arrangement) and the optional dependentValues prop, and publishes the edit widgets' reads and kind table plus UnresolvedReferenceMark. @object-ui/app-shell registers the naming faces under the two keys and loads them, with the directory, lazily on the first cell that draws one. The grid test now probes the grid's half of the contract (key and row on every path); the faces are pinned in app-shell. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12613-widget-ref-read-cells.md | 11 +- packages/app-shell/package.json | 2 + packages/app-shell/src/index.ts | 4 + .../widget-ref-cells/WidgetRefCellFaces.tsx | 86 ++++++ .../WidgetRefCellFaces-12613.test.tsx | 241 +++++++++++++++++ .../widget-ref-cells}/refLabelDirectory.ts | 66 ++--- .../widget-ref-cell-renderers.tsx | 50 ++++ packages/fields/README.md | 15 +- .../__tests__/widgetRefCells-12613.test.tsx | 252 ++++-------------- packages/fields/src/index.tsx | 109 +++----- .../src/widgets/RecipientPickerField.tsx | 8 +- packages/fields/src/widgets/objectCatalog.ts | 9 +- packages/fields/src/widgets/recipientKinds.ts | 20 +- .../widgetHintedColumns-12613.test.tsx | 136 +++++----- 14 files changed, 601 insertions(+), 408 deletions(-) create mode 100644 packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx create mode 100644 packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx rename packages/{fields/src/widgets => app-shell/src/views/widget-ref-cells}/refLabelDirectory.ts (81%) create mode 100644 packages/app-shell/src/views/widget-ref-cells/widget-ref-cell-renderers.tsx diff --git a/.changeset/12613-widget-ref-read-cells.md b/.changeset/12613-widget-ref-read-cells.md index 497391a240..c97d34b4bc 100644 --- a/.changeset/12613-widget-ref-read-cells.md +++ b/.changeset/12613-widget-ref-read-cells.md @@ -1,14 +1,15 @@ --- '@object-ui/fields': minor '@object-ui/plugin-grid': minor +'@object-ui/app-shell': minor --- A list names what an `object-ref` or `recipient-picker` field references, instead of printing its api name (objectui#12613). The Sharing Rules list read `crm_account` and `sales_manager` where the edit form reads "Account" and "Sales Manager". -- **The declared `widget` picks the read cell.** `resolveCellRendererType` now promotes a textual field declaring `widget: 'object-ref'` or `widget: 'recipient-picker'` to a read cell registered under the widget's key, the same second step a `format` hint takes. A widget with no read cell of its own (`filter-condition`, `position-ref`, …) still draws as its `type`, and nothing is keyed on an object or field name. -- **`object-ref`** draws the object's label, translated through the app bundle like every other object label, with the picker's `Label (name)` spelling as its tooltip. The catalog is the one `ObjectRefField` lists its options from, read once per data source for the whole list. -- **`recipient-picker`** draws the label of the user, team, business unit, position or user column the stored value names, from the very list the picker offers: its candidate query for the record kinds, the metadata store for positions, and the shared object's schema for the `field` kind. Each list is read once per data source for the whole page. +- **The declared `widget` picks the read cell (`@object-ui/fields`).** `resolveCellRendererType` now promotes a textual field declaring `widget: 'object-ref'` or `widget: 'recipient-picker'` to a cell key of that name, the same second step a `format` hint takes. Both keys are registered with the stored text as their default face, as `RECORD_POINTER_CARD_TYPE` is. A widget with no read cell of its own (`filter-condition`, `position-ref`, …) still draws as its `type`, and nothing is keyed on an object or field name. +- **`CellRendererProps` gains one optional member, `dependentValues`**: the row's values, the cell-side counterpart of the edit widget's `dependentValues`. `ObjectGrid` (`@object-ui/plugin-grid`) passes the row, and the widget hint, on every column path. A call site that passes nothing draws the stored text exactly as before. +- **The edit widgets' reads are published** so a host face names a value from the very lists the widgets offer: `loadObjectCatalog`, `objectRefOptionLabel`, `RECIPIENT_RECORD_KINDS`, `RECIPIENT_REGISTRY_KINDS`, `FIELD_RECIPIENT_TYPE`, `loadRecipientCandidates` and `recipientCandidateLabel`, plus the shared `UnresolvedReferenceMark` drawing. `ObjectRefField` and `RecipientPickerField` read through them and list and store exactly what they did. +- **The console's faces (`@object-ui/app-shell`)** register under the two keys and load lazily, on the first cell that draws one, so a page without such a column pays nothing on load. `object-ref` draws the object's label, translated through the app bundle like every other object label, with the picker's `Label (name)` spelling as its tooltip. `recipient-picker` draws the label of the user, team, business unit, position or user column the stored value names, from the list the picker offers: its candidate query for the record kinds, the metadata store for positions, the shared object's schema for the `field` kind. Each list is read once per data source for the whole page. - **A value nothing names** (a deleted record, a renamed object, a read the viewer is refused) draws as the raw stored value, muted, with the shared unresolved-reference mark. It is never blank. -- **`CellRendererProps` gains one optional member, `dependentValues`**: the row's values, the cell-side counterpart of the edit widget's `dependentValues`. The recipient cell reads the row's `recipient_type` from it. `ObjectGrid` passes the row on every column path; a call site that passes nothing draws the stored text exactly as before. -Stored values do not change. The edit widgets list and store exactly what they did: their catalog read and kind table moved into modules both faces share. +Stored values do not change. diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 4ff8bccc9a..abfb973ef7 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -21,6 +21,7 @@ "./dist/views/record-approvals-renderer.js", "./dist/views/record-attachments-renderer.js", "./dist/views/record-preview/record-pointer-card-renderer.js", + "./dist/views/widget-ref-cells/widget-ref-cell-renderers.js", "./dist/views/studio-design/studio-canvas-preview.js", "./src/index.ts", "./src/views/app-launcher-renderer.tsx", @@ -40,6 +41,7 @@ "./src/views/record-approvals-renderer.tsx", "./src/views/record-attachments-renderer.tsx", "./src/views/record-preview/record-pointer-card-renderer.tsx", + "./src/views/widget-ref-cells/widget-ref-cell-renderers.tsx", "./src/views/studio-design/studio-canvas-preview.tsx", "./src/styles.css" ], diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index f82f8bf572..2776e8c387 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -359,6 +359,10 @@ import './views/record-approval-decision-renderer.js'; // pair, registered under `@object-ui/fields`' `RECORD_POINTER_CARD_TYPE` // (objectui#12045). import './views/record-preview/record-pointer-card-renderer.js'; +// The faces of `@object-ui/fields`' widget-hinted reference keys, +// `object-ref` and `recipient-picker`, loaded lazily on the first cell that +// draws one (objectui#12613). +import './views/widget-ref-cells/widget-ref-cell-renderers.js'; // `global:search` / `global:notifications` — the two spec `PageComponentType` // members the 2026-08-26 ruling on objectstack#12183 kept declared because both // data sources shipped (`f99932a42`). Registered here, not in diff --git a/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx b/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx new file mode 100644 index 0000000000..4bfcf8f481 --- /dev/null +++ b/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx @@ -0,0 +1,86 @@ +/** + * 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 console's faces for the two widget-hinted reference keys of + * `@object-ui/fields` (objectui#12613). A textual field declaring + * `widget: 'object-ref'` or `widget: 'recipient-picker'` resolves to that key + * (`resolveCellRendererType`), and these name what the stored value references + * instead of printing the api name — the Sharing Rules list read `crm_account` + * and `sales_manager` where the edit form reads "Account" and "Sales Manager". + * + * Loaded lazily by `./widget-ref-cell-renderers.tsx`; see `./refLabelDirectory.ts` + * for where the labels come from and why the faces live in this package. + */ + +import * as React from 'react'; +import { + TextCellRenderer, + UnresolvedReferenceMark, + coerceToSafeValue, + objectRefOptionLabel, + type CellRendererProps, +} from '@object-ui/fields'; +import { EmptyValue } from '@object-ui/components'; +import { isEmptyValue } from '@object-ui/core'; +import { useObjectLabel } from '@object-ui/i18n'; +import { useObjectRefLabel, useRecipientLabel } from './refLabelDirectory.js'; + +/** + * The stored value the directory could NOT name: raw and muted, never a blank, + * in the shipped unresolved-reference presentation, so this state looks the + * same here as on a lookup cell. Its tooltip is the stored value itself: the + * lookup family's sentence names a RECORD, which an object or a position is not. + */ +function Unresolved({ text }: { text: string }) { + return ; +} + +/** + * `object-ref`: the object's label, translated through the app bundle like + * every other object label, with the picker's own `Label (name)` spelling as + * its tooltip so the api name stays one hover away. + */ +export function ObjectRefCellFace({ value, field }: CellRendererProps) { + const ref = useObjectRefLabel(value); + const { objectLabel } = useObjectLabel(); + const safe = coerceToSafeValue(value); + if (isEmptyValue(safe)) return ; + if (ref.state === 'resolved' && typeof value === 'string') { + const label = objectLabel({ name: value, label: ref.label || value }); + return ( + + {label} + + ); + } + if (ref.state === 'unresolved') return ; + return ; +} + +/** + * `recipient-picker`: the label of the user, team, business unit, position or + * user column the stored value names — the label the picker shows for it. + * Which of those it names is the row's `recipient_type`, so the face reads + * `dependentValues`; without them it draws the stored text as the text cell + * does. + */ +export function RecipientCellFace({ value, field, dependentValues }: CellRendererProps) { + const ref = useRecipientLabel(value, dependentValues); + const safe = coerceToSafeValue(value); + if (isEmptyValue(safe)) return ; + if (ref.state === 'resolved') { + return ( + + {ref.label} + + ); + } + if (ref.state === 'unresolved') return ; + return ; +} diff --git a/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx b/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx new file mode 100644 index 0000000000..69e327c209 --- /dev/null +++ b/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx @@ -0,0 +1,241 @@ +/** + * 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. + */ + +/** + * objectui#12613 — the console's faces for `@object-ui/fields`' widget-hinted + * reference keys name what the stored value references. + * + * Every case carries its control: + * - the reads are the edit widgets' reads, made once per page, not once per + * cell — each case counts the calls; + * - a value nothing could name draws muted, never blank, and a face that + * could not ask (no row, no data source) draws the text it drew before; + * - the registration case goes through the REAL key a declared widget + * resolves to, so a face that is registered under the wrong key, or not at + * all, fails here rather than in a browser. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MetadataCtx, SchemaRendererProvider, type MetadataContextValue } from '@object-ui/react'; +import { getCellRenderer, resolveCellRendererType, type CellRendererProps } from '@object-ui/fields'; +// The faces are what the registration loads lazily; imported at module scope +// so the lazy factory below resolves at once (AGENTS.md, flaky-test rule). +import { ObjectRefCellFace, RecipientCellFace } from '../WidgetRefCellFaces'; +import { ObjectRefCell, RecipientCell } from '../widget-ref-cell-renderers'; + +afterEach(() => cleanup()); + +const POSITIONS = [ + { name: 'sales_manager', label: 'Sales Manager' }, + { name: 'eu_sales_lead', label: 'EU Sales Lead' }, +]; + +function metadataStore(items: unknown[]): MetadataContextValue & { ensureType: ReturnType } { + return { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: vi.fn().mockResolvedValue(items), + getItem: async () => null, + getItemsByType: () => [], + } as any; +} + +interface CellCase { + value: unknown; + dependentValues?: Record; +} + +function renderFaces( + Face: React.FC, + cells: CellCase[], + { dataSource, store }: { dataSource?: any; store?: MetadataContextValue | null } = {}, +) { + const body = ( + + {cells.map((c, i) => ( +
+ +
+ ))} +
+ ); + return render(store ? {body} : body); +} + +const cell = (i: number) => screen.getByTestId(`cell-${i}`); +const unresolvedMark = (i: number) => cell(i).querySelector('[data-slot="unresolved-reference"]'); + +function catalogSource() { + return { + getObjects: vi.fn(async () => [ + { name: 'crm_account', label: 'Account' }, + { name: 'crm_contact', label: 'Contact' }, + ]), + }; +} + +describe('objectui#12613 — the registered faces, through the key a declared widget resolves to', () => { + it('registers both faces under the widget keys', () => { + expect(getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'object-ref' }))).toBe(ObjectRefCell); + expect(getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'recipient-picker' }))).toBe(RecipientCell); + }); + + it('a rule with recipient `sales_manager` renders "Sales Manager", and its object renders "Account"', async () => { + const store = metadataStore(POSITIONS); + const ds = { ...catalogSource(), find: vi.fn() }; + const ObjectCell = getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'object-ref' })); + const Recipient = getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'recipient-picker' })); + const row = { object_name: 'crm_account', recipient_type: 'position', recipient_id: 'sales_manager' }; + render( + + +
+ +
+
+ +
+
+
, + ); + + await waitFor(() => expect(screen.getByTestId('recipient')).toHaveTextContent('Sales Manager')); + await waitFor(() => expect(screen.getByTestId('object')).toHaveTextContent('Account')); + expect(screen.getByTestId('object').querySelector('[title]')?.getAttribute('title')).toBe('Account (crm_account)'); + }); +}); + +describe('objectui#12613 — the `object-ref` face', () => { + it("draws the object's label, with the api name in the tooltip, from ONE catalog read", async () => { + const ds = catalogSource(); + renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }, { value: 'crm_contact' }, { value: 'crm_account' }], { + dataSource: ds, + }); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); + expect(cell(0)).not.toHaveTextContent('crm_account'); + expect(cell(0).querySelector('[title]')?.getAttribute('title')).toBe('Account (crm_account)'); + expect(cell(1)).toHaveTextContent('Contact'); + expect(cell(2)).toHaveTextContent('Account'); + expect(ds.getObjects, 'one catalog read for every row, not one per cell').toHaveBeenCalledTimes(1); + }); + + it('draws a name the catalog does not list as the raw value, muted — never blank', async () => { + renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }, { value: 'crm_retired' }], { dataSource: catalogSource() }); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); + await waitFor(() => expect(unresolvedMark(1), 'the unknown name wears the unresolved mark').not.toBeNull()); + expect(cell(1)).toHaveTextContent('crm_retired'); + expect(unresolvedMark(0), 'the resolved one does not').toBeNull(); + }); + + it('draws the stored text exactly as before when there is no data source to ask', () => { + renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }]); + expect(cell(0)).toHaveTextContent('crm_account'); + expect(unresolvedMark(0)).toBeNull(); + }); +}); + +describe('objectui#12613 — the `recipient-picker` face', () => { + it('names position recipients through the registry, with one store read', async () => { + const store = metadataStore(POSITIONS); + renderFaces( + RecipientCellFace, + [ + { value: 'sales_manager', dependentValues: { recipient_type: 'position' } }, + { value: 'eu_sales_lead', dependentValues: { recipient_type: 'position' } }, + ], + { dataSource: { find: vi.fn() }, store }, + ); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Sales Manager')); + expect(cell(0)).not.toHaveTextContent('sales_manager'); + expect(cell(1)).toHaveTextContent('EU Sales Lead'); + expect(store.ensureType).toHaveBeenCalledTimes(1); + expect(store.ensureType).toHaveBeenCalledWith('position'); + }); + + it("names record recipients from ONE read of the picker's own candidate query, and marks the one it does not list", async () => { + const find = vi.fn(async (_object: string, _params: any) => ({ + data: [ + { id: 'team_eu', name: 'EU Sales' }, + { id: 'team_us', name: 'US Sales' }, + ], + })); + renderFaces( + RecipientCellFace, + [ + { value: 'team_eu', dependentValues: { recipient_type: 'team' } }, + { value: 'team_us', dependentValues: { recipient_type: 'team' } }, + { value: 'team_gone', dependentValues: { recipient_type: 'team' } }, + ], + { dataSource: { find } }, + ); + + await waitFor(() => expect(cell(0)).toHaveTextContent('EU Sales')); + expect(cell(1)).toHaveTextContent('US Sales'); + await waitFor(() => expect(unresolvedMark(2)).not.toBeNull()); + expect(cell(2)).toHaveTextContent('team_gone'); + + expect(find, 'one query for the three rows, not one per cell').toHaveBeenCalledTimes(1); + const [object, params] = find.mock.calls[0]; + expect(object).toBe('sys_team'); + // The picker's query, so the list names exactly what the open picker offers. + expect(params).toEqual({ $top: 500, $orderby: { name: 'asc' } }); + }); + + it("names a `field` recipient by the shared object's user column label", async () => { + const getObjectSchema = vi.fn(async () => ({ + name: 'crm_case', + fields: { + escalated_from: { type: 'user', label: 'Escalated from' }, + subject: { type: 'text', label: 'Subject' }, + }, + })); + renderFaces( + RecipientCellFace, + [ + { value: 'escalated_from', dependentValues: { recipient_type: 'field', object_name: 'crm_case' } }, + // Not a user column: the evaluator grants nobody for it, so it is not + // named as one. + { value: 'subject', dependentValues: { recipient_type: 'field', object_name: 'crm_case' } }, + ], + { dataSource: { find: vi.fn(), getObjectSchema } }, + ); + + await waitFor(() => expect(cell(0)).toHaveTextContent('Escalated from')); + await waitFor(() => expect(unresolvedMark(1)).not.toBeNull()); + expect(cell(1)).toHaveTextContent('subject'); + expect(getObjectSchema).toHaveBeenCalledTimes(1); + expect(getObjectSchema).toHaveBeenCalledWith('crm_case'); + }); + + it('draws a read the viewer is refused as the raw value, muted', async () => { + const find = vi.fn(async () => { + throw new Error('OBJECT_API_DISABLED'); + }); + renderFaces(RecipientCellFace, [{ value: 'usr_1', dependentValues: { recipient_type: 'user' } }], { + dataSource: { find }, + }); + + await waitFor(() => expect(unresolvedMark(0)).not.toBeNull()); + expect(cell(0)).toHaveTextContent('usr_1'); + }); + + it('draws the stored text exactly as before when the call site passes no row', () => { + const find = vi.fn(); + renderFaces(RecipientCellFace, [{ value: 'sales_manager' }], { dataSource: { find } }); + expect(cell(0)).toHaveTextContent('sales_manager'); + expect(unresolvedMark(0)).toBeNull(); + expect(find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/fields/src/widgets/refLabelDirectory.ts b/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts similarity index 81% rename from packages/fields/src/widgets/refLabelDirectory.ts rename to packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts index 7da8616c93..7ccfa0d59a 100644 --- a/packages/fields/src/widgets/refLabelDirectory.ts +++ b/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts @@ -7,75 +7,79 @@ */ /** - * The read cells' label directory for the two widget-hinted reference fields - * (objectui#12613): `object-ref` (a stored object name) and `recipient-picker` - * (a stored recipient id or name, whose kind a sibling `recipient_type` names). + * The label directory behind the console's faces for the two widget-hinted + * reference keys of `@object-ui/fields` (objectui#12613): `object-ref` (a stored + * object name) and `recipient-picker` (a stored recipient id or name, whose kind + * a sibling `recipient_type` names). * * ## The edit widgets' own lists, read once per page * - * A cell names a stored value from the very list its edit widget offers: + * A face names a stored value from the very list its edit widget offers, read + * through what `@object-ui/fields` publishes for exactly this: * - * - `object-ref` → the registered-object catalog, `loadObjectCatalog` - * (`./objectCatalog.ts`), the read `ObjectRefField` makes; + * - `object-ref` → the registered-object catalog, `loadObjectCatalog`, the + * read `ObjectRefField` makes; * - a record kind (`user`, `team`, `business_unit`, `unit_and_subordinates`) - * → `loadRecipientCandidates` (`./recipientKinds.ts`), the picker's one - * candidate query, labelled with the kind's `labelFields`; - * - a registry kind (`position`) → the console's metadata store, - * `ensureType()`, the reader `RecipientPickerField` lists it with; + * → `loadRecipientCandidates`, the picker's one candidate query, labelled + * with the kind's `labelFields` from `RECIPIENT_RECORD_KINDS`; + * - a registry kind (`position`, `RECIPIENT_REGISTRY_KINDS`) → the console's + * metadata store, `ensureType()`, the reader `RecipientPickerField` + * lists it with; * - the `field` kind → the shared object's schema through * `deriveUserFields`, the picker's own column derivation. * * Each list is a CATALOG: read the first time any cell asks it, once per data * source, and every cell of every row answered from it. A grid page of fifty - * rules costs one read per list it names, not fifty — and a value reads in the + * rules costs one read per list it names, not fifty, and a value reads in the * list exactly as it reads in the open picker, including the picker's bound (a * record past the candidate query's first 500 is not offered, and not named). * - * ## The four answers a cell gets + * ## The four answers a face gets * * - `unasked` — the edit widget could not list it either: no data source, no * sibling kind, a kind it does not know, no metadata store for a registry - * kind. The cell draws the stored text exactly as the text cell did before - * it existed, so a call site that cannot supply what the cell needs - * changes nothing. + * kind. The face draws the stored text exactly as the text cell does, so a + * call site that cannot supply what the face needs changes nothing. * - `pending` — asked, not answered yet. Drawn the same as `unasked`. * - `resolved` — the label. * - `unresolved` — the list was read and does not hold the value (a deleted - * record, a renamed object), or the read failed (no read access). The cell + * record, a renamed object), or the read failed (no read access). The face * draws the raw stored value, muted, never a blank. * * ## Cache lifetime * * Answers live for the data source's lifetime, which is the page session: a * display label, not a decision input, so an item renamed mid-session reading - * its old label until reload is the right trade (the same one the approver - * directory in `@object-ui/app-shell` makes). Keyed by the data source object, - * so two hosts, and two tests, never share an answer. + * its old label until reload is the right trade (the same one + * `useApproverDirectory` makes). Keyed by the data source object, so two hosts, + * and two tests, never share an answer. * - * ## Why this module is small + * ## Why it lives here, behind a lazy import * * Every `packages/fields` module rides the console's eager `ui-components` * chunk, whatever imports it lazily (`scripts/vite-ineffective-dynamic-imports.ts` * says why), and that chunk has a ceiling of its own in - * `scripts/check-eager-closure-budget.mjs`. One catalog mechanism serves all - * five lists for that reason: a second one (a batched `$in` reader for the - * record kinds) cost a page-load payload the budget refused. + * `scripts/check-eager-closure-budget.mjs` with no room for a label reader. So + * `@object-ui/fields` keeps the rule and the keys with a text default, as it + * does for `RECORD_POINTER_CARD_TYPE`, and this package registers the faces, + * loading this module and the faces only when a cell first draws one + * (`./widget-ref-cell-renderers.tsx`). */ import React from 'react'; import { MetadataCtx, SchemaRendererContext } from '@object-ui/react'; import type { DataSource } from '@object-ui/types'; -import { loadObjectCatalog } from './objectCatalog.js'; import { FIELD_RECIPIENT_TYPE, - TYPE_TO_OBJECT, - TYPE_TO_REGISTRY, + RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_KINDS, + deriveUserFields, + loadObjectCatalog, loadRecipientCandidates, recipientCandidateLabel, -} from './recipientKinds.js'; -import { deriveUserFields } from './RecipientPickerField.js'; +} from '@object-ui/fields'; -/** What a read cell knows about one stored reference. See the module doc. */ +/** What a face knows about one stored reference. See the module doc. */ export type RefLabel = | { readonly state: 'unasked' } | { readonly state: 'pending' } @@ -202,8 +206,8 @@ export function useRecipientLabel( // are offered — both declared in the field's own `dependsOn`. const kind = String(dependentValues?.recipient_type ?? ''); const objectName = String(dependentValues?.object_name ?? ''); - const record = TYPE_TO_OBJECT[kind]; - const registry = TYPE_TO_REGISTRY[kind]; + const record = RECIPIENT_RECORD_KINDS[kind]; + const registry = RECIPIENT_REGISTRY_KINDS[kind]; const catalogId = !dependentValues || typeof value !== 'string' || !value ? '' diff --git a/packages/app-shell/src/views/widget-ref-cells/widget-ref-cell-renderers.tsx b/packages/app-shell/src/views/widget-ref-cells/widget-ref-cell-renderers.tsx new file mode 100644 index 0000000000..685e8575e1 --- /dev/null +++ b/packages/app-shell/src/views/widget-ref-cells/widget-ref-cell-renderers.tsx @@ -0,0 +1,50 @@ +/** + * 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. + */ + +/** + * Registers the console's faces for `@object-ui/fields`' two widget-hinted + * reference keys, `object-ref` and `recipient-picker` (objectui#12613), the + * way `record-pointer-card-renderer.tsx` registers the pointer card: + * `@object-ui/fields` owns the rule and the keys and keeps the stored text as + * their default face, and this package, imported for its side effect from the + * package entry (`src/index.ts`), registers the faces that name the value. + * + * The faces and their label directory load LAZILY, on the first cell that + * draws one: a list that shows no such column pays nothing on page load, which + * is the point of keeping them out of `@object-ui/fields` (see + * `./refLabelDirectory.ts`). Until the module arrives a cell draws the stored + * text — exactly what it draws while its label is being read. + */ + +import * as React from 'react'; +import { TextCellRenderer, registerFieldRenderer, type CellRendererProps } from '@object-ui/fields'; + +const loadFaces = () => import('./WidgetRefCellFaces.js'); +const LazyObjectRefFace = React.lazy(() => loadFaces().then((m) => ({ default: m.ObjectRefCellFace }))); +const LazyRecipientFace = React.lazy(() => loadFaces().then((m) => ({ default: m.RecipientCellFace }))); + +/** The `object-ref` key's face: the lazily loaded label face, text until it lands. */ +export function ObjectRefCell(props: CellRendererProps) { + return ( + }> + + + ); +} + +/** The `recipient-picker` key's face: the lazily loaded label face, text until it lands. */ +export function RecipientCell(props: CellRendererProps) { + return ( + }> + + + ); +} + +registerFieldRenderer('object-ref', ObjectRefCell); +registerFieldRenderer('recipient-picker', RecipientCell); diff --git a/packages/fields/README.md b/packages/fields/README.md index da3e25b155..eaf221df28 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -53,16 +53,19 @@ const MyGridCell = ({ field, value, row }: CellRendererProps & { row: Record; }; ``` -The `object-ref` and `recipient-picker` cells name what the stored value references -with the same reads their edit widgets list it from, made once per data source for the -whole list rather than once per cell. A value nothing names draws as the raw stored -value, muted. +This package registers the `object-ref` and `recipient-picker` keys with the stored +text as their default face. A host that can read the referenced lists registers faces +that name the value under the same keys (`registerFieldRenderer`); the console's live in +`@object-ui/app-shell`. They read through what this package publishes for it, the reads +its edit widgets list from: `loadObjectCatalog`, `objectRefOptionLabel`, +`RECIPIENT_RECORD_KINDS`, `RECIPIENT_REGISTRY_KINDS`, `FIELD_RECIPIENT_TYPE`, +`loadRecipientCandidates` and `recipientCandidateLabel`. ## Standard Field Types diff --git a/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx index 32ba00faf4..f96f58ec05 100644 --- a/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx +++ b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx @@ -7,81 +7,44 @@ */ /** - * objectui#12613 — a field's declared `widget` picks its READ cell, so a list - * names what an `object-ref` / `recipient-picker` value references instead of - * printing the api name. + * objectui#12613 — a field's declared `widget` picks its READ cell key, so a + * host face can name what an `object-ref` / `recipient-picker` value references + * instead of printing the api name. * * The platform declares both hints on `sys_sharing_rule` (`object_name` is * `Field.text({ widget: 'object-ref' })`, `recipient_id` is * `Field.text({ widget: 'recipient-picker' })`), and before this change * `resolveCellRendererType` read `type` and `format` only: both columns drew - * through the text cell, so the Sharing Rules list read `crm_account` and - * `sales_manager` where the edit form reads "Account" and "Sales Manager". + * through the text cell under the text key, where no face could reach them. * - * Every case carries its control: - * - the promotion is driven by the DECLARED widget, never by the value: the - * same value under a plain `text` field still draws as text; - * - the reads are the edit widgets' reads, made once per page, not once per - * cell — each case counts the calls; - * - a value nothing could name draws muted, never blank, and a cell that - * could not ask (no row, no data source) draws the text it drew before. + * This package owns the rule, the two keys and their DEFAULT face (the stored + * text, as `RECORD_POINTER_CARD_TYPE`'s default is), plus the reads and the kind + * table its edit widgets list from, published for the host face. The faces that + * name the value are `@object-ui/app-shell`'s, and are pinned there + * (`WidgetRefCellFaces-12613.test.tsx`). */ import React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, cleanup, screen, waitFor } from '@testing-library/react'; +import { render, cleanup, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { MetadataCtx, SchemaRendererProvider, type MetadataContextValue } from '@object-ui/react'; -import { getCellRenderer, resolveCellRendererType, TextCellRenderer } from '../index'; +import { + getCellRenderer, + listCellRendererTypes, + loadObjectCatalog, + loadRecipientCandidates, + recipientCandidateLabel, + resolveCellRendererType, + RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_KINDS, + FIELD_RECIPIENT_TYPE, + TextCellRenderer, +} from '../index'; afterEach(() => cleanup()); -const POSITIONS = [ - { name: 'sales_manager', label: 'Sales Manager' }, - { name: 'eu_sales_lead', label: 'EU Sales Lead' }, -]; - -function metadataStore(items: unknown[]): MetadataContextValue & { ensureType: ReturnType } { - return { - apps: [], objects: [], dashboards: [], reports: [], pages: [], - loading: false, error: null, - refresh: async () => {}, invalidate: () => {}, - ensureType: vi.fn().mockResolvedValue(items), - getItem: async () => null, - getItemsByType: () => [], - } as any; -} - -interface CellCase { - value: unknown; - dependentValues?: Record; -} - -/** Draw one cell per case, through the renderer the declared widget resolves to. */ -function renderCells( - widget: string, - cells: CellCase[], - { dataSource, store }: { dataSource?: any; store?: MetadataContextValue | null } = {}, -) { - const key = resolveCellRendererType({ type: 'text', widget }); - const Renderer = getCellRenderer(key); - const body = ( - - {cells.map((c, i) => ( -
- -
- ))} -
- ); - return render(store ? {body} : body); -} - -const cell = (i: number) => screen.getByTestId(`cell-${i}`); -const unresolvedMark = (i: number) => cell(i).querySelector('[data-slot="unresolved-reference"]'); - -describe('objectui#12613 — the declared widget picks the cell', () => { - it('promotes a textual field declaring `object-ref` / `recipient-picker` to that cell, and nothing else', () => { +describe('objectui#12613 — the declared widget picks the cell key', () => { + it('promotes a textual field declaring `object-ref` / `recipient-picker` to that key, and nothing else', () => { expect(resolveCellRendererType({ type: 'text', widget: 'object-ref' })).toBe('object-ref'); expect(resolveCellRendererType({ type: 'text', widget: 'recipient-picker' })).toBe('recipient-picker'); // Controls: no widget, a widget with no read cell of its own, and a @@ -92,156 +55,43 @@ describe('objectui#12613 — the declared widget picks the cell', () => { // The widget wins over a `format` hint on the same text field: it names // what the value IS, the hint only how plain text is drawn. expect(resolveCellRendererType({ type: 'text', format: 'url', widget: 'object-ref' })).toBe('object-ref'); - expect(getCellRenderer('object-ref')).not.toBe(TextCellRenderer); - expect(getCellRenderer('recipient-picker')).not.toBe(TextCellRenderer); - }); -}); - -describe('objectui#12613 — the `object-ref` cell', () => { - function catalogSource() { - return { - getObjects: vi.fn(async () => [ - { name: 'crm_account', label: 'Account' }, - { name: 'crm_contact', label: 'Contact' }, - ]), - }; - } - - it("draws the object's label, with the api name in the tooltip, from ONE catalog read", async () => { - const ds = catalogSource(); - renderCells('object-ref', [{ value: 'crm_account' }, { value: 'crm_contact' }, { value: 'crm_account' }], { - dataSource: ds, - }); - - await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); - expect(cell(0)).not.toHaveTextContent('crm_account'); - expect(cell(0).querySelector('[title]')?.getAttribute('title')).toBe('Account (crm_account)'); - expect(cell(1)).toHaveTextContent('Contact'); - expect(cell(2)).toHaveTextContent('Account'); - expect(ds.getObjects, 'one catalog read for every row, not one per cell').toHaveBeenCalledTimes(1); }); - it('draws a name the catalog does not list as the raw value, muted — never blank', async () => { - renderCells('object-ref', [{ value: 'crm_account' }, { value: 'crm_retired' }], { dataSource: catalogSource() }); - - await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); - expect(cell(1)).toHaveTextContent('crm_retired'); - expect(unresolvedMark(1), 'the unknown name wears the unresolved mark').not.toBeNull(); - expect(unresolvedMark(0), 'the resolved one does not').toBeNull(); - }); - - it('draws the stored text exactly as before when there is no data source to ask', () => { - renderCells('object-ref', [{ value: 'crm_account' }]); - expect(cell(0)).toHaveTextContent('crm_account'); - expect(unresolvedMark(0)).toBeNull(); - }); + it('registers both keys with the stored text as their default face', () => { + const types = listCellRendererTypes(); + expect(types).toContain('object-ref'); + expect(types).toContain('recipient-picker'); + expect(getCellRenderer('object-ref')).toBe(TextCellRenderer); + expect(getCellRenderer('recipient-picker')).toBe(TextCellRenderer); - it('control: the same value under a plain `text` field is not named', async () => { - const ds = catalogSource(); - const Text = getCellRenderer(resolveCellRendererType({ type: 'text' })); + const Renderer = getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'recipient-picker' })); render( - -
- -
-
, +
+ +
, ); - expect(screen.getByTestId('plain')).toHaveTextContent('crm_account'); - expect(ds.getObjects).not.toHaveBeenCalled(); + expect(screen.getByTestId('cell')).toHaveTextContent('sales_manager'); }); }); -describe('objectui#12613 — the `recipient-picker` cell', () => { - it('names a position recipient through the registry: `sales_manager` reads "Sales Manager"', async () => { - const store = metadataStore(POSITIONS); - renderCells( - 'recipient-picker', - [ - { value: 'sales_manager', dependentValues: { recipient_type: 'position' } }, - { value: 'eu_sales_lead', dependentValues: { recipient_type: 'position' } }, - ], - { dataSource: { find: vi.fn() }, store }, - ); - - await waitFor(() => expect(cell(0)).toHaveTextContent('Sales Manager')); - expect(cell(0)).not.toHaveTextContent('sales_manager'); - expect(cell(1)).toHaveTextContent('EU Sales Lead'); - expect(store.ensureType).toHaveBeenCalledTimes(1); - expect(store.ensureType).toHaveBeenCalledWith('position'); - }); - - it("names record recipients from ONE read of the picker's own candidate query, and marks the one it does not list", async () => { - const find = vi.fn(async (_object: string, _params: any) => ({ - data: [ - { id: 'team_eu', name: 'EU Sales' }, - { id: 'team_us', name: 'US Sales' }, - ], - })); - renderCells( - 'recipient-picker', - [ - { value: 'team_eu', dependentValues: { recipient_type: 'team' } }, - { value: 'team_us', dependentValues: { recipient_type: 'team' } }, - { value: 'team_gone', dependentValues: { recipient_type: 'team' } }, - ], - { dataSource: { find } }, - ); - - await waitFor(() => expect(cell(0)).toHaveTextContent('EU Sales')); - expect(cell(1)).toHaveTextContent('US Sales'); - await waitFor(() => expect(unresolvedMark(2)).not.toBeNull()); - expect(cell(2)).toHaveTextContent('team_gone'); - - expect(find, 'one query for the three rows, not one per cell').toHaveBeenCalledTimes(1); - const [object, params] = find.mock.calls[0]; - expect(object).toBe('sys_team'); - // The picker's query, so the list names exactly what the open picker offers. - expect(params).toEqual({ $top: 500, $orderby: { name: 'asc' } }); - }); - - it("names a `field` recipient by the shared object's user column label", async () => { - const getObjectSchema = vi.fn(async () => ({ - name: 'crm_case', - fields: { - escalated_from: { type: 'user', label: 'Escalated from' }, - subject: { type: 'text', label: 'Subject' }, - }, - })); - renderCells( - 'recipient-picker', - [ - { value: 'escalated_from', dependentValues: { recipient_type: 'field', object_name: 'crm_case' } }, - // Not a user column: the evaluator grants nobody for it, so it is not - // named as one. - { value: 'subject', dependentValues: { recipient_type: 'field', object_name: 'crm_case' } }, - ], - { dataSource: { find: vi.fn(), getObjectSchema } }, - ); - - await waitFor(() => expect(cell(0)).toHaveTextContent('Escalated from')); - await waitFor(() => expect(unresolvedMark(1)).not.toBeNull()); - expect(cell(1)).toHaveTextContent('subject'); - expect(getObjectSchema).toHaveBeenCalledTimes(1); - expect(getObjectSchema).toHaveBeenCalledWith('crm_case'); - }); - - it('draws a read the viewer is refused as the raw value, muted', async () => { - const find = vi.fn(async () => { - throw new Error('OBJECT_API_DISABLED'); - }); - renderCells('recipient-picker', [{ value: 'usr_1', dependentValues: { recipient_type: 'user' } }], { - dataSource: { find }, - }); +describe('objectui#12613 — the published reads are the edit widgets\' reads', () => { + it('`loadObjectCatalog` lists through `getObjects`, then the `sys_metadata` fallback', async () => { + const getObjects = vi.fn(async () => [{ name: 'crm_account', label: 'Account' }]); + expect(await loadObjectCatalog({ getObjects } as any)).toEqual([{ name: 'crm_account', label: 'Account' }]); - await waitFor(() => expect(unresolvedMark(0)).not.toBeNull()); - expect(cell(0)).toHaveTextContent('usr_1'); + const find = vi.fn(async () => ({ data: [{ name: 'crm_contact', label: 'Contact' }, { label: 'nameless' }] })); + expect(await loadObjectCatalog({ find } as any)).toEqual([{ name: 'crm_contact', label: 'Contact' }]); + expect(find).toHaveBeenCalledWith('sys_metadata', { $filter: { type: 'object' }, $top: 1000 }); }); - it('draws the stored text exactly as before when the call site passes no row', () => { - const find = vi.fn(); - renderCells('recipient-picker', [{ value: 'sales_manager' }], { dataSource: { find } }); - expect(cell(0)).toHaveTextContent('sales_manager'); - expect(unresolvedMark(0)).toBeNull(); - expect(find).not.toHaveBeenCalled(); + it("`loadRecipientCandidates` is the picker's one candidate query, and labels follow the kind's fields", async () => { + const find = vi.fn(async () => ({ data: [{ id: 'team_eu', name: 'EU Sales' }] })); + const rows = await loadRecipientCandidates({ find } as any, RECIPIENT_RECORD_KINDS.team); + expect(find).toHaveBeenCalledWith('sys_team', { $top: 500, $orderby: { name: 'asc' } }); + expect(recipientCandidateLabel(RECIPIENT_RECORD_KINDS.team.labelFields, rows[0])).toBe('EU Sales'); + // No label field filled ⇒ the id, as the picker labels such a row. + expect(recipientCandidateLabel(RECIPIENT_RECORD_KINDS.user.labelFields, { id: 'usr_9' })).toBe('usr_9'); + expect(RECIPIENT_REGISTRY_KINDS.position.type).toBe('position'); + expect(FIELD_RECIPIENT_TYPE).toBe('field'); }); }); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 41d9c158a1..cef8608ee3 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -15,7 +15,7 @@ import { ComponentRegistry, percentDisplayValue, type PercentScale, getRecordDis // `resolveFieldScale` is the same kind of ask: the width an ABSENT `scale` // means is the protocol's answer, not this package's (objectui#9843). import { valueSchemaFor, resolveFieldScale } from '@objectstack/spec/data'; -import { useLocalization, useDisplayLocale, formatDisplayNumber, useObjectLabel } from '@object-ui/i18n'; +import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n'; import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components'; import { Check, Copy, Phone as PhoneIcon, MapPin, CircleQuestionMark, Star } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/react'; @@ -367,11 +367,6 @@ import { readFileValue, readFileValues, type FileValueView } from './widgets/fil // Shared with `FileField` rather than copied into it; deliberately NOT // re-exported below, so this package's published surface is unchanged. import { FileValueAffordance, RestrictedFileAffordance } from './widgets/file-affordance.js'; -// The read cells of the two widget-hinted reference fields (objectui#12613) -// name a stored value from the reads their edit widgets list it from — see -// `./widgets/refLabelDirectory.ts`. Deliberately NOT re-exported below. -import { useObjectRefLabel, useRecipientLabel } from './widgets/refLabelDirectory.js'; -import { objectRefOptionLabel } from './widgets/objectCatalog.js'; /** * Cell renderer props @@ -384,8 +379,8 @@ export interface CellRendererProps { /** * The row's values, for a renderer whose value only means something next to * a sibling column — the cell-side counterpart of the edit widget's - * `FieldWidgetComponentProps.dependentValues` (objectui#12613). The - * `recipient-picker` cell reads the row's `recipient_type` from it, as the + * `FieldWidgetComponentProps.dependentValues` (objectui#12613). The console's + * `recipient-picker` face reads the row's `recipient_type` from it, as the * picker reads the form record's. * * A call site that holds the row passes it; a renderer that needs a sibling @@ -702,67 +697,6 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme return ; } -/** - * The face of a stored reference the directory could NOT name (objectui#12613): - * the raw stored value, muted, never a blank — a deleted record, a renamed - * object, or a read the viewer has no access to. It is drawn with the shipped - * unresolved-reference presentation (`UnresolvedReferenceMark`), so this state - * looks the same here as on a lookup cell. Its tooltip is the stored value - * itself: the lookup family's sentence names a RECORD, which an object or a - * position is not, so it is not borrowed. - */ -function UnresolvedRefText({ text }: { text: string }): React.ReactElement { - return ; -} - -/** - * The read cell of a field declaring `widget: 'object-ref'` (objectui#12613): - * the stored object NAME, drawn as the object's label, with the picker's own - * `Label (name)` spelling as its tooltip so the api name stays one hover away. - * - * The label comes from the catalog `ObjectRefField` lists its options from - * (`loadObjectCatalog`), read once per data source for every row, and is then - * translated through the app bundle by `useObjectLabel` like every other - * object label the console draws. Pending and unaskable values draw as the - * text cell; an unknown name draws muted (`UnresolvedRefText`). - */ -function ObjectRefCellRenderer({ value, field }: CellRendererProps): React.ReactElement { - const ref = useObjectRefLabel(value); - const { objectLabel } = useObjectLabel(); - const safe = coerceToSafeValue(value); - if (isEmptyValue(safe)) return ; - if (ref.state === 'resolved' && typeof value === 'string') { - const label = objectLabel({ name: value, label: ref.label || value }); - return ( - - {label} - - ); - } - if (ref.state === 'unresolved') return ; - return ; -} - -/** - * The read cell of a field declaring `widget: 'recipient-picker'` - * (objectui#12613): the stored recipient id or name, drawn as the label of the - * user, team, business unit, position or user column it names — the label the - * picker shows for the same value. - * - * Which of those it names is the row's `recipient_type`, so the cell needs the - * row: it reads `dependentValues`, and without them it draws the stored text as - * the text cell does. The lookups are the picker's, batched across rows - * (`useRecipientLabel`). An unknown value draws muted (`UnresolvedRefText`). - */ -function RecipientCellRenderer({ value, field, dependentValues }: CellRendererProps): React.ReactElement { - const ref = useRecipientLabel(value, dependentValues); - const safe = coerceToSafeValue(value); - if (isEmptyValue(safe)) return ; - if (ref.state === 'resolved') return ; - if (ref.state === 'unresolved') return ; - return ; -} - /** * Number field cell renderer */ @@ -3061,7 +2995,8 @@ function UnresolvedLookupReference({ /** * The shipped PRESENTATION of an unresolved reference, shared by the two * renderers that state one (objectui#8434 for `user`, objectui#8695 for - * `lookup` / `master_detail` / `tree`). + * `lookup` / `master_detail` / `tree`), and published for the host faces of the + * widget-hinted reference keys (objectui#12613), which draw the same state. * * Only the drawing is shared. Each caller keeps its own literal i18n key and * its own English fallback, because a key reaching this component as a prop @@ -3081,7 +3016,7 @@ function UnresolvedLookupReference({ * (a single-line value must never expand its column and must expose its full * text) with an icon in front of the text. */ -function UnresolvedReferenceMark({ +export function UnresolvedReferenceMark({ raw, hint, className, @@ -3351,8 +3286,8 @@ const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', '' * 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 the cell names what it references, as the edit widget's readonly branch - * does, instead of printing the api name. + * 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. * * 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 @@ -4084,10 +4019,17 @@ registerFieldRenderer('user', UserCellRenderer); // The pointer pair's default face (objectui#12045): the record id as text. A // host that can read records registers its own face under the same key. registerFieldRenderer(RECORD_POINTER_CARD_TYPE, RecordPointerTextCellRenderer); -// The read cells of the two widget-hinted reference fields (objectui#12613), -// under the widget keys `resolveCellRendererType` promotes them to. -registerFieldRenderer('object-ref', ObjectRefCellRenderer); -registerFieldRenderer('recipient-picker', RecipientCellRenderer); +// The two widget-hinted reference keys `resolveCellRendererType` promotes to +// (objectui#12613), with the stored text as their DEFAULT face — the +// `RECORD_POINTER_CARD_TYPE` arrangement above: this package owns the rule and +// the keys, and a host that can read the referenced lists registers the faces +// that name them (`@object-ui/app-shell`'s widget reference cells). Not drawn +// here because every module of this package rides the console's eager +// `ui-components` chunk, whatever imports it lazily, and that chunk's ceiling +// in `scripts/check-eager-closure-budget.mjs` has no room for a label reader; +// the host loads its faces lazily, off the eager closure. +registerFieldRenderer('object-ref', TextCellRenderer); +registerFieldRenderer('recipient-picker', TextCellRenderer); // `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. @@ -5044,6 +4986,19 @@ export * from './widgets/CapabilityMultiSelectField.js'; export * from './widgets/ObjectRefField.js'; export * from './widgets/FilterConditionField.js'; export * from './widgets/RecipientPickerField.js'; +// The reads and the kind table the two reference widgets list from +// (objectui#12613), published so a host face names a stored value from the very +// same lists: `@object-ui/app-shell`'s widget reference cells are the reader. +export { loadObjectCatalog, objectRefOptionLabel, type ObjectHeader, type ObjectCatalogSource } from './widgets/objectCatalog.js'; +export { + RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_KINDS, + FIELD_RECIPIENT_TYPE, + loadRecipientCandidates, + recipientCandidateLabel, + type RecipientMapping, + type RegistryRecipientMapping, +} from './widgets/recipientKinds.js'; export * from './widgets/RecordPickerDialog.js'; // Shared picker-column derivation (ADR-0085 highlightFields → displayFields → // schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx index c603ad9909..b9b75dba67 100644 --- a/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/packages/fields/src/widgets/RecipientPickerField.tsx @@ -8,8 +8,8 @@ import { useFieldTranslation } from './useFieldTranslation.js'; // reads the same in the list and in this picker. import { FIELD_RECIPIENT_TYPE, - TYPE_TO_OBJECT, - TYPE_TO_REGISTRY, + RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_KINDS, loadRecipientCandidates, recipientCandidateLabel, } from './recipientKinds.js'; @@ -128,13 +128,13 @@ export function RecipientPickerField({ const disabled = props.disabled; const dependentValues: Record = (props as any).dependentValues ?? {}; const recipientType = String(dependentValues.recipient_type ?? ''); - const recordMapping = TYPE_TO_OBJECT[recipientType]; + const recordMapping = RECIPIENT_RECORD_KINDS[recipientType]; // The console's metadata store, or `null` outside a ``. // Read raw rather than through `useMetadata()`, whose no-provider fallback // answers every type with `[]`: an empty registry and an absent reader are // different facts, and only the second degrades to the text input below. const metadataStore = React.useContext(MetadataCtx); - const registryMapping = metadataStore ? TYPE_TO_REGISTRY[recipientType] : undefined; + const registryMapping = metadataStore ? RECIPIENT_REGISTRY_KINDS[recipientType] : undefined; const mapping: { storeField: 'id' | 'name'; labelFields: string[]; placeholderKey: string } | undefined = recordMapping ?? registryMapping; const objectName = String(dependentValues.object_name ?? ''); diff --git a/packages/fields/src/widgets/objectCatalog.ts b/packages/fields/src/widgets/objectCatalog.ts index c5721ef6b9..eab77e3c53 100644 --- a/packages/fields/src/widgets/objectCatalog.ts +++ b/packages/fields/src/widgets/objectCatalog.ts @@ -10,10 +10,9 @@ * The registered-object catalog an `object-ref` field reads (objectui#12613). * * ONE read, two readers: `ObjectRefField` (the edit widget) lists the catalog as - * its options, and the read cell (`ObjectRefCellRenderer`, through - * `refLabelDirectory.ts`) names a stored object from it. Deliberately not - * re-exported from the package barrel, and free of React so the barrel can - * import it without pulling the widget in. + * its options, and the console's read face for the field (`@object-ui/app-shell`'s + * widget reference cells) names a stored object from it. Published from the + * package barrel for that reader. No React here. */ import type { DataSource } from '@object-ui/types'; @@ -66,7 +65,7 @@ export async function loadObjectCatalog( /** * An object's name as the picker spells it, `Label (name)`, or the bare name * when the catalog gives it no label. The picker uses it for each option and - * the read cell for its tooltip, so the two faces spell an object one way. + * the read face for its tooltip, so the two faces spell an object one way. */ export function objectRefOptionLabel(name: string, label: string | undefined): string { return label ? `${label} (${name})` : name; diff --git a/packages/fields/src/widgets/recipientKinds.ts b/packages/fields/src/widgets/recipientKinds.ts index ecaff9da31..01510fc3e4 100644 --- a/packages/fields/src/widgets/recipientKinds.ts +++ b/packages/fields/src/widgets/recipientKinds.ts @@ -11,14 +11,14 @@ * candidates and labels come from (objectui#12613). * * ONE table, two readers: `RecipientPickerField` (the edit widget) lists a - * kind's candidates from it, and the read cell (`RecipientCellRenderer`, through - * `refLabelDirectory.ts`) names a stored recipient from it. They lived inside - * the widget until the cell needed the same answer; a second copy beside the - * cell is the drift this module exists to prevent, so a kind added here reaches - * both faces at once. Deliberately not re-exported from the package barrel: it - * is the two faces' shared rule, not a published surface. + * kind's candidates from it, and the console's read face for the field + * (`@object-ui/app-shell`'s widget reference cells) names a stored recipient + * from it. They lived inside the widget until the face needed the same answer; + * a second copy beside the face is the drift this module exists to prevent, so + * a kind added here reaches both faces at once. Published from the package + * barrel for that reader. * - * No React here, so the barrel can import it without pulling the widget in. + * No React here. */ import type { DataSource } from '@object-ui/types'; @@ -46,7 +46,7 @@ export interface RecipientMapping { * business_unit → sys_business_unit, store `id` * unit_and_subordinates → sys_business_unit, store `id` */ -export const TYPE_TO_OBJECT: Readonly> = { +export const RECIPIENT_RECORD_KINDS: Readonly> = { user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' }, team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' }, business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' }, @@ -75,14 +75,14 @@ export interface RegistryRecipientMapping { * objects, so the list is fetched once per session and shared with every * other registry consumer. */ -export const TYPE_TO_REGISTRY: Readonly> = { +export const RECIPIENT_REGISTRY_KINDS: Readonly> = { position: { type: 'position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' }, }; /** * The RECORD-RELATIVE recipient kind (maintainer ruling objectstack#14103, * executor objectstack#15072). It is deliberately absent from - * `TYPE_TO_OBJECT`: there is no target object to query and no record id to + * `RECIPIENT_RECORD_KINDS`: there is no target object to query and no record id to * store. `recipient_id` holds the NAME of a user-valued column on the SHARED * object, and the recipients are whoever that column names on each matched * record — so this mode reads the object chosen in the sibling `object_name` diff --git a/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx b/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx index b13c6d7d7f..689fb444c4 100644 --- a/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx +++ b/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx @@ -8,28 +8,36 @@ /** * objectui#12613 — a grid column whose field declares `widget: 'object-ref'` - * or `widget: 'recipient-picker'` names what the stored value references, on - * EVERY column path `ObjectGrid` can pick. + * or `widget: 'recipient-picker'` is drawn by the face registered under that + * widget key, and that face gets the ROW, on EVERY column path `ObjectGrid` + * can pick. * * The Sharing Rules list is the measured case: the platform's `sys_sharing_rule` * declares `object_name` with `widget: 'object-ref'` and `recipient_id` with * `widget: 'recipient-picker'` (`dependsOn: ['recipient_type', 'object_name']`), * and its list views name those columns as strings. The grid resolved each - * column from `type` and `format` alone, so both drew through the text cell and - * the list read `crm_account` / `sales_manager` where the edit form reads - * "Account" / "Sales Manager". + * column from `type` and `format` alone, so both drew through the text cell, + * and no face — the console registers label faces under the two keys + * (`@object-ui/app-shell`'s widget reference cells) — could reach them. * - * The object here is NOT `sys_sharing_rule`, on purpose: the cell is chosen by + * ## What is probed, and why a probe + * + * This file pins the GRID's half of the contract: the key the column resolves + * to, and the row handed over as `dependentValues` (the recipient face reads + * the row's `recipient_type` from it). So the two keys are registered here with + * a probe face that prints exactly those two facts. The label faces themselves + * are pinned beside them in `@object-ui/app-shell`. + * + * The object here is NOT `sys_sharing_rule`, on purpose: the face is chosen by * the declared widget, so a neutral object declaring the same two hints is the * honest subject, and nothing in the grid may key on an object or field name. * * ## The control * * `note` is a plain `text` column holding the SAME strings the two hinted - * columns hold (`sales_manager`, `crm_account`). It must still print them raw: - * the naming is driven by the declaration, not by the value. And the recipient - * cell needs the ROW (its `recipient_type`), which only a call site holding the - * row can hand it — so each path below is its own case (the paths are listed in + * columns hold (`sales_manager`, `crm_account`). It must still print them raw + * through the text cell: the face is picked by the declaration, not by the + * value. Each path is its own case (the paths are listed in * `formatHintedColumnRenderer-8920.test.tsx`). */ @@ -41,12 +49,26 @@ import { } from '@object-ui/test-support'; import { render, screen, cleanup, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { registerAllFields } from '@object-ui/fields'; -import { ActionProvider, MetadataCtx, SchemaRendererProvider } from '@object-ui/react'; +import { registerAllFields, registerFieldRenderer, type CellRendererProps } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; import { ObjectGrid } from '../ObjectGrid'; registerAllFields(); +/** + * Prints which key drew the cell, the stored value, and the row's + * `recipient_type` as the face received it (`-` for no row). `registerFieldRenderer` + * has no inverse; this `.tsx` file runs in the isolated `dom` project, so the + * probes do not outlive it. + */ +function probe(key: string): React.FC { + return ({ value, dependentValues }) => ( + {`${key}|${String(value)}|${String(dependentValues?.recipient_type ?? '-')}`} + ); +} +registerFieldRenderer('object-ref', probe('object-ref')); +registerFieldRenderer('recipient-picker', probe('recipient-picker')); + beforeAll(() => { if (!Element.prototype.hasPointerCapture) { Element.prototype.hasPointerCapture = vi.fn(() => false) as any; @@ -110,102 +132,78 @@ const ROWS = [ function makeDataSource() { return { - find: vi.fn(async (object: string) => { - if (object === 'sys_team') return { data: [{ id: 'team_eu', name: 'EU Sales' }] }; - return { data: ROWS, total: ROWS.length }; - }), + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), getObjectSchema: vi.fn(async () => RULE_SCHEMA), - getObjects: vi.fn(async () => [{ name: 'crm_account', label: 'Account' }]), - } as any; -} - -function metadataStore() { - return { - apps: [], objects: [], dashboards: [], reports: [], pages: [], - loading: false, error: null, - refresh: async () => {}, invalidate: () => {}, - ensureType: vi.fn(async (type: string) => - type === 'position' ? [{ name: 'sales_manager', label: 'Sales Manager' }] : [], - ), - getItem: async () => null, - getItemsByType: () => [], } as any; } const COLUMN_NAMES = ['label', 'object_name', 'recipient_type', 'recipient_id', 'note']; function renderGrid(schemaOverrides: Record, props: Record = {}) { - const dataSource = makeDataSource(); - const utils = render( - - - - - - - , + return render( + + + , ); - return { ...utils, dataSource }; } +/** The probes drawn, in DOM order. */ +const probes = () => screen.queryAllByTestId('widget-probe').map((el) => el.textContent); + /** - * Both hinted columns named on both rows, and the control printing the same - * strings raw. `getAllByText` counts TEXT nodes, not attributes, so the - * tooltips carrying the api name do not count toward the raw-string tallies. + * Both hinted columns drawn by their widget key's face on both rows, each with + * its own row, and the control printing the same strings raw. `getAllByText` + * counts TEXT nodes, so a probe (one text node holding the whole line) does not + * count toward the raw-string tallies. */ -async function expectReferencesNamed(dataSource: any) { - await waitFor(() => expect(screen.getByText('Sales Manager')).toBeInTheDocument()); - await waitFor(() => expect(screen.getByText('EU Sales')).toBeInTheDocument()); - await waitFor(() => expect(screen.getAllByText('Account')).toHaveLength(2)); - - const account = screen.getAllByText('Account')[0]; - expect(account.getAttribute('title'), 'the api name stays one hover away').toBe('Account (crm_account)'); - +async function expectWidgetFacesWithRow() { + await waitFor(() => expect(probes()).toHaveLength(4)); + expect([...probes()].sort()).toEqual([ + 'object-ref|crm_account|position', + 'object-ref|crm_account|team', + 'recipient-picker|sales_manager|position', + 'recipient-picker|team_eu|team', + ]); // The control column: the same strings, undeclared, print raw — once each. expect(screen.getAllByText('sales_manager')).toHaveLength(1); expect(screen.getAllByText('crm_account')).toHaveLength(1); - expect(screen.queryByText('team_eu'), 'the team id is named, not printed').toBeNull(); - - // One catalog read and one team query for the whole grid, not one per row. - expect(dataSource.getObjects).toHaveBeenCalledTimes(1); - expect(dataSource.find.mock.calls.filter(([object]: [string]) => object === 'sys_team')).toHaveLength(1); } -describe('objectui#12613 — every ObjectGrid path names an `object-ref` / `recipient-picker` value', () => { +describe('objectui#12613 — every ObjectGrid path draws a widget-hinted column with its key\'s face and the row', () => { it('A: object `columns`', async () => { - const { dataSource } = renderGrid({ + renderGrid({ columns: COLUMN_NAMES.map((field) => ({ field })), data: { provider: 'value', items: ROWS }, }); await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); - await expectReferencesNamed(dataSource); + await expectWidgetFacesWithRow(); }); it('B: string `columns` — the shape the platform list views declare', async () => { - const { dataSource } = renderGrid({ + renderGrid({ columns: COLUMN_NAMES, data: { provider: 'value', items: ROWS }, }); await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); - await expectReferencesNamed(dataSource); + await expectWidgetFacesWithRow(); }); it('C: inline-data projection (`fields`, no `columns`)', async () => { - const { dataSource } = renderGrid({ + renderGrid({ fields: COLUMN_NAMES, data: { provider: 'value', items: ROWS }, }); await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); - await expectReferencesNamed(dataSource); + await expectWidgetFacesWithRow(); }); it('D: object-schema default columns (rows handed down, no projection)', async () => { - const { dataSource } = renderGrid({}, { data: ROWS }); + renderGrid({}, { data: ROWS }); await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); - await expectReferencesNamed(dataSource); + await expectWidgetFacesWithRow(); }); }); From d47fb073c26f3ccb97970019bfa41cfa80dc94a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:17:09 +0000 Subject: [PATCH 5/5] fix(app-shell,fields): record recipients resolve by exact id again, the object face draws its icon, and fields publishes only what the face imports (objectui#12613) The app-shell directory reads record-kind recipients by exact id, one $in query per kind per tick, and builds the label at render with the viewer's denied fields removed (withoutDeniedFields), so a recipient past the picker's 500-row candidate page is named. The object face reads the console metadata store's object list for label and icon and draws the icon with LazyIcon. @object-ui/fields stops publishing loadObjectCatalog and loadRecipientCandidates: the widgets keep their own reads inline, and only the kind table, recipientCandidateLabel, objectRefOptionLabel and UnresolvedReferenceMark remain exported for the face. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12613-widget-ref-read-cells.md | 4 +- .../widget-ref-cells/WidgetRefCellFaces.tsx | 16 +- .../WidgetRefCellFaces-12613.test.tsx | 142 +++++++--- .../widget-ref-cells/refLabelDirectory.ts | 266 ++++++++++++------ packages/fields/README.md | 8 +- .../__tests__/widgetRefCells-12613.test.tsx | 32 +-- packages/fields/src/index.tsx | 12 +- .../fields/src/widgets/ObjectRefField.tsx | 33 ++- .../src/widgets/RecipientPickerField.tsx | 11 +- packages/fields/src/widgets/objectCatalog.ts | 72 ----- packages/fields/src/widgets/objectRefLabel.ts | 19 ++ packages/fields/src/widgets/recipientKinds.ts | 28 +- 12 files changed, 371 insertions(+), 272 deletions(-) delete mode 100644 packages/fields/src/widgets/objectCatalog.ts create mode 100644 packages/fields/src/widgets/objectRefLabel.ts diff --git a/.changeset/12613-widget-ref-read-cells.md b/.changeset/12613-widget-ref-read-cells.md index c97d34b4bc..8bacf9ec37 100644 --- a/.changeset/12613-widget-ref-read-cells.md +++ b/.changeset/12613-widget-ref-read-cells.md @@ -8,8 +8,8 @@ A list names what an `object-ref` or `recipient-picker` field references, instea - **The declared `widget` picks the read cell (`@object-ui/fields`).** `resolveCellRendererType` now promotes a textual field declaring `widget: 'object-ref'` or `widget: 'recipient-picker'` to a cell key of that name, the same second step a `format` hint takes. Both keys are registered with the stored text as their default face, as `RECORD_POINTER_CARD_TYPE` is. A widget with no read cell of its own (`filter-condition`, `position-ref`, …) still draws as its `type`, and nothing is keyed on an object or field name. - **`CellRendererProps` gains one optional member, `dependentValues`**: the row's values, the cell-side counterpart of the edit widget's `dependentValues`. `ObjectGrid` (`@object-ui/plugin-grid`) passes the row, and the widget hint, on every column path. A call site that passes nothing draws the stored text exactly as before. -- **The edit widgets' reads are published** so a host face names a value from the very lists the widgets offer: `loadObjectCatalog`, `objectRefOptionLabel`, `RECIPIENT_RECORD_KINDS`, `RECIPIENT_REGISTRY_KINDS`, `FIELD_RECIPIENT_TYPE`, `loadRecipientCandidates` and `recipientCandidateLabel`, plus the shared `UnresolvedReferenceMark` drawing. `ObjectRefField` and `RecipientPickerField` read through them and list and store exactly what they did. -- **The console's faces (`@object-ui/app-shell`)** register under the two keys and load lazily, on the first cell that draws one, so a page without such a column pays nothing on load. `object-ref` draws the object's label, translated through the app bundle like every other object label, with the picker's `Label (name)` spelling as its tooltip. `recipient-picker` draws the label of the user, team, business unit, position or user column the stored value names, from the list the picker offers: its candidate query for the record kinds, the metadata store for positions, the shared object's schema for the `field` kind. Each list is read once per data source for the whole page. +- **The edit widgets' kind table and spellings are published** so a host face names a value as the widgets do: `RECIPIENT_RECORD_KINDS`, `RECIPIENT_REGISTRY_KINDS`, `FIELD_RECIPIENT_TYPE`, `recipientCandidateLabel` and `objectRefOptionLabel`, plus the shared `UnresolvedReferenceMark` drawing. `ObjectRefField` and `RecipientPickerField` read through them and list and store exactly what they did. +- **The console's faces (`@object-ui/app-shell`)** register under the two keys and load lazily, on the first cell that draws one, so a page without such a column pays nothing on load. `object-ref` draws the object's icon and label from the console's metadata store (the same object list the picker offers), the label translated through the app bundle like every other object label, with the picker's `Label (name)` spelling as its tooltip. `recipient-picker` draws the label of the user, team, business unit, position or user column the stored value names: record recipients by their exact id, one `$in` query per kind for the whole page, with the fields the viewer may not read left out of the label; positions from the metadata store; the `field` kind from the shared object's schema. - **A value nothing names** (a deleted record, a renamed object, a read the viewer is refused) draws as the raw stored value, muted, with the shared unresolved-reference mark. It is never blank. Stored values do not change. diff --git a/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx b/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx index 4bfcf8f481..c6594c1d37 100644 --- a/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx +++ b/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx @@ -26,7 +26,7 @@ import { objectRefOptionLabel, type CellRendererProps, } from '@object-ui/fields'; -import { EmptyValue } from '@object-ui/components'; +import { EmptyValue, LazyIcon } from '@object-ui/components'; import { isEmptyValue } from '@object-ui/core'; import { useObjectLabel } from '@object-ui/i18n'; import { useObjectRefLabel, useRecipientLabel } from './refLabelDirectory.js'; @@ -42,9 +42,12 @@ function Unresolved({ text }: { text: string }) { } /** - * `object-ref`: the object's label, translated through the app bundle like - * every other object label, with the picker's own `Label (name)` spelling as - * its tooltip so the api name stays one hover away. + * `object-ref`: the object's icon and label, the label translated through the + * app bundle like every other object label, with the picker's own + * `Label (name)` spelling as its tooltip so the api name stays one hover away. + * The icon is drawn the way the console's navigation draws an object's — + * `LazyIcon`, which falls back to the database glyph for an object that names + * no icon (or one Lucide does not have). */ export function ObjectRefCellFace({ value, field }: CellRendererProps) { const ref = useObjectRefLabel(value); @@ -54,8 +57,9 @@ export function ObjectRefCellFace({ value, field }: CellRendererProps) { if (ref.state === 'resolved' && typeof value === 'string') { const label = objectLabel({ name: value, label: ref.label || value }); return ( - - {label} + + ); } diff --git a/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx b/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx index 69e327c209..4a90f7301d 100644 --- a/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx +++ b/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx @@ -11,8 +11,10 @@ * reference keys name what the stored value references. * * Every case carries its control: - * - the reads are the edit widgets' reads, made once per page, not once per - * cell — each case counts the calls; + * - nothing is read per cell — each case counts the calls; + * - a record recipient is read by its EXACT id, so one past the picker's + * 500-row candidate page is still named, with the fields the viewer may + * not read left out of its label; * - a value nothing could name draws muted, never blank, and a face that * could not ask (no row, no data source) draws the text it drew before; * - the registration case goes through the REAL key a declared widget @@ -25,6 +27,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, cleanup, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { MetadataCtx, SchemaRendererProvider, type MetadataContextValue } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import type { ObjectPermissionConfig } from '@object-ui/types'; import { getCellRenderer, resolveCellRendererType, type CellRendererProps } from '@object-ui/fields'; // The faces are what the registration loads lazily; imported at module scope // so the lazy factory below resolves at once (AGENTS.md, flaky-test rule). @@ -38,17 +42,46 @@ const POSITIONS = [ { name: 'eu_sales_lead', label: 'EU Sales Lead' }, ]; -function metadataStore(items: unknown[]): MetadataContextValue & { ensureType: ReturnType } { +/** The registered objects, as the metadata store lists them: full definitions. */ +const OBJECTS = [ + { name: 'crm_account', label: 'Account', icon: 'building' }, + { name: 'crm_contact', label: 'Contact' }, +]; + +/** A metadata store answering `ensureType(type)` from `byType`. */ +function metadataStore( + byType: Record = { position: POSITIONS, object: OBJECTS }, +): MetadataContextValue & { ensureType: ReturnType } { return { apps: [], objects: [], dashboards: [], reports: [], pages: [], loading: false, error: null, refresh: async () => {}, invalidate: () => {}, - ensureType: vi.fn().mockResolvedValue(items), + ensureType: vi.fn(async (type: string) => byType[type] ?? []), getItem: async () => null, getItemsByType: () => [], } as any; } +/** + * Six hundred users, and a `find` that answers the way the server does: an + * `$in` over ids returns exactly those rows, and the picker's candidate query + * (`$top: 500`, by name) returns only the first 500 — which `usr_550` is not in. + */ +const USERS = Array.from({ length: 600 }, (_, i) => ({ + id: `usr_${i}`, + name: `User ${String(i).padStart(3, '0')}`, + email: `user${i}@example.com`, +})); +function directorySource() { + return { + find: vi.fn(async (object: string, params: any) => { + const rows = object === 'sys_user' ? USERS : object === 'sys_team' ? [{ id: 'team_eu', name: 'EU Sales' }] : []; + const ids: string[] | undefined = params?.$filter?.id?.$in; + return { data: ids ? rows.filter((r) => ids.includes(r.id)) : rows.slice(0, params?.$top ?? rows.length) }; + }), + }; +} + interface CellCase { value: unknown; dependentValues?: Record; @@ -74,15 +107,6 @@ function renderFaces( const cell = (i: number) => screen.getByTestId(`cell-${i}`); const unresolvedMark = (i: number) => cell(i).querySelector('[data-slot="unresolved-reference"]'); -function catalogSource() { - return { - getObjects: vi.fn(async () => [ - { name: 'crm_account', label: 'Account' }, - { name: 'crm_contact', label: 'Contact' }, - ]), - }; -} - describe('objectui#12613 — the registered faces, through the key a declared widget resolves to', () => { it('registers both faces under the widget keys', () => { expect(getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'object-ref' }))).toBe(ObjectRefCell); @@ -90,8 +114,8 @@ describe('objectui#12613 — the registered faces, through the key a declared wi }); it('a rule with recipient `sales_manager` renders "Sales Manager", and its object renders "Account"', async () => { - const store = metadataStore(POSITIONS); - const ds = { ...catalogSource(), find: vi.fn() }; + const store = metadataStore(); + const ds = { find: vi.fn() }; const ObjectCell = getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'object-ref' })); const Recipient = getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'recipient-picker' })); const row = { object_name: 'crm_account', recipient_type: 'position', recipient_id: 'sales_manager' }; @@ -115,10 +139,11 @@ describe('objectui#12613 — the registered faces, through the key a declared wi }); describe('objectui#12613 — the `object-ref` face', () => { - it("draws the object's label, with the api name in the tooltip, from ONE catalog read", async () => { - const ds = catalogSource(); + it("draws the object's icon and label, with the api name in the tooltip, from ONE store read", async () => { + const store = metadataStore(); renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }, { value: 'crm_contact' }, { value: 'crm_account' }], { - dataSource: ds, + dataSource: { find: vi.fn() }, + store, }); await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); @@ -126,11 +151,20 @@ describe('objectui#12613 — the `object-ref` face', () => { expect(cell(0).querySelector('[title]')?.getAttribute('title')).toBe('Account (crm_account)'); expect(cell(1)).toHaveTextContent('Contact'); expect(cell(2)).toHaveTextContent('Account'); - expect(ds.getObjects, 'one catalog read for every row, not one per cell').toHaveBeenCalledTimes(1); + // The icon the object declares, and the database glyph for one that names + // none — the console's navigation draws an object the same way. + await waitFor(() => expect(cell(0).querySelector('svg.lucide-building')).not.toBeNull()); + await waitFor(() => expect(cell(1).querySelector('svg.lucide-database')).not.toBeNull()); + expect(cell(1).querySelector('svg.lucide-building'), 'the icon is the object\'s own').toBeNull(); + expect(store.ensureType, 'one object-list read for every row, not one per cell').toHaveBeenCalledTimes(1); + expect(store.ensureType).toHaveBeenCalledWith('object'); }); - it('draws a name the catalog does not list as the raw value, muted — never blank', async () => { - renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }, { value: 'crm_retired' }], { dataSource: catalogSource() }); + it('draws a name the store does not list as the raw value, muted — never blank', async () => { + renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }, { value: 'crm_retired' }], { + dataSource: { find: vi.fn() }, + store: metadataStore(), + }); await waitFor(() => expect(cell(0)).toHaveTextContent('Account')); await waitFor(() => expect(unresolvedMark(1), 'the unknown name wears the unresolved mark').not.toBeNull()); @@ -138,8 +172,8 @@ describe('objectui#12613 — the `object-ref` face', () => { expect(unresolvedMark(0), 'the resolved one does not').toBeNull(); }); - it('draws the stored text exactly as before when there is no data source to ask', () => { - renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }]); + it('draws the stored text exactly as before outside a metadata store', () => { + renderFaces(ObjectRefCellFace, [{ value: 'crm_account' }], { dataSource: { find: vi.fn() } }); expect(cell(0)).toHaveTextContent('crm_account'); expect(unresolvedMark(0)).toBeNull(); }); @@ -147,7 +181,7 @@ describe('objectui#12613 — the `object-ref` face', () => { describe('objectui#12613 — the `recipient-picker` face', () => { it('names position recipients through the registry, with one store read', async () => { - const store = metadataStore(POSITIONS); + const store = metadataStore(); renderFaces( RecipientCellFace, [ @@ -164,33 +198,55 @@ describe('objectui#12613 — the `recipient-picker` face', () => { expect(store.ensureType).toHaveBeenCalledWith('position'); }); - it("names record recipients from ONE read of the picker's own candidate query, and marks the one it does not list", async () => { - const find = vi.fn(async (_object: string, _params: any) => ({ - data: [ - { id: 'team_eu', name: 'EU Sales' }, - { id: 'team_us', name: 'US Sales' }, - ], - })); + it('names record recipients by EXACT id — one past the first 500 candidates too — with one `$in` query per kind', async () => { + const ds = directorySource(); renderFaces( RecipientCellFace, [ + // Not in the picker's first page of 500 candidates by name. + { value: 'usr_550', dependentValues: { recipient_type: 'user' } }, + { value: 'usr_7', dependentValues: { recipient_type: 'user' } }, { value: 'team_eu', dependentValues: { recipient_type: 'team' } }, - { value: 'team_us', dependentValues: { recipient_type: 'team' } }, - { value: 'team_gone', dependentValues: { recipient_type: 'team' } }, + { value: 'usr_gone', dependentValues: { recipient_type: 'user' } }, ], - { dataSource: { find } }, + { dataSource: ds }, ); - await waitFor(() => expect(cell(0)).toHaveTextContent('EU Sales')); - expect(cell(1)).toHaveTextContent('US Sales'); - await waitFor(() => expect(unresolvedMark(2)).not.toBeNull()); - expect(cell(2)).toHaveTextContent('team_gone'); + await waitFor(() => expect(cell(0)).toHaveTextContent('User 550')); + expect(cell(1)).toHaveTextContent('User 007'); + await waitFor(() => expect(cell(2)).toHaveTextContent('EU Sales')); + await waitFor(() => expect(unresolvedMark(3)).not.toBeNull()); + expect(cell(3)).toHaveTextContent('usr_gone'); + + // One query per kind for the whole page, each an exact-id read — never the + // picker's `$top: 500` candidate query. + expect(ds.find).toHaveBeenCalledTimes(2); + const byObject = Object.fromEntries(ds.find.mock.calls.map(([object, params]: [string, any]) => [object, params])); + expect([...byObject.sys_user.$filter.id.$in].sort()).toEqual(['usr_550', 'usr_7', 'usr_gone']); + expect(byObject.sys_user.$top).toBe(3); + expect(byObject.sys_team.$filter.id.$in).toEqual(['team_eu']); + }); + + it('leaves the fields the viewer may not read out of a record label, as the lookup picker does', async () => { + const denyName: ObjectPermissionConfig[] = [ + { + object: 'sys_user', + roles: { viewer: { actions: ['read'], fieldPermissions: [{ field: 'name', read: false }] } }, + } as ObjectPermissionConfig, + ]; + render( + + +
+ +
+
+
, + ); - expect(find, 'one query for the three rows, not one per cell').toHaveBeenCalledTimes(1); - const [object, params] = find.mock.calls[0]; - expect(object).toBe('sys_team'); - // The picker's query, so the list names exactly what the open picker offers. - expect(params).toEqual({ $top: 500, $orderby: { name: 'asc' } }); + // `name` is denied, so the label falls through to the kind's next field. + await waitFor(() => expect(cell(0)).toHaveTextContent('user550@example.com')); + expect(cell(0)).not.toHaveTextContent('User 550'); }); it("names a `field` recipient by the shared object's user column label", async () => { diff --git a/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts b/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts index 7ccfa0d59a..83e934f1fc 100644 --- a/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts +++ b/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts @@ -12,39 +12,41 @@ * object name) and `recipient-picker` (a stored recipient id or name, whose kind * a sibling `recipient_type` names). * - * ## The edit widgets' own lists, read once per page + * ## Where each answer comes from * - * A face names a stored value from the very list its edit widget offers, read - * through what `@object-ui/fields` publishes for exactly this: - * - * - `object-ref` → the registered-object catalog, `loadObjectCatalog`, the - * read `ObjectRefField` makes; - * - a record kind (`user`, `team`, `business_unit`, `unit_and_subordinates`) - * → `loadRecipientCandidates`, the picker's one candidate query, labelled - * with the kind's `labelFields` from `RECIPIENT_RECORD_KINDS`; - * - a registry kind (`position`, `RECIPIENT_REGISTRY_KINDS`) → the console's - * metadata store, `ensureType()`, the reader `RecipientPickerField` - * lists it with; + * - `object-ref` → the console's metadata store, `ensureType('object')`: the + * same `GET /meta/object` list `ObjectRefField` offers, as full object + * definitions, so the label AND the icon come from one read the console + * already caches for the session; + * - a record kind (`user`, `team`, `business_unit`, `unit_and_subordinates`, + * `RECIPIENT_RECORD_KINDS`) → the stored ids THEMSELVES, read by exact id: + * every id any cell asks for in one tick goes out as one `$in` query per + * kind. ⛔ Not the picker's candidate list: that query stops at 500 rows by + * name, and a recipient past it is a real recipient the list must still + * name. The ROW is kept, and its label is built at render with the fields + * the viewer may not read removed (`withoutDeniedFields`), as the lookup + * picker builds its option labels; + * - a registry kind (`position`, `RECIPIENT_REGISTRY_KINDS`) → the metadata + * store, `ensureType()`, the reader `RecipientPickerField` lists it + * with; * - the `field` kind → the shared object's schema through * `deriveUserFields`, the picker's own column derivation. * - * Each list is a CATALOG: read the first time any cell asks it, once per data - * source, and every cell of every row answered from it. A grid page of fifty - * rules costs one read per list it names, not fifty, and a value reads in the - * list exactly as it reads in the open picker, including the picker's bound (a - * record past the candidate query's first 500 is not offered, and not named). + * Nothing is read per cell: a catalog (the objects, a registry type, one + * object's user columns) is read the first time any cell asks it, and record + * ids are batched per kind, once per data source. * - * ## The four answers a face gets + * ## The answers a face gets * * - `unasked` — the edit widget could not list it either: no data source, no - * sibling kind, a kind it does not know, no metadata store for a registry + * sibling kind, a kind it does not know, no metadata store for a store-read * kind. The face draws the stored text exactly as the text cell does, so a * call site that cannot supply what the face needs changes nothing. * - `pending` — asked, not answered yet. Drawn the same as `unasked`. - * - `resolved` — the label. - * - `unresolved` — the list was read and does not hold the value (a deleted - * record, a renamed object), or the read failed (no read access). The face - * draws the raw stored value, muted, never a blank. + * - `resolved` — the label (and, for an object, its icon). + * - `unresolved` — the read answered without the value (a deleted record, a + * renamed object), or failed (no read access). The face draws the raw + * stored value, muted, never a blank. * * ## Cache lifetime * @@ -52,7 +54,9 @@ * display label, not a decision input, so an item renamed mid-session reading * its old label until reload is the right trade (the same one * `useApproverDirectory` makes). Keyed by the data source object, so two hosts, - * and two tests, never share an answer. + * and two tests, never share an answer. The record LABEL is not cached: it is + * rebuilt from the cached row on every render, so a read policy that loads + * after the row arrived relabels the cell. * * ## Why it lives here, behind a lazy import * @@ -68,14 +72,14 @@ import React from 'react'; import { MetadataCtx, SchemaRendererContext } from '@object-ui/react'; +import { extractRecords, withoutDeniedFields } from '@object-ui/core'; +import { usePermissions } from '@object-ui/permissions'; import type { DataSource } from '@object-ui/types'; import { FIELD_RECIPIENT_TYPE, RECIPIENT_RECORD_KINDS, RECIPIENT_REGISTRY_KINDS, deriveUserFields, - loadObjectCatalog, - loadRecipientCandidates, recipientCandidateLabel, } from '@object-ui/fields'; @@ -83,22 +87,38 @@ import { export type RefLabel = | { readonly state: 'unasked' } | { readonly state: 'pending' } - | { readonly state: 'resolved'; readonly label: string } + | { readonly state: 'resolved'; readonly label: string; readonly icon?: string } | { readonly state: 'unresolved' }; +/** A book entry: a `RefLabel`, or — for a record kind — the row it was read as. */ +type Entry = RefLabel | { readonly state: 'record'; readonly row: Readonly> }; + const UNASKED: RefLabel = Object.freeze({ state: 'unasked' }); const PENDING: RefLabel = Object.freeze({ state: 'pending' }); const UNRESOLVED: RefLabel = Object.freeze({ state: 'unresolved' }); -/** A list's name → label pairs, or `null` when its read failed. */ -type Catalog = ReadonlyMap | null; -type CatalogLoad = () => Promise>; +/** At most this many ids go into one `$in` query. */ +const ID_QUERY_CHUNK = 100; + +/** One catalog member's face: its label, and an icon when the list has one. */ +interface CatalogItem { + label: string; + icon?: string; +} +/** A catalog's members by name, or `null` when its read failed. */ +type Catalog = ReadonlyMap | null; +type CatalogLoad = () => Promise>; /** Every answer one data source has given, and the reads still in flight. */ class LabelBook { - private readonly entries = new Map(); + private readonly entries = new Map(); private readonly listeners = new Set<() => void>(); private readonly catalogs = new Map>(); + /** Record kind → id → entry key, waiting for the next flush. */ + private recordQueue = new Map>(); + private flushScheduled = false; + + constructor(private readonly dataSource: DataSource) {} readonly subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); @@ -107,10 +127,14 @@ class LabelBook { }; }; - read(key: string): RefLabel | undefined { + read(key: string): Entry | undefined { return this.entries.get(key); } + private publish(): void { + for (const listener of [...this.listeners]) listener(); + } + /** * Ask catalog `catalogId` for `name`, loading it with `load` the first time * any cell asks it. The answer is published from a promise callback, so a @@ -127,11 +151,61 @@ class LabelBook { this.catalogs.set(catalogId, catalog); } void catalog.then((map) => { - const label = map?.get(name); - this.entries.set(key, label === undefined ? UNRESOLVED : Object.freeze({ state: 'resolved', label })); - for (const listener of [...this.listeners]) listener(); + const item = map?.get(name); + this.entries.set(key, item ? Object.freeze({ state: 'resolved', ...item }) : UNRESOLVED); + this.publish(); }); } + + /** Ask for one record of a record kind by its stored id; batched per kind. */ + askRecord(kind: string, id: string, key: string): void { + if (this.entries.has(key)) return; + this.entries.set(key, PENDING); + let ids = this.recordQueue.get(kind); + if (!ids) { + ids = new Map(); + this.recordQueue.set(kind, ids); + } + ids.set(id, key); + if (this.flushScheduled) return; + this.flushScheduled = true; + queueMicrotask(() => { + this.flushScheduled = false; + const queue = this.recordQueue; + this.recordQueue = new Map(); + for (const [queuedKind, queuedIds] of queue) void this.readRecords(queuedKind, queuedIds); + }); + } + + /** One `$in` query per kind (per chunk) over the ids asked this tick. */ + private async readRecords(kind: string, ids: ReadonlyMap): Promise { + const mapping = RECIPIENT_RECORD_KINDS[kind]; + const rowsById = new Map>>(); + let failed = !mapping; + if (mapping) { + try { + const all = [...ids.keys()]; + for (let i = 0; i < all.length; i += ID_QUERY_CHUNK) { + const chunk = all.slice(i, i + ID_QUERY_CHUNK); + const res = await this.dataSource.find(mapping.object, { + $filter: { [mapping.storeField]: { $in: chunk } }, + $top: chunk.length, + }); + for (const row of extractRecords(res)) { + const stored = (row as Record | null)?.[mapping.storeField]; + if (stored != null) rowsById.set(String(stored), row as Readonly>); + } + } + } catch { + failed = true; + } + } + for (const [id, key] of ids) { + const row = failed ? undefined : rowsById.get(id); + this.entries.set(key, row ? Object.freeze({ state: 'record', row }) : UNRESOLVED); + } + this.publish(); + } } const books = new WeakMap(); @@ -139,7 +213,7 @@ const books = new WeakMap(); function bookFor(dataSource: DataSource): LabelBook { let book = books.get(dataSource); if (!book) { - book = new LabelBook(); + book = new LabelBook(dataSource); books.set(dataSource, book); } return book; @@ -148,39 +222,64 @@ function bookFor(dataSource: DataSource): LabelBook { const noSubscription = (): (() => void) => () => {}; /** - * The one store subscription both cells make: `name` asked of the catalog - * `catalogId` names (`''` = nothing can be asked), loaded by `load`. `load` is + * The one store subscription both faces make: the entry under `key`, asked by + * `ask` when a cell first subscribes (`key` '' = nothing can be asked). `ask` is * read through a ref so a render's fresh closure never re-subscribes a cell. */ -function useCatalogLabel(dataSource: DataSource | null, catalogId: string, name: string, load: CatalogLoad): RefLabel { - const book = catalogId && dataSource ? bookFor(dataSource) : null; - const key = JSON.stringify([catalogId, name]); - const loadRef = React.useRef(load); +function useBookEntry(dataSource: DataSource | null, key: string, ask: (book: LabelBook) => void): Entry { + const book = key && dataSource ? bookFor(dataSource) : null; + const askRef = React.useRef(ask); // An effect, so it runs before the subscription's own effect below. React.useEffect(() => { - loadRef.current = load; + askRef.current = ask; }); const subscribe = React.useCallback( (listener: () => void) => { if (!book) return noSubscription(); const off = book.subscribe(listener); - book.ask(catalogId, name, key, () => loadRef.current()); + askRef.current(book); return off; }, - [book, catalogId, name, key], + [book, key], ); const getSnapshot = React.useCallback(() => (book ? book.read(key) ?? PENDING : UNASKED), [book, key]); return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } -/** The registered object a stored `object-ref` value names. */ +/** + * The console's metadata store, read raw — an absent store and an empty + * registry are different facts — and through a ref, because the store's + * context value is rebuilt whenever any type it caches settles (AGENTS.md #10). + */ +function useMetadataStoreRef() { + const store = React.useContext(MetadataCtx); + const ref = React.useRef(store); + React.useEffect(() => { + ref.current = store; + }); + return { present: !!store, ref }; +} + +/** The registered object a stored `object-ref` value names: its label and icon. */ export function useObjectRefLabel(value: unknown): RefLabel { const dataSource = React.useContext(SchemaRendererContext)?.dataSource ?? null; - const canList = typeof dataSource?.getObjects === 'function' || typeof dataSource?.find === 'function'; - return useCatalogLabel(dataSource, typeof value === 'string' && value && canList ? 'object' : '', String(value), async () => { - const rows = await loadObjectCatalog(dataSource); - return new Map(rows.map((row) => [row.name, row.label ?? ''])); - }); + const store = useMetadataStoreRef(); + const name = typeof value === 'string' ? value : ''; + const key = name && store.present ? JSON.stringify(['object', name]) : ''; + return useBookEntry(dataSource, key, (book) => + book.ask('object', name, key, async () => { + const items = (await store.ref.current?.ensureType('object')) ?? []; + const map = new Map(); + for (const item of Array.isArray(items) ? items : []) { + if (typeof item?.name !== 'string' || !item.name) continue; + map.set(item.name, { + label: typeof item.label === 'string' ? item.label : '', + ...(typeof item.icon === 'string' && item.icon ? { icon: item.icon } : {}), + }); + } + return map; + }), + ) as RefLabel; } /** @@ -193,14 +292,8 @@ export function useRecipientLabel( dependentValues: Readonly> | undefined, ): RefLabel { const dataSource = React.useContext(SchemaRendererContext)?.dataSource ?? null; - // Read raw, as the picker reads it: an absent store and an empty registry - // are different facts. Through a ref, because the store's context value is - // rebuilt whenever any type it caches settles (AGENTS.md #10). - const metadataStore = React.useContext(MetadataCtx); - const storeRef = React.useRef(metadataStore); - React.useEffect(() => { - storeRef.current = metadataStore; - }); + const store = useMetadataStoreRef(); + const perms = usePermissions(); // The same sibling reads the edit widget makes: `recipient_type` picks the // kind, and in `field` mode `object_name` names the object whose columns // are offered — both declared in the field's own `dependsOn`. @@ -208,31 +301,38 @@ export function useRecipientLabel( const objectName = String(dependentValues?.object_name ?? ''); const record = RECIPIENT_RECORD_KINDS[kind]; const registry = RECIPIENT_REGISTRY_KINDS[kind]; - const catalogId = - !dependentValues || typeof value !== 'string' || !value - ? '' - : record - ? typeof dataSource?.find === 'function' ? `record:${kind}` : '' - : registry - ? metadataStore ? `registry:${registry.type}` : '' - : kind === FIELD_RECIPIENT_TYPE && objectName && typeof dataSource?.getObjectSchema === 'function' - ? `field:${objectName}` - : ''; - return useCatalogLabel(dataSource, catalogId, String(value), async () => { - // `catalogId` is '' without a data source, so no load runs without one. - if (!dataSource) return new Map(); - if (record) { - const rows = await loadRecipientCandidates(dataSource, record); - return new Map(rows.map((row) => [String((row as Record)?.[record.storeField] ?? ''), recipientCandidateLabel(record.labelFields, row)])); - } + const id = dependentValues && typeof value === 'string' ? value : ''; + const key = !id + ? '' + : record + ? typeof dataSource?.find === 'function' ? JSON.stringify(['record', kind, id]) : '' + : registry + ? store.present ? JSON.stringify(['registry', registry.type, id]) : '' + : kind === FIELD_RECIPIENT_TYPE && objectName && typeof dataSource?.getObjectSchema === 'function' + ? JSON.stringify(['field', objectName, id]) + : ''; + const entry = useBookEntry(dataSource, key, (book) => { + if (record) return book.askRecord(kind, id, key); if (registry) { - const items = (await storeRef.current?.ensureType(registry.type)) ?? []; - return new Map( - (Array.isArray(items) ? items : []) - .filter((item) => typeof item?.name === 'string' && item.name) - .map((item) => [item.name as string, recipientCandidateLabel(registry.labelFields, item)]), - ); + return book.ask(`registry:${registry.type}`, id, key, async () => { + const items = (await store.ref.current?.ensureType(registry.type)) ?? []; + const map = new Map(); + for (const item of Array.isArray(items) ? items : []) { + if (typeof item?.name === 'string' && item.name) { + map.set(item.name, { label: recipientCandidateLabel(registry.labelFields, item) }); + } + } + return map; + }); } - return new Map(deriveUserFields(await dataSource.getObjectSchema(objectName)).map((f) => [f.name, f.label])); + book.ask(`field:${objectName}`, id, key, async () => { + const schema = dataSource ? await dataSource.getObjectSchema(objectName) : null; + return new Map(deriveUserFields(schema).map((f) => [f.name, { label: f.label }])); + }); }); + if (entry.state !== 'record') return entry; + // Built at render, from the row with the fields this viewer may not read + // removed, so a policy that loads after the row relabels the cell. + const shown = withoutDeniedFields(entry.row, perms, record?.object, record ? [record.storeField] : undefined); + return { state: 'resolved', label: recipientCandidateLabel(record?.labelFields, shown) }; } diff --git a/packages/fields/README.md b/packages/fields/README.md index eaf221df28..65493e7b28 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -62,10 +62,10 @@ const MyGridCell = ({ field, value, row }: CellRendererProps & { row: Record { }); }); -describe('objectui#12613 — the published reads are the edit widgets\' reads', () => { - it('`loadObjectCatalog` lists through `getObjects`, then the `sys_metadata` fallback', async () => { - const getObjects = vi.fn(async () => [{ name: 'crm_account', label: 'Account' }]); - expect(await loadObjectCatalog({ getObjects } as any)).toEqual([{ name: 'crm_account', label: 'Account' }]); - - const find = vi.fn(async () => ({ data: [{ name: 'crm_contact', label: 'Contact' }, { label: 'nameless' }] })); - expect(await loadObjectCatalog({ find } as any)).toEqual([{ name: 'crm_contact', label: 'Contact' }]); - expect(find).toHaveBeenCalledWith('sys_metadata', { $filter: { type: 'object' }, $top: 1000 }); - }); - - it("`loadRecipientCandidates` is the picker's one candidate query, and labels follow the kind's fields", async () => { - const find = vi.fn(async () => ({ data: [{ id: 'team_eu', name: 'EU Sales' }] })); - const rows = await loadRecipientCandidates({ find } as any, RECIPIENT_RECORD_KINDS.team); - expect(find).toHaveBeenCalledWith('sys_team', { $top: 500, $orderby: { name: 'asc' } }); - expect(recipientCandidateLabel(RECIPIENT_RECORD_KINDS.team.labelFields, rows[0])).toBe('EU Sales'); +describe("objectui#12613 — what is published is the edit widgets' own table and spellings", () => { + it("labels a candidate by its kind's fields, then its id, and spells an object `Label (name)`", () => { + expect(recipientCandidateLabel(RECIPIENT_RECORD_KINDS.team.labelFields, { id: 'team_eu', name: 'EU Sales' })).toBe('EU Sales'); // No label field filled ⇒ the id, as the picker labels such a row. expect(recipientCandidateLabel(RECIPIENT_RECORD_KINDS.user.labelFields, { id: 'usr_9' })).toBe('usr_9'); + expect(RECIPIENT_RECORD_KINDS.team.object).toBe('sys_team'); expect(RECIPIENT_REGISTRY_KINDS.position.type).toBe('position'); expect(FIELD_RECIPIENT_TYPE).toBe('field'); + expect(objectRefOptionLabel('crm_account', 'Account')).toBe('Account (crm_account)'); + expect(objectRefOptionLabel('crm_account', undefined)).toBe('crm_account'); }); }); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 5586dabad7..04f2742df9 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -5009,18 +5009,16 @@ export * from './widgets/CapabilityMultiSelectField.js'; export * from './widgets/ObjectRefField.js'; export * from './widgets/FilterConditionField.js'; export * from './widgets/RecipientPickerField.js'; -// The reads and the kind table the two reference widgets list from -// (objectui#12613), published so a host face names a stored value from the very -// same lists: `@object-ui/app-shell`'s widget reference cells are the reader. -export { loadObjectCatalog, objectRefOptionLabel, type ObjectHeader, type ObjectCatalogSource } from './widgets/objectCatalog.js'; +// The kind table and the two spellings the reference widgets share with a host +// face (objectui#12613), published so the face names a stored value as the +// widget does: `@object-ui/app-shell`'s widget reference cells are the reader. +// Only what that reader imports; the widgets' own reads stay module-private. +export { objectRefOptionLabel } from './widgets/objectRefLabel.js'; export { RECIPIENT_RECORD_KINDS, RECIPIENT_REGISTRY_KINDS, FIELD_RECIPIENT_TYPE, - loadRecipientCandidates, recipientCandidateLabel, - type RecipientMapping, - type RegistryRecipientMapping, } from './widgets/recipientKinds.js'; export * from './widgets/RecordPickerDialog.js'; // Shared picker-column derivation (ADR-0085 highlightFields → displayFields → diff --git a/packages/fields/src/widgets/ObjectRefField.tsx b/packages/fields/src/widgets/ObjectRefField.tsx index 5787e2e0f5..e8a5ced56f 100644 --- a/packages/fields/src/widgets/ObjectRefField.tsx +++ b/packages/fields/src/widgets/ObjectRefField.tsx @@ -4,7 +4,7 @@ import { SchemaRendererContext } from '@object-ui/react'; import type { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; import { useFieldTranslation } from './useFieldTranslation.js'; -import { loadObjectCatalog, objectRefOptionLabel, type ObjectHeader } from './objectCatalog.js'; +import { objectRefOptionLabel } from './objectRefLabel.js'; /** * ObjectRefField — object-name picker for form fields that store the machine @@ -15,11 +15,18 @@ import { loadObjectCatalog, objectRefOptionLabel, type ObjectHeader } from './ob * the platform's registered objects instead of a free-text input where the * admin has to remember a machine name. * - * The object catalog is `loadObjectCatalog` (`./objectCatalog.ts`), the read - * the list cell names a stored object from as well (objectui#12613). + * Object catalog source, in order of preference: + * 1. `dataSource.getObjects()` — the metadata-registry list endpoint + * (`GET /api/v1/meta/object`), which merges code- and DB-defined objects. + * 2. Fallback: `dataSource.find('sys_metadata', { type: 'object' })` — the + * same source the Setup app's object grid browses. * The stored value is always the object's `name` string. If the catalog can't * load, the current value is still shown, so the control degrades safely. */ +interface ObjectHeader { + name: string; + label?: string; +} export function ObjectRefField({ value, @@ -45,7 +52,23 @@ export function ObjectRefField({ let cancelled = false; (async () => { try { - const rows = await loadObjectCatalog(dataSource); + let rows: ObjectHeader[] = []; + if (typeof dataSource.getObjects === 'function') { + rows = (await dataSource.getObjects()) ?? []; + } else if (typeof dataSource.find === 'function') { + const res = await dataSource.find('sys_metadata', { + $filter: { type: 'object' }, + $top: 1000, + }); + const list: any[] = + res?.data ?? res?.records ?? (Array.isArray(res) ? res : []); + rows = list + .map((r) => ({ + name: String(r?.name ?? ''), + label: r?.label != null ? String(r.label) : undefined, + })) + .filter((r) => r.name); + } if (!cancelled) setObjects(rows); } catch { if (!cancelled) setObjects([]); @@ -59,6 +82,8 @@ export function ObjectRefField({ const options = React.useMemo(() => { const opts = (objects ?? []).map((o) => ({ value: o.name, + // The one `Label (name)` spelling, shared with the console's read face + // for this field (objectui#12613). label: objectRefOptionLabel(o.name, o.label), })); // Keep the current value selectable even if the catalog didn't include it diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx index b9b75dba67..e374a7916d 100644 --- a/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/packages/fields/src/widgets/RecipientPickerField.tsx @@ -10,7 +10,6 @@ import { FIELD_RECIPIENT_TYPE, RECIPIENT_RECORD_KINDS, RECIPIENT_REGISTRY_KINDS, - loadRecipientCandidates, recipientCandidateLabel, } from './recipientKinds.js'; @@ -173,9 +172,13 @@ export function RecipientPickerField({ let cancelled = false; (async () => { try { - // The one candidate query, shared with the read cell (objectui#12613). - const list = await loadRecipientCandidates(dataSource, recordMapping); - if (!cancelled) setRecords(list); + // Object form, not the 'name asc' clause string: the clause string is + // supported again (objectstack#3821 fixed ApiDataSource walking it + // character by character), but the structured form can't regress that + // way for any data source. + const res = await dataSource.find(recordMapping.object, { $top: 500, $orderby: { name: 'asc' } }); + const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []); + if (!cancelled) setRecords(Array.isArray(list) ? list : []); } catch { if (!cancelled) setRecords([]); } diff --git a/packages/fields/src/widgets/objectCatalog.ts b/packages/fields/src/widgets/objectCatalog.ts deleted file mode 100644 index eab77e3c53..0000000000 --- a/packages/fields/src/widgets/objectCatalog.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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 registered-object catalog an `object-ref` field reads (objectui#12613). - * - * ONE read, two readers: `ObjectRefField` (the edit widget) lists the catalog as - * its options, and the console's read face for the field (`@object-ui/app-shell`'s - * widget reference cells) names a stored object from it. Published from the - * package barrel for that reader. No React here. - */ - -import type { DataSource } from '@object-ui/types'; - -/** The two reads the catalog can be loaded through. */ -export type ObjectCatalogSource = Partial>; - -/** One `sys_metadata` row, as far as the catalog reads it. */ -type CatalogRow = { name?: unknown; label?: unknown } | null | undefined; - -/** One registered object, as the catalog lists it. */ -export interface ObjectHeader { - name: string; - label?: string; -} - -/** - * Load the platform's registered objects, in order of preference: - * 1. `dataSource.getObjects()` — the metadata-registry list endpoint - * (`GET /api/v1/meta/object`), which merges code- and DB-defined objects. - * 2. Fallback: `dataSource.find('sys_metadata', { type: 'object' })` — the - * same source the Setup app's object grid browses. - * - * Resolves `[]` for a data source that offers neither read. A failed read - * REJECTS, and each caller decides what a failure means for its face. - */ -export async function loadObjectCatalog( - dataSource: ObjectCatalogSource | null | undefined, -): Promise { - if (typeof dataSource?.getObjects === 'function') { - return (await dataSource.getObjects()) ?? []; - } - if (typeof dataSource?.find === 'function') { - // The rows ladder `ObjectRefField` has always read here, moved verbatim. - const res = (await dataSource.find('sys_metadata', { - $filter: { type: 'object' }, - $top: 1000, - })) as unknown as { data?: CatalogRow[]; records?: CatalogRow[] } | CatalogRow[] | null | undefined; - const list: CatalogRow[] = Array.isArray(res) ? res : (res?.data ?? res?.records ?? []); - return list - .map((r) => ({ - name: String(r?.name ?? ''), - label: r?.label != null ? String(r.label) : undefined, - })) - .filter((r) => r.name); - } - return []; -} - -/** - * An object's name as the picker spells it, `Label (name)`, or the bare name - * when the catalog gives it no label. The picker uses it for each option and - * the read face for its tooltip, so the two faces spell an object one way. - */ -export function objectRefOptionLabel(name: string, label: string | undefined): string { - return label ? `${label} (${name})` : name; -} diff --git a/packages/fields/src/widgets/objectRefLabel.ts b/packages/fields/src/widgets/objectRefLabel.ts new file mode 100644 index 0000000000..013e8b6787 --- /dev/null +++ b/packages/fields/src/widgets/objectRefLabel.ts @@ -0,0 +1,19 @@ +/** + * 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. + */ + +/** + * An object's name as the `object-ref` picker spells it, `Label (name)`, or the + * bare name when the object has no label (objectui#12613). `ObjectRefField` + * labels each option with it, and the console's read face for the field + * (`@object-ui/app-shell`'s widget reference cells) uses it for its tooltip, so + * the two faces spell an object one way. Published from the package barrel for + * that reader. No React here. + */ +export function objectRefOptionLabel(name: string, label: string | undefined): string { + return label ? `${label} (${name})` : name; +} diff --git a/packages/fields/src/widgets/recipientKinds.ts b/packages/fields/src/widgets/recipientKinds.ts index 01510fc3e4..45d40ecf9b 100644 --- a/packages/fields/src/widgets/recipientKinds.ts +++ b/packages/fields/src/widgets/recipientKinds.ts @@ -16,13 +16,12 @@ * from it. They lived inside the widget until the face needed the same answer; * a second copy beside the face is the drift this module exists to prevent, so * a kind added here reaches both faces at once. Published from the package - * barrel for that reader. + * barrel for that reader. The picker's candidate query is not shared: it stops + * at 500 rows by name, and the face reads stored ids by exact id instead. * * No React here. */ -import type { DataSource } from '@object-ui/types'; - export interface RecipientMapping { object: string; /** Which record field to persist into recipient_id. */ @@ -91,29 +90,6 @@ export const RECIPIENT_REGISTRY_KINDS: Readonly, - mapping: RecipientMapping, -): Promise { - const res = (await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } })) as unknown as - | { data?: unknown[]; records?: unknown[] } - | unknown[] - | null - | undefined; - const list = Array.isArray(res) ? res : (res?.data ?? res?.records ?? []); - return Array.isArray(list) ? list : []; -} - /** * The display label of one candidate: the first non-empty member of the * kind's `labelFields`, then the record id. The picker labels its options with