diff --git a/.changeset/12613-widget-ref-read-cells.md b/.changeset/12613-widget-ref-read-cells.md new file mode 100644 index 0000000000..8bacf9ec37 --- /dev/null +++ b/.changeset/12613-widget-ref-read-cells.md @@ -0,0 +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 (`@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' 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/package.json b/packages/app-shell/package.json index 08b20fdcdd..7dfa16b768 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..c6594c1d37 --- /dev/null +++ b/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx @@ -0,0 +1,90 @@ +/** + * 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, LazyIcon } 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 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); + 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 ( + + + ); + } + 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..4a90f7301d --- /dev/null +++ b/packages/app-shell/src/views/widget-ref-cells/__tests__/WidgetRefCellFaces-12613.test.tsx @@ -0,0 +1,297 @@ +/** + * 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: + * - 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 + * 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 { 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). +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' }, +]; + +/** 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(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; +} + +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"]'); + +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(); + 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' }; + 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 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: { find: vi.fn() }, + store, + }); + + 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'); + // 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 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()); + expect(cell(1)).toHaveTextContent('crm_retired'); + expect(unresolvedMark(0), 'the resolved one does not').toBeNull(); + }); + + 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(); + }); +}); + +describe('objectui#12613 — the `recipient-picker` face', () => { + it('names position recipients through the registry, with one store read', async () => { + const store = metadataStore(); + 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 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: 'usr_gone', dependentValues: { recipient_type: 'user' } }, + ], + { dataSource: ds }, + ); + + 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( + + +
+ +
+
+
, + ); + + // `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 () => { + 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/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts b/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts new file mode 100644 index 0000000000..83e934f1fc --- /dev/null +++ b/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts @@ -0,0 +1,338 @@ +/** + * 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 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). + * + * ## Where each answer comes from + * + * - `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. + * + * 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 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 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 (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 + * + * 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 + * `useApproverDirectory` makes). Keyed by the data source object, so two hosts, + * 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 + * + * 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` 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 { 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, + recipientCandidateLabel, +} from '@object-ui/fields'; + +/** What a face 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 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' }); + +/** 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 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); + return () => { + this.listeners.delete(listener); + }; + }; + + 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 + * subscriber registering right now always hears it. + */ + ask(catalogId: string, name: string, key: string, load: CatalogLoad): void { + if (this.entries.has(key)) return; + this.entries.set(key, PENDING); + let catalog = this.catalogs.get(catalogId); + if (!catalog) { + catalog = Promise.resolve() + .then(load) + .catch(() => null); + this.catalogs.set(catalogId, catalog); + } + void catalog.then((map) => { + 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(); + +function bookFor(dataSource: DataSource): LabelBook { + let book = books.get(dataSource); + if (!book) { + book = new LabelBook(dataSource); + books.set(dataSource, book); + } + return book; +} + +const noSubscription = (): (() => void) => () => {}; + +/** + * 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 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(() => { + askRef.current = ask; + }); + const subscribe = React.useCallback( + (listener: () => void) => { + if (!book) return noSubscription(); + const off = book.subscribe(listener); + askRef.current(book); + return off; + }, + [book, key], + ); + const getSnapshot = React.useCallback(() => (book ? book.read(key) ?? PENDING : UNASKED), [book, key]); + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * 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 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; +} + +/** + * 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 dataSource = React.useContext(SchemaRendererContext)?.dataSource ?? null; + 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`. + const kind = String(dependentValues?.recipient_type ?? ''); + const objectName = String(dependentValues?.object_name ?? ''); + const record = RECIPIENT_RECORD_KINDS[kind]; + const registry = RECIPIENT_REGISTRY_KINDS[kind]; + 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) { + 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; + }); + } + 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/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 3026d5c507..c8a24d1db8 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -42,18 +42,31 @@ 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: a `recipient-picker` face reads the row's + // `recipient_type` from it. Without it, that face draws the stored text. + return ; }; ``` +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 name a value as the edit widgets do, through the kind table +and the spellings this package publishes for it: `RECIPIENT_RECORD_KINDS`, +`RECIPIENT_REGISTRY_KINDS`, `FIELD_RECIPIENT_TYPE`, `recipientCandidateLabel`, +`objectRefOptionLabel` and the shared `UnresolvedReferenceMark` drawing. + ## 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/__tests__/widgetRefCells-12613.test.tsx b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx new file mode 100644 index 0000000000..7723753451 --- /dev/null +++ b/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx @@ -0,0 +1,87 @@ +/** + * 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 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 under the text key, where no face could reach them. + * + * 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 kind table and the + * spellings its edit widgets label with, 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, afterEach } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { + getCellRenderer, + listCellRendererTypes, + objectRefOptionLabel, + recipientCandidateLabel, + resolveCellRendererType, + RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_KINDS, + FIELD_RECIPIENT_TYPE, + TextCellRenderer, +} from '../index'; + +afterEach(() => cleanup()); + +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 + // 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'); + }); + + 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); + + const Renderer = getCellRenderer(resolveCellRendererType({ type: 'text', widget: 'recipient-picker' })); + render( +
+ +
, + ); + expect(screen.getByTestId('cell')).toHaveTextContent('sales_manager'); + }); +}); + +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 14106cf145..bc5716bcb1 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -376,6 +376,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 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 + * 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 @@ -2983,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 @@ -3003,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, @@ -3336,16 +3349,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 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 + * (`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)) { @@ -4055,6 +4087,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 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. @@ -5018,6 +5061,17 @@ export * from './widgets/CapabilityMultiSelectField.js'; export * from './widgets/ObjectRefField.js'; export * from './widgets/FilterConditionField.js'; export * from './widgets/RecipientPickerField.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, + recipientCandidateLabel, +} 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/ObjectRefField.tsx b/packages/fields/src/widgets/ObjectRefField.tsx index d07261113d..e8a5ced56f 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 { objectRefOptionLabel } from './objectRefLabel.js'; /** * ObjectRefField — object-name picker for form fields that store the machine @@ -81,7 +82,9 @@ export function ObjectRefField({ const options = React.useMemo(() => { const opts = (objects ?? []).map((o) => ({ value: o.name, - label: o.label ? `${o.label} (${o.name})` : 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 // (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..e374a7916d 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, + RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_KINDS, + 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`. */ @@ -178,13 +127,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 ?? ''); @@ -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/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 new file mode 100644 index 0000000000..45d40ecf9b --- /dev/null +++ b/packages/fields/src/widgets/recipientKinds.ts @@ -0,0 +1,103 @@ +/** + * 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 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. 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. + */ + +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 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' }, + 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 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 + * `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` + * 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/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/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index b2ee8f0769..73d504e913 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -3811,7 +3811,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 @@ -3882,7 +3889,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 = pointerFace?.(row) ?? (LinkContentRenderer - ? + ? : (value != null && value !== '' ? String(value) : )); return ( = ({ // Link column: clicking navigates to the record detail cellRenderer = (value: any, row: any) => { const displayContent = pointerFace?.(row) ?? (LinkContentRenderer - ? + ? : (value != null && value !== '' ? String(value) : )); return ( = ({ ); }; } else if (CellRenderer) { - // Type-only cell renderer (no link/action) + // 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) => ( - pointerFace?.(row) ?? + pointerFace?.(row) ?? ); } else { // Default renderer with empty value handling @@ -4068,6 +4077,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; @@ -4115,7 +4125,7 @@ export const ObjectGrid: React.FC = ({ if (isPrimaryField && LinkContentRenderer) { cellRenderer = (value: any, row: any) => { - const displayContent = pointerFace?.(row) ?? ; + const displayContent = pointerFace?.(row) ?? ; return ( = ({ ); } else if (CellRenderer) { - cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? ; + cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? ; } return { @@ -4250,6 +4260,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, ' '); @@ -4300,7 +4311,7 @@ export const ObjectGrid: React.FC = ({ ...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }), ...(inferredAlign && { align: inferredAlign }), ...(CellRenderer && { - cell: (value: any, row: any) => pointerFace?.(row) ?? , + cell: (value: any, row: any) => pointerFace?.(row) ?? , }), sortable: fieldDef?.sortable !== false, }; @@ -4408,7 +4419,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, row: any) => pointerFace?.(row) ?? , + cell: (value: any, row: any) => pointerFace?.(row) ?? , sortable: field.sortable !== false, }); }); @@ -6356,7 +6367,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/__tests__/widgetHintedColumns-12613.test.tsx b/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx new file mode 100644 index 0000000000..689fb444c4 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/widgetHintedColumns-12613.test.tsx @@ -0,0 +1,209 @@ +/** + * 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'` 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 no face — the console registers label faces under the two keys + * (`@object-ui/app-shell`'s widget reference cells) — could reach them. + * + * ## 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 + * 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`). + */ + +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, 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; + } + 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 () => ({ data: ROWS, total: ROWS.length })), + getObjectSchema: vi.fn(async () => RULE_SCHEMA), + } as any; +} + +const COLUMN_NAMES = ['label', 'object_name', 'recipient_type', 'recipient_id', 'note']; + +function renderGrid(schemaOverrides: Record, props: Record = {}) { + return render( + + + , + ); +} + +/** The probes drawn, in DOM order. */ +const probes = () => screen.queryAllByTestId('widget-probe').map((el) => el.textContent); + +/** + * 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 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); +} + +describe('objectui#12613 — every ObjectGrid path draws a widget-hinted column with its key\'s face and the row', () => { + it('A: object `columns`', async () => { + renderGrid({ + columns: COLUMN_NAMES.map((field) => ({ field })), + data: { provider: 'value', items: ROWS }, + }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectWidgetFacesWithRow(); + }); + + it('B: string `columns` — the shape the platform list views declare', async () => { + renderGrid({ + columns: COLUMN_NAMES, + data: { provider: 'value', items: ROWS }, + }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectWidgetFacesWithRow(); + }); + + it('C: inline-data projection (`fields`, no `columns`)', async () => { + renderGrid({ + fields: COLUMN_NAMES, + data: { provider: 'value', items: ROWS }, + }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectWidgetFacesWithRow(); + }); + + it('D: object-schema default columns (rows handed down, no projection)', async () => { + renderGrid({}, { data: ROWS }); + await waitFor(() => expect(screen.getByText('Account managers')).toBeInTheDocument()); + await expectWidgetFacesWithRow(); + }); +}); 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,