diff --git a/.changeset/12608-record-pointer-names.md b/.changeset/12608-record-pointer-names.md new file mode 100644 index 0000000000..33d152ef22 --- /dev/null +++ b/.changeset/12608-record-pointer-names.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-grid': minor +'@object-ui/i18n': minor +'@object-ui/console': minor +'@object-ui/fields': patch +--- + +List grids show the record a pointer field names, and the Approvals Inbox says who else is waiting (objectui#12608). + +- **Record names in place of record ids.** A `text` field that declares `referenceVia` holds the id of a record whose object the sibling column names on the same row, as `record_id` does on Approval Requests (`referenceVia: 'object_name'`) or `source_id` on activities. In a list grid such a column now shows the target object's icon and the record's name, linking to the record. The record id moves to the tooltip. The grid reads the records of one page with one request per target object, not one per row. +- **Unavailable records say so.** When the record cannot be read (deleted, or outside what the viewer may read), the cell shows the object's label followed by "record unavailable", with the id in the tooltip, instead of the bare id. +- **The empty Approvals Inbox names who else is waiting.** When nothing is waiting on the viewer but other requests are pending, the empty state adds one muted line: "N requests are waiting on other approvers · Browse all requests". The number comes from one count request and counts only requests the viewer can see; when it cannot be read, the line is not shown. + +New language-pack keys, in every shipped language: `grid.recordUnavailable` and the `approvalsInbox.waitingOnOthers` plural family. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 6ec8abbe14..035ecfa9a5 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -1123,6 +1123,13 @@ export function ApprovalsInboxPage() { const [error, setError] = useState(null); /** "My pending" count, independent of the active tab (badge + bell parity). */ const [myPendingCount, setMyPendingCount] = useState(0); + /** + * objectui#12608 — the pending requests this caller can see in the + * environment, read only while the "pending" tab is empty: the empty state + * then says how many are waiting on other approvers. `null` = not read, or + * the read failed or answered no total; the line is then not drawn. + */ + const [environmentPendingCount, setEnvironmentPendingCount] = useState(null); const [selectedId, setSelectedId] = useState(null); const [selected, setSelected] = useState(null); @@ -1301,6 +1308,18 @@ export function ApprovalsInboxPage() { : []; setMyPendingCount(requests.length); setTotal(null); + // objectui#12608 — the caller's pending list is empty, so ONE count + // query asks how many pending requests the environment holds. Paged + // (`limit`), so the server answers `total`; it counts under the same + // visibility as the "all" tab's own read, so the number never names a + // request the caller cannot open from there, and every request it + // counts waits on someone else. A failed read draws no line. + setEnvironmentPendingCount(null); + if (requests.length === 0) { + approvalsApi.listRequests({ status: 'pending', limit: 1, offset: 0 }) + .then(res => setEnvironmentPendingCount(typeof res.total === 'number' ? res.total : null)) + .catch(() => { /* the line is best-effort: no count, no line */ }); + } } else { const pageParams = { q: serverQuery || undefined, @@ -2013,7 +2032,20 @@ export function ApprovalsInboxPage() { ? tr('emptyPending', "You're all caught up — nothing is waiting on you.") : tr('emptyOther', 'Nothing here yet.')} - {tab === 'pending' && ( + {tab === 'pending' && (environmentPendingCount ?? 0) > 0 ? ( +

+ {tr('waitingOnOthers', '{{count}} requests are waiting on other approvers', { + count: environmentPendingCount, + })} + {' · '} + +

+ ) : tab === 'pending' && ( diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.waitingOnOthers-12608.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.waitingOnOthers-12608.test.tsx new file mode 100644 index 0000000000..f301817389 --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.waitingOnOthers-12608.test.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approvals Inbox — the empty "pending" tab says who else is waiting + * (objectui#12608). + * + * A caller with nothing pending read "You're all caught up" while the + * environment held 53 pending requests, and nothing on the page said they were + * waiting on someone else. When the caller's pending list is empty the page now + * makes ONE count query (`listRequests` paged with `limit`, so the server + * answers `total`) and, for a non-zero total, adds one muted line: + * "N requests are waiting on other approvers · Browse all requests". + * + * Pinned here: the line for a non-zero count, nothing for zero, and nothing + * when the count read fails. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const APP = 'com.objectstack.account'; + +const { approvalsApiStub, ADAPTER, AUTH, I18N } = vi.hoisted(() => { + const approvalsApiStub = { + listRequests: vi.fn(async (_params?: Record): Promise<{ data: unknown[]; total?: number }> => ({ data: [] })), + getRequest: vi.fn(async () => ({ data: undefined })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: undefined, finalized: true })), + reject: vi.fn(async () => ({ data: undefined, finalized: true })), + }; + const ADAPTER = { find: vi.fn(async () => ({ data: [] })) }; + const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; + // Interpolates `{{count}}` into the inline default, as i18next would. + const I18N = { + t: (key: string, options?: Record) => + String(options?.defaultValue ?? key).replace(/\{\{count\}\}/g, String(options?.count ?? '')), + language: 'en', + }; + return { approvalsApiStub, ADAPTER, AUTH, I18N }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); + return { + ...(await importOriginal>()), + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +/** The caller's own pending list is empty; the environment count is `total`. */ +function stubEnvironment(total: number | Error) { + approvalsApiStub.listRequests.mockImplementation(async (params?: Record) => { + const isCountQuery = params?.status === 'pending' && params?.limit != null && params?.approverId == null; + if (!isCountQuery) return { data: [] }; + if (total instanceof Error) throw total; + return { data: [], total }; + }); +} + +function renderInbox() { + return render( + + + } /> + + , + ); +} + +function countQueries() { + return approvalsApiStub.listRequests.mock.calls.filter( + ([params]) => params?.status === 'pending' && params?.limit != null && params?.approverId == null, + ); +} + +beforeEach(() => { + for (const fn of Object.values(approvalsApiStub)) fn.mockReset(); +}); +afterEach(cleanup); + +describe('Approvals Inbox — the empty pending tab names who else is waiting (objectui#12608)', () => { + it('renders the "waiting on other approvers" line for a non-zero environment count', async () => { + stubEnvironment(53); + renderInbox(); + + const line = await screen.findByTestId('approvals-inbox-waiting-on-others'); + expect(line).toHaveTextContent('53 requests are waiting on other approvers · Browse all requests'); + expect(line).toHaveClass('text-muted-foreground'); + // ONE count query, paged so the server answers `total`. + expect(countQueries()).toHaveLength(1); + expect(countQueries()[0][0]).toMatchObject({ status: 'pending', limit: 1 }); + // "Browse all requests" is drawn once, inside the line. + expect(screen.getAllByRole('button', { name: 'Browse all requests' })).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'Browse all requests' })); + await waitFor(() => + expect(approvalsApiStub.listRequests).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 })), + ); + }); + + it('renders nothing extra for a zero environment count', async () => { + stubEnvironment(0); + renderInbox(); + + await waitFor(() => expect(countQueries()).toHaveLength(1)); + // Let the count answer land before asserting the absence. + await screen.findByText("You're all caught up — nothing is waiting on you."); + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByTestId('approvals-inbox-waiting-on-others')).toBeNull(); + expect(screen.getByRole('button', { name: 'Browse all requests' })).toBeInTheDocument(); + }); + + it('renders nothing extra when the count read fails', async () => { + stubEnvironment(new Error('403')); + renderInbox(); + + await waitFor(() => expect(countQueries()).toHaveLength(1)); + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByTestId('approvals-inbox-waiting-on-others')).toBeNull(); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 8d8a77e75a..8608ebcd09 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -3184,10 +3184,13 @@ export function registerFieldRenderer(type: string, renderer: React.FC React.ReactElement | null) | null { + const referenceVia = fieldDef?.referenceVia; + if (typeof referenceVia !== 'string' || referenceVia === '') return null; + return (row: any) => { + const pointer = resolveRecordPointer({ name: fieldName, type: declaredType, referenceVia }, row); + return pointer ? : null; + }; +} + // Default English fallback translations for the grid const GRID_DEFAULT_TRANSLATIONS: Record = { 'grid.actions': 'Actions', @@ -3846,13 +3874,16 @@ export const ObjectGrid: React.FC = ({ // The face the value takes INSIDE the link: never an anchor of its // own (objectui#11817, `linkCellRenderer`). const LinkContentRenderer = linkCellRenderer(inferredType, CellRenderer); + // A `referenceVia` pointer column draws the record it names + // (objectui#12608); `null` per row falls through to the faces below. + const pointerFace = withheld ? null : recordPointerFace(col.field, baseInferredType, objectDefField, dataSource); if ((col.link && col.action) || (isPrimaryField && col.action)) { // Both link and action: link takes priority for navigation, action executes on secondary interaction cellRenderer = (value: any, row: any) => { - const displayContent = LinkContentRenderer + const displayContent = pointerFace?.(row) ?? (LinkContentRenderer ? - : (value != null && value !== '' ? String(value) : ); + : (value != null && value !== '' ? String(value) : )); return ( = ({ } else if (isLinked) { // Link column: clicking navigates to the record detail cellRenderer = (value: any, row: any) => { - const displayContent = LinkContentRenderer + const displayContent = pointerFace?.(row) ?? (LinkContentRenderer ? - : (value != null && value !== '' ? String(value) : ); + : (value != null && value !== '' ? String(value) : )); return ( = ({ }; } else if (CellRenderer) { // Type-only cell renderer (no link/action) - cellRenderer = (value: any) => ( - + cellRenderer = (value: any, row: any) => ( + pointerFace?.(row) ?? ); } else { // Default renderer with empty value handling @@ -4079,10 +4110,12 @@ export const ObjectGrid: React.FC = ({ // The face the value takes inside the link (objectui#11817). const LinkContentRenderer = linkCellRenderer(rendererType, CellRenderer); + // A `referenceVia` pointer column (objectui#12608), as path A. + const pointerFace = withheld ? null : recordPointerFace(fieldName, declaredType, fieldDef, dataSource); if (isPrimaryField && LinkContentRenderer) { cellRenderer = (value: any, row: any) => { - const displayContent = ; + const displayContent = pointerFace?.(row) ?? ; return ( = ({ ); } else if (CellRenderer) { - cellRenderer = (value: any) => ; + cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? ; } return { @@ -4253,6 +4286,8 @@ export const ObjectGrid: React.FC = ({ const numericTypes = ['number', 'currency', 'percent']; const inferredAlign = rendererType && numericTypes.includes(rendererType) ? 'right' as const : undefined; + // A `referenceVia` pointer column (objectui#12608), as path A. + const pointerFace = withheld ? null : recordPointerFace(fieldName, declaredType, fieldDef, dataSource); return { header, @@ -4264,7 +4299,9 @@ 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) => pointerFace?.(row) ?? , + }), sortable: fieldDef?.sortable !== false, }; }); @@ -4361,6 +4398,8 @@ export const ObjectGrid: React.FC = ({ // The face is handed the header's label, as paths A-C hand it // (objectui#11689). const fieldForCell: any = field.label ? { ...translatedField, label: header } : translatedField; + // A `referenceVia` pointer column (objectui#12608), as path A. + const pointerFace = recordPointerFace(fieldName, fieldType, field, dataSource); generatedColumns.push({ header, accessorKey: fieldName, @@ -4369,13 +4408,13 @@ 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) => pointerFace?.(row) ?? , sortable: field.sortable !== false, }); }); return generatedColumns; - }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms, uiLanguage]); + }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms, uiLanguage, dataSource]); // Formats this grid can actually deliver (objectui#2942): the server stream // handles csv/xlsx/json, the client fallback only csv/json. Declared-but-dead diff --git a/packages/plugin-grid/src/RecordPointerCell.tsx b/packages/plugin-grid/src/RecordPointerCell.tsx new file mode 100644 index 0000000000..88f4f12d94 --- /dev/null +++ b/packages/plugin-grid/src/RecordPointerCell.tsx @@ -0,0 +1,315 @@ +/** + * 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 list grid's face for a `referenceVia` pointer pair (objectui#12608). + * + * `@objectstack/spec`'s `FieldSchema.referenceVia` declares a `text` field as + * the id half of a two-column pointer pair: its value is a record id of the + * object the sibling column names on the same row + * (`sys_approval_request.record_id` with `referenceVia: 'object_name'`). The + * rule that reads the pair is `resolveRecordPointer` in `@object-ui/fields`, + * and the grid asks it per row, where the row is in hand; this module only + * draws the pair it answers. There is no second resolver here. + * + * ## What the cell draws + * + * - `readable` — the target object's icon and the record's name, linking to + * the record through the host's record-URL builder (`recordHref` / + * `openRecord` on `RelatedRecordActionsContext`, the one lookup values link + * through). The name is `getRecordDisplayName`, the ADR-0079 ladder the + * record page's own title reads, over the row with the fields the viewer may + * not read removed. The raw record id is the tooltip. + * - `unreadable` — the target object's label and "record unavailable", with the + * raw id as the tooltip; never the id alone. One rendering for every cause + * (deleted, outside the viewer's row set, definition unavailable, read + * failed): the platform answers a record outside the viewer's row set the + * same way it answers a deleted one, so a cell that told them apart would be + * an existence oracle (the rule `RecordPreviewCard` states for the details + * grid's face). + * - `loading` — a placeholder bar, until both reads answer. + * + * ## Cost: one read per target object per tick, not one per row + * + * The details grid's face (`RecordPreviewCard`) reads its target with one + * `findOne`; a list draws a page of such cells, so the list face batches. Every + * cell that mounts in the same tick queues its id under its target object, and + * one `find` per object then asks for all of them with an `id` `$in` filter. + * The target object's definition is read once per object through + * `getObjectSchema`. Both answers are cached per data source, so a re-render + * or a second page that names a record already read costs nothing. + */ + +import React, { useEffect, useSyncExternalStore } from 'react'; +import { Skeleton, resolveIcon } from '@object-ui/components'; +import { extractRecords, getRecordDisplayName, withoutDeniedFields } from '@object-ui/core'; +import type { RecordPointer } from '@object-ui/fields'; +import { createSafeTranslation } from '@object-ui/i18n'; +import { usePermissions } from '@object-ui/permissions'; +import { useObjectLabel, useRelatedRecordActions } from '@object-ui/react'; + +/** The two reads the cell makes. Structural, so the console's adapter fits. */ +export interface RecordPointerSource { + find?: (objectName: string, params?: Record) => Promise; + getObjectSchema?: (objectName: string) => Promise; +} + +type RecordAnswer = + | { state: 'pending' } + | { state: 'readable'; record: Record } + | { state: 'unreadable' }; + +type DefAnswer = + | { state: 'pending' } + | { state: 'ok'; def: ObjectDefLike } + | { state: 'unavailable' }; + +interface ObjectDefLike { + name?: string; + label?: string; + icon?: string; + [key: string]: unknown; +} + +/** Everything one data source has answered, and what it still owes. */ +interface PointerStore { + records: Map; + defs: Map; + /** Ids queued for the next flush, per target object. */ + queued: Map>; + flushScheduled: boolean; + listeners: Set<() => void>; + /** Bumped on every settled answer; what `useSyncExternalStore` compares. */ + version: number; +} + +/** + * The most ids one `find` asks for. A page is at most a few hundred rows, so + * this only splits an unusually large page; the request carries `$top` equal + * to its id count, so the server's default window never truncates it. + */ +const MAX_IDS_PER_READ = 200; + +const stores = new WeakMap(); + +function storeFor(source: object): PointerStore { + let store = stores.get(source); + if (!store) { + store = { records: new Map(), defs: new Map(), queued: new Map(), flushScheduled: false, listeners: new Set(), version: 0 }; + stores.set(source, store); + } + return store; +} + +function recordKey(objectName: string, recordId: string): string { + return `${objectName}::${recordId}`; +} + +function settle(store: PointerStore): void { + store.version += 1; + for (const listener of store.listeners) listener(); +} + +function readDef(source: RecordPointerSource, store: PointerStore, objectName: string): void { + if (store.defs.has(objectName)) return; + if (typeof source.getObjectSchema !== 'function') { + store.defs.set(objectName, { state: 'unavailable' }); + return; + } + store.defs.set(objectName, { state: 'pending' }); + // The executor runs synchronously, so a source that THROWS lands in the same + // branch as one that rejects. + new Promise((resolve) => resolve(source.getObjectSchema!(objectName))).then( + (def) => { + store.defs.set( + objectName, + def && typeof def === 'object' ? { state: 'ok', def: def as ObjectDefLike } : { state: 'unavailable' }, + ); + settle(store); + }, + () => { + store.defs.set(objectName, { state: 'unavailable' }); + settle(store); + }, + ); +} + +function flush(source: RecordPointerSource, store: PointerStore): void { + store.flushScheduled = false; + const queued = Array.from(store.queued.entries()); + store.queued.clear(); + for (const [objectName, idSet] of queued) { + const ids = Array.from(idSet); + for (let start = 0; start < ids.length; start += MAX_IDS_PER_READ) { + const chunk = ids.slice(start, start + MAX_IDS_PER_READ); + const markAll = (answer: (id: string) => RecordAnswer) => { + for (const id of chunk) store.records.set(recordKey(objectName, id), answer(id)); + settle(store); + }; + if (typeof source.find !== 'function') { + markAll(() => ({ state: 'unreadable' })); + continue; + } + new Promise((resolve) => + resolve(source.find!(objectName, { $filter: { id: { $in: chunk } }, $top: chunk.length })), + ).then( + (result) => { + const byId = new Map>(); + for (const row of extractRecords(result) as Array>) { + const id = row?.id ?? row?._id; + if (id != null) byId.set(String(id), row); + } + // An id the read did not return is unreadable: deleted and not + // visible are one answer (see the module header). + markAll((id) => { + const record = byId.get(id); + return record ? { state: 'readable', record } : { state: 'unreadable' }; + }); + }, + () => markAll(() => ({ state: 'unreadable' })), + ); + } + } +} + +/** Queue one target for the next batched read, unless it is already known. */ +function requestRecord(source: RecordPointerSource, objectName: string, recordId: string): void { + const store = storeFor(source); + readDef(source, store, objectName); + const key = recordKey(objectName, recordId); + if (store.records.has(key)) return; + store.records.set(key, { state: 'pending' }); + let ids = store.queued.get(objectName); + if (!ids) { + ids = new Set(); + store.queued.set(objectName, ids); + } + ids.add(recordId); + if (!store.flushScheduled) { + store.flushScheduled = true; + // A macrotask, not a microtask: every cell of the page commits its effect + // before the flush runs, so the page shares one read per object. + setTimeout(() => flush(source, store), 0); + } +} + +const NO_SOURCE_STORE: PointerStore = { + records: new Map(), + defs: new Map(), + queued: new Map(), + flushScheduled: false, + listeners: new Set(), + version: 0, +}; + +function useStoreVersion(store: PointerStore): number { + return useSyncExternalStore( + (listener) => { + store.listeners.add(listener); + return () => { + store.listeners.delete(listener); + }; + }, + () => store.version, + () => store.version, + ); +} + +const useRecordPointerTranslation = createSafeTranslation( + { 'grid.recordUnavailable': '{{object}} · record unavailable' }, + 'grid.recordUnavailable', +); + +export interface RecordPointerCellProps { + /** The pair `resolveRecordPointer` answered for this row. */ + pointer: RecordPointer; + /** Where the target is read from: the grid's own data source. */ + dataSource?: RecordPointerSource | null; +} + +/** Draws the record a pointer pair names: its icon and name, or "unavailable". */ +export function RecordPointerCell({ pointer, dataSource }: RecordPointerCellProps): React.ReactElement { + const { objectName, recordId } = pointer; + const store = dataSource ? storeFor(dataSource) : NO_SOURCE_STORE; + useStoreVersion(store); + + useEffect(() => { + if (dataSource) requestRecord(dataSource, objectName, recordId); + }, [dataSource, objectName, recordId]); + + const perms = usePermissions(); + const { objectLabel } = useObjectLabel(); + const { t } = useRecordPointerTranslation(); + const recordLinks = useRelatedRecordActions(); + + const defAnswer: DefAnswer = dataSource ? store.defs.get(objectName) ?? { state: 'pending' } : { state: 'unavailable' }; + const recordAnswer: RecordAnswer = dataSource + ? store.records.get(recordKey(objectName, recordId)) ?? { state: 'pending' } + : { state: 'unreadable' }; + + if (defAnswer.state === 'pending' || recordAnswer.state === 'pending') { + return ( + + + + ); + } + + const def = defAnswer.state === 'ok' ? defAnswer.def : null; + const typeLabel = objectLabel({ name: objectName, label: def?.label || objectName }); + const Icon = def?.icon ? resolveIcon(def.icon) : null; + // The annotation the other `resolveIcon` seam call sites carry: it returns a + // STABLE, cached component per icon name; it does not create one during + // render. The rule cannot see that through a call. + // eslint-disable-next-line react-hooks/static-components + const icon = Icon ?