diff --git a/.changeset/12032-approval-requests-data-source.md b/.changeset/12032-approval-requests-data-source.md index d25dc47a5b..d5ebb3e26e 100644 --- a/.changeset/12032-approval-requests-data-source.md +++ b/.changeset/12032-approval-requests-data-source.md @@ -9,3 +9,5 @@ Approval requests can now be read by the standard list and record views, and `Ap - **A console data source for approval requests.** `apps/console/src/services/approvalRequestsDataSource.ts` routes reads of `sys_approval_request` to the approvals routes through `ApiDataSource`, and everything else to the console's own adapter. Each source serves one server-side scope: awaiting me, submitted by me, or all. Rows come back as the approvals service serves them, so `viewer` and (on a single read) `decision_progress` are fields a view can bind. On a list read, `$top`, `$skip` and `$search` become the route's `limit`, `offset` and `q`, and `$select` is dropped. Every other parameter, such as a filter, a sort or an expansion, is refused with an `UNSUPPORTED_QUERY_PARAM` error, so the list shows its error panel and never shows unfiltered rows. Nothing mounts the source yet; the approvals list and detail pages that will use it come later. Nothing is added to any package entry: no export, prop, type member or language-pack key. + +**Superseded in part (objectui#12045):** the console now mounts this source on the `sys_approval_request` record route, so the sentence above saying nothing mounts it no longer holds when both changes release together. diff --git a/.changeset/12045-approval-decision-panel.md b/.changeset/12045-approval-decision-panel.md new file mode 100644 index 0000000000..4863643e94 --- /dev/null +++ b/.changeset/12045-approval-decision-panel.md @@ -0,0 +1,15 @@ +--- +'@object-ui/fields': minor +'@object-ui/app-shell': patch +'@object-ui/plugin-detail': patch +'@object-ui/console': patch +--- + +A pointer field draws the record it points at, the console reads an approval request's record page through the approvals routes, and the approval decision panel is built for the request page (objectui#12045, B1 of objectui#2763). + +- **The approval decision panel (`@object-ui/app-shell`, module-internal).** One panel that draws the request's decision progress (`DecisionProgressIndicator`) above the request's own declared decision actions (`DeclaredActionsBar` at `record_section`, decided through `ActionParamDialog`). It reads the bound request and takes no authorable props; outside a `sys_approval_request` record page it renders nothing. After a decision it invalidates the request record and its timeline instead of remounting. It is not registered as a component type yet: the type `record:approval_decision` is proposed to the spec on objectstack-ai/objectstack#22472, and its registration lands with the spec row. +- **`referenceVia` pointer pairs (`@object-ui/fields`).** `resolveRecordPointer(field, row)` returns the record a `text` field declaring `referenceVia` points at on one row (`{ objectName, recordId }`, the new `RecordPointer` type), and `RECORD_POINTER_CARD_TYPE` is the registry key a resolved pair is drawn with. The package's default under that key draws the record id as text, as the field drew before. +- **The record details grid draws the pair (`@object-ui/plugin-detail`).** A field declaring `referenceVia` is resolved from the row and drawn with the pointer face; with `@object-ui/app-shell` loaded that face is the record preview card, so `sys_approval_request.record_id` and the other pointer fields show the record they point at. A pair whose row leaves either half blank draws the stored text. +- **The console's approval request page.** The console's record route for `sys_approval_request` mounts the record page over the routed approvals source, so the request carries the `viewer` block its declared actions gate on and the decision tally. The source now reads one request's `sys_approval_action` timeline from `GET /approvals/requests/:id/actions`, applying the read's `$orderby`, `$top` and `$skip` itself, dropping `$select` and `$expand`, and refusing any other parameter with `UNSUPPORTED_QUERY_PARAM`. On a request's own page the record view no longer asks for approvals opened on the request itself. + +No language-pack key is added, and `CellRendererProps` is unchanged. diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx index 2a74707962..4dd7d7bf62 100644 --- a/apps/console/src/AppContent.tsx +++ b/apps/console/src/AppContent.tsx @@ -9,12 +9,14 @@ */ import { lazy, Suspense, useMemo } from 'react'; -import { Route, useParams, useLocation, Navigate } from 'react-router-dom'; -import { DefaultAppContent, LoadingScreen } from '@object-ui/app-shell'; +import { Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom'; +import { DefaultAppContent, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell'; import { MePermissionsProvider } from '@object-ui/permissions'; import { createAuthenticatedFetch } from '@object-ui/auth'; +import type { DataSource } from '@object-ui/types'; import { LocalizationFetchProvider } from './LocalizationFetchProvider'; import { WorkspaceTimezonePrompt } from './pages/settings/WorkspaceTimezonePrompt'; +import { APPROVAL_REQUEST_OBJECT, createApprovalRequestsDataSource } from './services/approvalRequestsDataSource'; const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage }))); const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({ default: m.ProfilePage }))); @@ -273,6 +275,57 @@ export const systemRoutes = ( ); +/** + * Every `sys_approval_request` record page reads through the approvals routes + * (objectui#12045, B1 of objectui#2763). + * + * The generic record route (`:objectName/record/:recordId`, declared by + * `DefaultAppContent` in `@object-ui/app-shell`) hands `RecordDetailView` the + * console's adapter, the data API. For an approval request that read is the + * wrong door: the data API attaches neither the `viewer` block the request's + * declared decision actions gate on nor the `decision_progress` tally, and it + * refuses the request's timeline (`sys_approval_action`) to an ordinary + * approver. The approvals routes answer all three for anyone who is a party to + * the request. So this route, more specific than the generic one and therefore + * the one React Router picks for this object, mounts the same view over the + * routed approvals source (`createApprovalRequestsDataSource`): the request is + * read from `GET /approvals/requests/:id`, its timeline from + * `GET /approvals/requests/:id/actions`, and every other read the page makes + * goes to the adapter unchanged. + * + * The page lists no requests of its own; the source's `all` scope only means + * one would be read within the approvals service's own visibility. Records of + * this object are engine-owned, so the page offers no edit; the handler opens + * the record's edit route, which is where the generic route's page-mode edit + * goes. + */ +function ApprovalRequestRecordRoute() { + const adapter = useAdapter(); + const { objects } = useMetadata(); + const navigate = useNavigate(); + const dataSource = useMemo( + () => + adapter + ? createApprovalRequestsDataSource({ host: adapter, scope: { kind: 'all' } }) + : null, + [adapter], + ); + return ( + navigate('edit')} + /> + ); +} + +// Exported for `services/approvalRequestsDataSource.recordRoute.test.tsx`, +// which mounts this exact fragment beside the generic record route. +export const approvalRequestRoutes = ( + } /> +); + export function AppContent() { const serverUrl = import.meta.env.VITE_SERVER_URL || ''; const endpoint = `${serverUrl}/api/v1/auth/me/permissions`; @@ -308,7 +361,10 @@ export function AppContent() { errorFallback={(err, retry) => } > - + {systemRoutes}{approvalRequestRoutes}} + extraRoutesNoApp={systemRoutes} + /> {/* objectui#11758 — asks an administrator once to set a still-default workspace timezone. Here, inside `MePermissionsProvider`, because diff --git a/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx b/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx new file mode 100644 index 0000000000..dc7e07e533 --- /dev/null +++ b/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx @@ -0,0 +1,350 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#12045 (B1 of objectui#2763) — an approval request's record page, + * on the REAL `RecordDetailView`, read through the routed approvals source, + * with a slotted page that places `record:approval_decision`. + * + * The page fixture is the shape objectstack-ai/objectstack#22473 proposes for + * plugin-approvals: a `kind: 'slotted'` record page for `sys_approval_request` + * whose `actions` slot holds the decision panel and whose discussion is empty. + * + * The panel is module-internal this round (claim amendment 3 on objectui#12045, + * `6081385696`): nothing in source registers it, because a registered type + * needs a zod arm derived from the v18 spec row (objectstack-ai/objectstack#22472). + * So this file registers the shipped renderer itself, under the proposed name, + * before anything renders, as the page-mount step's registration will. + * Everything else on it is synthesized from the object, as on a served page. + * Only the HTTP transport and the console adapter are doubled, so what the page + * reads, and from where, is what the shipped code sends: + * + * - the request from `GET /approvals/requests/:id` (with `viewer` and + * `decision_progress`, which the data API does not attach); + * - its timeline from `GET /approvals/requests/:id/actions`; + * - the target record of the `record_id` pointer pair from the adapter, drawn + * as A2's card; + * - the decision as the declared action's POST, through `ActionParamDialog`. + * + * The last describe block mounts the console's own route for this object + * beside the generic record route, as `AppContent` hands both to the shell, and + * pins that the request's record page is the routed one. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +import { AdapterCtx, MetadataCtx } from '@object-ui/react'; +import { I18nProvider, type I18nConfig } from '@object-ui/i18n'; +import { RecordDetailView } from '@object-ui/app-shell'; +import type { DataSource } from '@object-ui/types'; +import { + APPROVAL_ACTION_OBJECT, + APPROVAL_REQUEST_OBJECT, + createApprovalRequestsDataSource, +} from './approvalRequestsDataSource'; +import { approvalRequestRoutes } from '../AppContent'; +import { PageSchema } from '@objectstack/spec/ui'; +import { ComponentRegistry } from '@object-ui/core'; + +// The shipped renderer, from its module (the package entry does not export it), +// registered here under the proposed type name; see the header. +const { ApprovalDecisionRenderer } = await vi.importActual<{ + ApprovalDecisionRenderer: Parameters[1]; +}>('../../../../packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel'); +ComponentRegistry.register('approval_decision', ApprovalDecisionRenderer, { namespace: 'record', skipFallback: true }); + +const REQ = 'req_12045'; +const VIEWER = { can_act: true, is_submitter: false, can_override: false }; +const ROW = { + id: REQ, + process_name: 'invoice_approval', + object_name: 'invoice', + record_id: 'inv_1', + status: 'pending', + pending_approvers: ['u1', 'u2', 'u3'], + viewer: VIEWER, + decision_progress: { behavior: 'quorum', got: 1, need: 2 }, +}; +const TIMELINE = [ + { id: 'act_1', request_id: REQ, action: 'submit', comment: 'Please review the laptop order', created_at: '2026-10-01T09:00:00Z' }, + { id: 'act_2', request_id: REQ, action: 'approve', comment: 'Budget checked', created_at: '2026-10-02T09:00:00Z' }, +]; + +/** The request's declared decisions, in the wire shape (CEL envelopes). */ +const DECIDE = { dialect: 'cel', source: 'record.viewer.can_act || record.viewer.can_override' }; +const DECISION_PARAMS = [ + { name: 'comment', label: 'Comment', type: 'textarea', required: false }, + { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false }, +]; +const REQUEST_DEF = { + name: APPROVAL_REQUEST_OBJECT, + label: 'Approval Request', + nameField: 'process_name', + fields: { + id: { label: 'Id', type: 'text' }, + process_name: { label: 'Process', type: 'text' }, + object_name: { label: 'Object', type: 'text' }, + record_id: { label: 'Record', type: 'text', referenceVia: 'object_name' }, + status: { label: 'Status', type: 'text' }, + submitter_id: { label: 'Submitter', type: 'lookup', reference: 'sys_user' }, + }, + actions: [ + { + name: 'approval_approve', label: 'Approve', type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/approve', params: DECISION_PARAMS, + visible: DECIDE, locations: ['record_section', 'list_item'], refreshAfter: true, + }, + { + name: 'approval_reject', label: 'Reject', type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/reject', params: DECISION_PARAMS, + visible: DECIDE, locations: ['record_section', 'list_item'], refreshAfter: true, + }, + ], +}; +const ACTION_DEF = { + name: APPROVAL_ACTION_OBJECT, + label: 'Approval Action', + pluralLabel: 'Approval Actions', + fields: { + id: { label: 'Action ID', type: 'text' }, + request_id: { label: 'Request', type: 'lookup', reference: APPROVAL_REQUEST_OBJECT }, + action: { label: 'Action', type: 'text' }, + comment: { label: 'Comment', type: 'text' }, + created_at: { label: 'Created', type: 'datetime' }, + }, +}; +const INVOICE_DEF = { + name: 'invoice', + label: 'Invoice', + nameField: 'subject', + highlightFields: ['amount'], + fields: { subject: { label: 'Subject', type: 'text' }, amount: { label: 'Amount', type: 'number' } }, +}; +const DEFS: Record = { + [APPROVAL_REQUEST_OBJECT]: REQUEST_DEF, + [APPROVAL_ACTION_OBJECT]: ACTION_DEF, + invoice: INVOICE_DEF, +}; + +/** + * The slotted page (objectstack-ai/objectstack#22473's shape). Its `highlights` + * slot is empty and its `details` slot authors the Details tab's body. Both are + * needed for the pointer card: the details grid hides every field a mounted + * highlights strip shows (the synthesized Details tab is also handed the + * object's `highlightFields` as `hideFields`), and the real object declares + * `record_id` among its `highlightFields`. Left in the strip, the pair is drawn + * by `record:highlights`, which is not a pointer call site. + */ +const REQUEST_PAGE = { + name: 'sys_approval_request_detail', + label: 'Approval Request', + type: 'record', + object: APPROVAL_REQUEST_OBJECT, + kind: 'slotted', + slots: { + actions: [{ type: 'record:approval_decision' }], + highlights: [], + details: [ + { + type: 'record:details', + properties: { sections: [{ label: 'Request', fields: ['process_name', 'object_name', 'record_id', 'status'] }] }, + }, + ], + discussion: [], + }, +}; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +/** Every request the page sent over HTTP: method, path and query, body. */ +let wire: Array<{ method: string; path: string; body?: unknown }> = []; + +/** The approvals routes, answered as objectstack's REST server answers them. */ +const transport = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = new URL(raw, 'http://test.local'); + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + wire.push({ method, path: url.pathname + url.search, body }); + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}`) return json(200, ROW); + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}/actions`) return json(200, { data: TIMELINE }); + if (method === 'POST' && url.pathname === `/api/v1/approvals/requests/${REQ}/approve`) { + return json(200, { request: ROW, finalized: false }); + } + return json(200, { success: true, data: [] }); +}); +vi.stubGlobal('fetch', transport); + +/** The console adapter, doubled: side reads and the pointer's target land here. */ +const host = { + find: vi.fn(async (_resource: string, _params?: unknown) => ({ data: [], total: 0 })), + findOne: vi.fn(async (resource: string, id: string) => + resource === 'invoice' && id === 'inv_1' ? { id: 'inv_1', subject: 'Laptop refresh', amount: 4200 } : null), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => DEFS[name] ?? null), +}; + +const metadata = { + apps: [], objects: [REQUEST_DEF, ACTION_DEF, INVOICE_DEF], dashboards: [], reports: [], + pages: [REQUEST_PAGE], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem: async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null), + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +function openRequestPage() { + const ds = createApprovalRequestsDataSource({ + host: host as unknown as DataSource, + scope: { kind: 'all' }, + fetch: transport, + baseUrl: 'http://test.local/api/v1', + }); + return render( + + + + + {}} + objectNameOverride={APPROVAL_REQUEST_OBJECT} + recordIdOverride={REQ} + /> + + + + , + ); +} + +const requestGets = () => wire.filter((w) => w.method === 'GET' && w.path === `/api/v1/approvals/requests/${REQ}`); + +afterEach(() => { + cleanup(); + wire = []; + host.find.mockClear(); + host.findOne.mockClear(); +}); + +describe("an approval request's record page with record:approval_decision (objectui#12045)", () => { + it('the page fixture parses as a spec page', () => { + const parsed = PageSchema.safeParse(REQUEST_PAGE); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it("the decision panel draws the request's tally and its declared decisions, gated by the served viewer block", async () => { + openRequestPage(); + const panel = await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 }); + const bar = await within(panel).findByRole('progressbar', undefined, { timeout: 8000 }); + expect(bar.getAttribute('aria-valuenow')).toBe('1'); + expect(bar.getAttribute('aria-valuemax')).toBe('2'); + expect(await within(panel).findByTestId('declared-action-approval_approve', undefined, { timeout: 8000 })).toBeTruthy(); + expect(within(panel).getByTestId('declared-action-approval_reject')).toBeTruthy(); + // The request came from the approvals get route, the one door that serves `viewer`. + expect(requestGets().length).toBeGreaterThan(0); + }); + + it('Approve collects a comment and several files in ActionParamDialog, POSTs the request route, then re-reads the request in place', async () => { + openRequestPage(); + const approve = await screen.findByTestId('declared-action-approval_approve', undefined, { timeout: 8000 }); + const before = requestGets().length; + fireEvent.click(approve); + + const dialog = await screen.findByRole('dialog', undefined, { timeout: 8000 }); + // The param widgets are lazy chunks; wait for both to replace their skeletons. + const comment = await within(dialog).findByRole('textbox', undefined, { timeout: 8000 }); + await waitFor(() => expect(dialog.querySelector('input[type="file"]')).not.toBeNull(), { timeout: 8000 }); + const files = dialog.querySelector('input[type="file"]') as HTMLInputElement; + expect(files.multiple, 'the attachments param is a multi-file upload control').toBe(true); + fireEvent.change(comment, { target: { value: 'Looks right' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /Confirm/ })); + + await waitFor( + () => expect(wire.some((w) => w.method === 'POST' && w.path === `/api/v1/approvals/requests/${REQ}/approve`)).toBe(true), + { timeout: 8000 }, + ); + const post = wire.find((w) => w.method === 'POST' && w.path === `/api/v1/approvals/requests/${REQ}/approve`); + expect(post?.body).toMatchObject({ comment: 'Looks right' }); + await waitFor(() => expect(requestGets().length).toBeGreaterThan(before), { timeout: 8000 }); + }); + + it("record_id, a referenceVia pair, draws the target invoice as A2's card", async () => { + const { container } = openRequestPage(); + await waitFor( + () => expect(container.querySelector('[data-record-preview="readable"]')).not.toBeNull(), + { timeout: 8000 }, + ); + expect(host.findOne).toHaveBeenCalledWith('invoice', 'inv_1'); + expect(container.querySelector('[data-record-preview="readable"]')?.textContent).toContain('Laptop refresh'); + }); + + it('the timeline reads the actions route, and no approvals are read ON the request itself', async () => { + openRequestPage(); + const related = await screen.findByRole('tab', { name: /Related/ }, { timeout: 8000 }); + fireEvent.mouseDown(related); + fireEvent.click(related); + expect(await screen.findByText('Budget checked', undefined, { timeout: 8000 })).toBeTruthy(); + expect(wire.some((w) => w.path === `/api/v1/approvals/requests/${REQ}/actions`)).toBe(true); + expect(host.find.mock.calls.some(([resource]) => resource === APPROVAL_ACTION_OBJECT)).toBe(false); + // `useRecordApprovals` is skipped on the request's own page. + expect(wire.some((w) => w.path.startsWith(`/api/v1/approvals/requests?object=${APPROVAL_REQUEST_OBJECT}`))).toBe(false); + }); +}); + +describe("the console's route for this object (objectui#12045)", () => { + it('wins over the generic record route and reads the request through the approvals get', async () => { + render( + + + + + + } /> + {approvalRequestRoutes} + + + + + , + ); + expect(await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 })).toBeTruthy(); + expect(screen.queryByTestId('generic-record-route')).toBeNull(); + expect(requestGets().length).toBeGreaterThan(0); + expect(host.findOne.mock.calls.some(([resource]) => resource === APPROVAL_REQUEST_OBJECT)).toBe(false); + }); + + it('control: another object still takes the generic record route', async () => { + render( + + + } /> + {approvalRequestRoutes} + + , + ); + expect(screen.getByTestId('generic-record-route')).toBeTruthy(); + }); +}); diff --git a/apps/console/src/services/approvalRequestsDataSource.timeline.test.ts b/apps/console/src/services/approvalRequestsDataSource.timeline.test.ts new file mode 100644 index 0000000000..e53650d47b --- /dev/null +++ b/apps/console/src/services/approvalRequestsDataSource.timeline.test.ts @@ -0,0 +1,139 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#12045 — one request's timeline (`sys_approval_action`) reads + * through `GET /approvals/requests/:id/actions`, not the data API, which is + * closed to ordinary approver positions. + * + * The transport is a fake approvals server that answers the actions route as + * objectstack's service does: the whole timeline, oldest first, `{ data }`. + * The source applies the read's own ordering and window; the pins hold the + * route it reads, the rows that come back, and what it refuses before any + * request goes out. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DataSource } from '@object-ui/types'; +import { APPROVAL_ACTION_OBJECT, createApprovalRequestsDataSource } from './approvalRequestsDataSource'; + +const BASE = 'http://test.local/api/v1'; + +/** Three actions of one request, in the route's own order (created_at asc). */ +const TIMELINE = [ + { id: 'act_1', request_id: 'req_1', action: 'submit', created_at: '2026-10-01T09:00:00Z', actor_name: 'Ada' }, + { id: 'act_2', request_id: 'req_1', action: 'approve', created_at: '2026-10-02T09:00:00Z', actor_name: 'Grace' }, + { id: 'act_3', request_id: 'req_1', action: 'comment', created_at: '2026-10-03T09:00:00Z', actor_name: 'Ada' }, +]; + +let wire: string[] = []; + +const transport = vi.fn(async (input: RequestInfo | URL): Promise => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + wire.push(url.pathname + url.search); + const actions = /^\/api\/v1\/approvals\/requests\/([^/]+)\/actions$/.exec(url.pathname); + if (actions) { + const id = decodeURIComponent(actions[1]); + return new Response(JSON.stringify({ data: TIMELINE.filter((row) => row.request_id === id) }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('{}', { status: 404 }); +}); + +const host = { + find: vi.fn(async () => ({ data: [{ id: 'from_host' }], total: 1 })), + findOne: vi.fn(async () => null), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({})), +}; + +function source(): DataSource { + return createApprovalRequestsDataSource({ + host: host as unknown as DataSource, + scope: { kind: 'all' }, + fetch: transport, + baseUrl: BASE, + }); +} + +const ids = (result: { data: unknown[] }) => result.data.map((row) => (row as { id: string }).id); + +beforeEach(() => { + wire = []; + host.find.mockClear(); + transport.mockClear(); +}); + +describe("one request's timeline reads the actions route (objectui#12045)", () => { + it('a read scoped to one request goes to that request\'s actions route, and never to the host', async () => { + const result = await source().find(APPROVAL_ACTION_OBJECT, { $filter: { request_id: 'req_1' } }); + expect(wire).toEqual(['/api/v1/approvals/requests/req_1/actions']); + expect(host.find).not.toHaveBeenCalled(); + expect(ids(result)).toEqual(['act_1', 'act_2', 'act_3']); + expect(result.total).toBe(3); + }); + + it('the request id is path-encoded', async () => { + await source().find(APPROVAL_ACTION_OBJECT, { $filter: { request_id: 'req/1 x' } }); + expect(wire).toEqual(['/api/v1/approvals/requests/req%2F1%20x/actions']); + }); + + it('applies $orderby, then windows with $top / $skip, and reports the whole timeline as total', async () => { + const result = await source().find(APPROVAL_ACTION_OBJECT, { + $filter: { request_id: 'req_1' }, + $orderby: [{ field: 'created_at', order: 'desc' }], + $top: 2, + $skip: 0, + }); + expect(ids(result), 'the newest two, as a related list sorted newest-first asks').toEqual(['act_3', 'act_2']); + expect(result.total).toBe(3); + + const second = await source().find(APPROVAL_ACTION_OBJECT, { + $filter: { request_id: 'req_1' }, + $orderby: [{ field: 'created_at', order: 'desc' }], + $top: 2, + $skip: 2, + }); + expect(ids(second)).toEqual(['act_1']); + // The route has no paging: each read asked it for the whole timeline once. + expect(wire).toEqual([ + '/api/v1/approvals/requests/req_1/actions', + '/api/v1/approvals/requests/req_1/actions', + ]); + }); + + it('drops $select and $expand: they shape columns, not which rows come back', async () => { + const result = await source().find(APPROVAL_ACTION_OBJECT, { + $filter: { request_id: 'req_1' }, + $select: ['action'], + $expand: ['actor_id'], + }); + expect(wire).toEqual(['/api/v1/approvals/requests/req_1/actions']); + expect(ids(result)).toEqual(['act_1', 'act_2', 'act_3']); + }); + + it.each([ + ['a search', { $search: 'approve' }], + ['an $orderby in the string form', { $orderby: 'created_at desc' }], + ['a negative window', { $top: -1 }], + ])('refuses %s with UNSUPPORTED_QUERY_PARAM before any request goes out', async (_case, extra) => { + await expect( + source().find(APPROVAL_ACTION_OBJECT, { $filter: { request_id: 'req_1' }, ...extra } as never), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_QUERY_PARAM' }); + expect(wire).toEqual([]); + expect(host.find).not.toHaveBeenCalled(); + }); + + it.each([ + ['no filter', undefined], + ['a filter on another column', { $filter: { actor_id: 'u1' } }], + ['one request AND another condition', { $filter: { request_id: 'req_1', action: 'approve' } }], + ['a filter in the array form', { $filter: [['request_id', '=', 'req_1']] }], + ])('control: a read with %s is not one request\'s timeline and goes to the host unchanged', async (_case, params) => { + const result = await source().find(APPROVAL_ACTION_OBJECT, params as never); + expect(wire).toEqual([]); + expect(host.find).toHaveBeenCalledWith(APPROVAL_ACTION_OBJECT, params); + expect(ids(result)).toEqual(['from_host']); + }); +}); diff --git a/apps/console/src/services/approvalRequestsDataSource.ts b/apps/console/src/services/approvalRequestsDataSource.ts index 2397dc6eda..a6cf35323a 100644 --- a/apps/console/src/services/approvalRequestsDataSource.ts +++ b/apps/console/src/services/approvalRequestsDataSource.ts @@ -61,6 +61,31 @@ * through the data API: outside the scope and without `viewer`. Called for * approval requests, they reject with the same `UNSUPPORTED_QUERY_PARAM` * refusal. For every other resource they are the host's. + * + * ## One request's timeline reads through the actions route (objectui#12045) + * + * The request's record page lists its `sys_approval_action` rows as a related + * list. Through the data API that read is refused to an ordinary approver: the + * object is deliberately closed to ordinary positions, and the approvals + * service answers who may see a decision (can the caller see the PARENT + * request?) on `GET /approvals/requests/:id/actions`. So a `find` of + * `sys_approval_action` whose `$filter` is exactly one request's scope, + * `{ request_id: '' }` (the shape a related list sends for its parent), + * reads that route instead. The route answers the request's whole timeline, + * oldest first, so the source applies the rest of the read itself: + * + * - `$orderby` (the object-array form) is applied to the rows, by stored + * value, as the data API orders them; + * - `$top` / `$skip` window the ordered rows, and `total` is the timeline's + * length, so a paged list pages over the right set; + * - `$select` and `$expand` are dropped: they shape columns, never which + * rows come back, and the route serves its own display names + * (`actor_name`) in place of an expansion; + * - any other name is REFUSED with the same `UNSUPPORTED_QUERY_PARAM` + * refusal as the list, before any request goes out. + * + * A read of `sys_approval_action` with any other filter is not one request's + * timeline, and goes to the host unchanged. */ import { ApiDataSource } from '@object-ui/core'; import { createAuthenticatedFetch } from '@object-ui/auth'; @@ -70,6 +95,12 @@ import { API_BASE, type ApprovalRequestRow } from './approvalsApi'; /** The object whose reads this source routes to the approvals routes. */ export const APPROVAL_REQUEST_OBJECT = 'sys_approval_request'; +/** The timeline object: one request's rows read through the actions route. */ +export const APPROVAL_ACTION_OBJECT = 'sys_approval_action'; + +/** The column that scopes a timeline row to its request. */ +const ACTION_REQUEST_FIELD = 'request_id'; + /** The inbox's three server-side scopes. */ export type ApprovalRequestScope = | { kind: 'awaiting_me'; approverIds: readonly string[] } @@ -149,6 +180,69 @@ function toScopeParams(scope: ApprovalRequestScope): Record | nu } } +/** The one request a timeline read is scoped to, or `null` when it is not one. */ +function timelineRequestId(params: QueryParams | undefined): string | null { + const filter = params?.$filter; + if (filter == null || typeof filter !== 'object' || Array.isArray(filter)) return null; + const keys = Object.keys(filter); + if (keys.length !== 1 || keys[0] !== ACTION_REQUEST_FIELD) return null; + const id = (filter as Record)[ACTION_REQUEST_FIELD]; + return typeof id === 'string' && id.trim() !== '' ? id : null; +} + +/** Names a timeline read applies itself, or drops because they shape columns. */ +const TIMELINE_APPLIED = new Set(['$filter', '$orderby', '$top', '$skip']); +const TIMELINE_DROPPED = new Set(['$select', '$expand']); + +/** The refusal a timeline read throws: the same code `ListView` classifies. */ +function timelineRefusal(what: string): Error & { code: string } { + const err = new Error( + `${APPROVAL_ACTION_OBJECT}: one request's timeline cannot honour ${what}. ` + + 'It orders ($orderby) and pages ($top, $skip) over that request\'s actions, and nothing else.', + ) as Error & { code: string }; + err.code = 'UNSUPPORTED_QUERY_PARAM'; + return err; +} + +/** One `$orderby` term, as the object-array form spells it. */ +type OrderTerm = { field: string; order: 'asc' | 'desc' }; + +function isOrderTerm(term: unknown): term is { field: string; order?: unknown } { + if (term == null || typeof term !== 'object') return false; + const { field } = term as { field?: unknown }; + return typeof field === 'string' && field !== ''; +} + +/** The timeline's ordering, or a thrown refusal for a form it cannot apply. */ +function toOrderTerms(orderby: QueryParams['$orderby']): OrderTerm[] { + if (isAbsent(orderby)) return []; + const terms: unknown[] = Array.isArray(orderby) ? orderby : []; + const objectTerms = terms.filter(isOrderTerm); + if (terms.length === 0 || objectTerms.length !== terms.length) { + throw timelineRefusal('$orderby in any form but a list of { field, order }'); + } + return objectTerms.map((term) => ({ field: term.field, order: term.order === 'desc' ? 'desc' : 'asc' })); +} + +/** Stored-value comparison; an absent value sorts after every present one. */ +function compareStored(a: unknown, b: unknown): number { + const aAbsent = a === undefined || a === null; + const bAbsent = b === undefined || b === null; + if (aAbsent || bAbsent) return aAbsent === bAbsent ? 0 : aAbsent ? 1 : -1; + if (typeof a === 'number' && typeof b === 'number') return a - b; + const as = String(a); + const bs = String(b); + return as < bs ? -1 : as > bs ? 1 : 0; +} + +/** A non-negative integer window bound, or a thrown refusal. */ +function windowBound(name: '$top' | '$skip', value: unknown): number | undefined { + if (isAbsent(value)) return undefined; + const n = Number(value); + if (!Number.isInteger(n) || n < 0) throw timelineRefusal(`${name} = ${String(value)}`); + return n; +} + /** * Build the routed source for one scope. Hand it to `ListView` / * `RecordDetailView` as their `dataSource`; mount one per scope. @@ -162,7 +256,41 @@ export function createApprovalRequestsDataSource( const scopeParams = toScopeParams(scope); const itemDoor = new ApiDataSource({ read: { url, method: 'GET' }, fetch: fetchFn }); + /** One request's timeline, from the actions route, ordered and windowed here. */ + const findTimeline = async (requestId: string, params: QueryParams): Promise> => { + const refused = Object.keys(params) + .filter((key) => !TIMELINE_APPLIED.has(key) && !TIMELINE_DROPPED.has(key)) + .filter((key) => !isAbsent((params as Record)[key])); + if (refused.length > 0) throw timelineRefusal(refused.sort().join(', ')); + const order = toOrderTerms(params.$orderby); + const top = windowBound('$top', params.$top); + const skip = windowBound('$skip', params.$skip) ?? 0; + const actionsDoor = new ApiDataSource>({ + read: { url: `${url}/${encodeURIComponent(requestId)}/actions`, method: 'GET' }, + fetch: fetchFn, + }); + const { data } = await actionsDoor.find(APPROVAL_ACTION_OBJECT); + const rows = [...data]; + if (order.length > 0) { + // Stable: `Array.prototype.sort` keeps the route's oldest-first order + // between rows the terms leave tied. + rows.sort((a, b) => { + for (const term of order) { + const c = compareStored(a[term.field], b[term.field]); + if (c !== 0) return term.order === 'desc' ? -c : c; + } + return 0; + }); + } + const page = top === undefined ? rows.slice(skip) : rows.slice(skip, skip + top); + return { data: page, total: rows.length }; + }; + const find = async (resource: string, params?: QueryParams): Promise> => { + if (resource === APPROVAL_ACTION_OBJECT) { + const requestId = timelineRequestId(params); + return requestId === null ? host.find(resource, params) : findTimeline(requestId, params ?? {}); + } if (resource !== APPROVAL_REQUEST_OBJECT) return host.find(resource, params); const query = toListQuery(params); if (scopeParams === null) return { data: [], total: 0 }; diff --git a/content/docs/guide/fields.md b/content/docs/guide/fields.md index 69667acf03..7933668d8a 100644 --- a/content/docs/guide/fields.md +++ b/content/docs/guide/fields.md @@ -134,6 +134,19 @@ browser could not read the box at all. Silence means the browser read (reference codes, serial numbers, anything where `1.2.3` is meaningful), declare a `text` field, not a numeric one. +## Polymorphic pointer pairs (`referenceVia`) + +A `text` field that declares `referenceVia` holds a record id of the object its +sibling column names, row by row: `sys_approval_request.record_id` points at the +record `object_name` names, and `sys_activity.source_id` at the one +`source_object` names. The record details grid resolves the pair from the row +with `resolveRecordPointer(field, row)` and draws it with the renderer +registered under `RECORD_POINTER_CARD_TYPE`. The fields package's default draws +the record id as text; the console registers a record preview card there, so a +pointer field on a record page shows the record it points at. Nothing is +authored for this: the `referenceVia` declaration on the field is the whole +contract. List cells are not a call site yet. + ## Using Renderers in Custom Components If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel. diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 8e8cafc1ce..29d94ebe9e 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -19,6 +19,7 @@ "./dist/views/nav-menu-renderer.js", "./dist/views/record-approvals-renderer.js", "./dist/views/record-attachments-renderer.js", + "./dist/views/record-preview/record-pointer-card-renderer.js", "./dist/views/studio-design/studio-canvas-preview.js", "./src/index.ts", "./src/views/app-launcher-renderer.tsx", @@ -36,6 +37,7 @@ "./src/views/nav-menu-renderer.tsx", "./src/views/record-approvals-renderer.tsx", "./src/views/record-attachments-renderer.tsx", + "./src/views/record-preview/record-pointer-card-renderer.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 e514dede02..2046ec1788 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -347,6 +347,10 @@ import './views/record-attachments-renderer.js'; // `record:approvals` — schema-addressable approval panel referenced by // synthesized record pages when the record has approval requests (#3461). import './views/record-approvals-renderer.js'; +// A2's record card as the details grid's face for a `referenceVia` pointer +// pair, registered under `@object-ui/fields`' `RECORD_POINTER_CARD_TYPE` +// (objectui#12045). +import './views/record-preview/record-pointer-card-renderer.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/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index fcfde81c42..515b7e6998 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -1283,7 +1283,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // three things: the status badge / edit lock, the approvals panel (#3461), // and — since objectui#3055 — the pending `sys_approval_request` row that the // object's own SERVER-DECLARED decision actions run against. - const approvals = useRecordApprovals(objectName, pureRecordId); + // + // Not on an approval request's OWN page (objectui#12045): approvals are + // never opened ON a request, so the read could only answer empty, and the + // request's decisions are its own declared actions, drawn by + // `record:approval_decision` over the page record itself. With no object + // name the hook sends nothing and stays inert. + const approvals = useRecordApprovals( + objectName === SYS_APPROVAL_REQUEST_OBJECT ? undefined : objectName, + pureRecordId, + ); // Hold latest approvals snapshot in a ref so the action handler // (memoized once inside ActionRunner) always sees fresh state instead of // the stale closure captured at the first render. diff --git a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx new file mode 100644 index 0000000000..d8cd25e0bd --- /dev/null +++ b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx @@ -0,0 +1,111 @@ +/** + * 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 approval decision panel (objectui#12045, B1 of objectui#2763): how it + * composes A3's progress with the request's declared decision actions over the + * bound record, and that this round registers it as no type (claim amendment 3, + * `6081385696`: the registration lands with its zod arm, derived from the v18 + * spec row, in the page-mount step). + * + * The bar is doubled to a probe that records the props it was handed: what is + * under test here is the panel's wiring (which object, which record, which + * location, what happens after a decision). The real bar, its dialog and the + * POST it sends are pinned over the real record page in the console's + * `approvalRequestsDataSource.decisionPage.test.tsx`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +const barProps: Array> = []; +vi.mock('../DeclaredActionsBar.js', () => ({ + DeclaredActionsBar: (props: Record) => { + barProps.push(props); + return
; + }, +})); + +import { ComponentRegistry } from '@object-ui/core'; +import { RecordContextProvider, subscribeDataChanges, type DataChange } from '@object-ui/react'; +import { ApprovalDecisionRenderer } from './ApprovalDecisionPanel'; + +afterEach(() => { + cleanup(); + barProps.length = 0; +}); + +const REQUEST = { + id: 'req_12045', + process_name: 'invoice_approval', + object_name: 'invoice', + record_id: 'inv_1', + status: 'pending', + pending_approvers: ['u1', 'u2', 'u3'], + viewer: { can_act: true, is_submitter: false, can_override: false }, + decision_progress: { behavior: 'quorum', got: 1, need: 2 }, +}; + +function mountOn(objectName: string, data: Record | undefined) { + return render( + + + , + ); +} + +describe('the approval decision panel (objectui#12045)', () => { + it('is module-internal this round: loading it registers no component type', () => { + expect(ComponentRegistry.has('record:approval_decision')).toBe(false); + expect(ComponentRegistry.has('approval_decision')).toBe(false); + }); + + it("draws the request's tally and runs its declared actions at record_section against the request row", () => { + mountOn('sys_approval_request', REQUEST); + const bar = screen.getByRole('progressbar'); + expect(bar.getAttribute('aria-valuenow')).toBe('1'); + expect(bar.getAttribute('aria-valuemax')).toBe('2'); + expect(barProps).not.toHaveLength(0); + const props = barProps[barProps.length - 1]; + expect(props.objectName).toBe('sys_approval_request'); + expect(props.location).toBe('record_section'); + expect(props.record).toMatchObject({ id: 'req_12045', viewer: REQUEST.viewer }); + expect(props.exclude, 'nothing is excluded: no other part of this page offers a decision').toBeUndefined(); + }); + + it('draws no tally for a node that carries none, and still offers the actions', () => { + mountOn('sys_approval_request', { ...REQUEST, decision_progress: undefined }); + expect(screen.queryByRole('progressbar')).toBeNull(); + expect(screen.getByTestId('declared-actions-probe')).toBeTruthy(); + }); + + it('after a decision, the request record and the timeline are invalidated, not remounted', () => { + mountOn('sys_approval_request', REQUEST); + const changes: DataChange[] = []; + const unsubscribe = subscribeDataChanges((change) => changes.push(change)); + try { + (barProps[barProps.length - 1].onDone as () => void)(); + } finally { + unsubscribe(); + } + expect(changes).toEqual([ + { objectName: 'sys_approval_request', recordId: 'req_12045' }, + { objectName: 'sys_approval_action' }, + ]); + }); + + it.each([ + ['another object', 'invoice', { id: 'inv_1' }], + ['a request page whose record has not loaded', 'sys_approval_request', undefined], + ])('renders nothing on %s', (_case, objectName, data) => { + const { container } = mountOn(objectName, data); + expect(container.innerHTML).toBe(''); + expect(barProps).toHaveLength(0); + }); +}); diff --git a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx new file mode 100644 index 0000000000..229201a847 --- /dev/null +++ b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx @@ -0,0 +1,141 @@ +/** + * 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 decision panel of an approval request's record page (objectui#12045, B1 + * of objectui#2763; ruling 6079807016, letter 乙), proposed to the spec as the + * page component type `record:approval_decision`. + * + * One node, two parts, both already shipped and both reused as they are: + * + * 1. **Progress** — `DecisionProgressIndicator` (A3), drawn from the request's + * server-computed `decision_progress` tally. It is present only on a + * pending node that aggregates more than one decision, and the panel draws + * it only then; nothing is re-derived here. + * 2. **Decisions** — `DeclaredActionsBar` over `sys_approval_request`'s OWN + * declared actions at `record_section`, the location and the bar the + * business record page already runs them through (objectui#3055). Every + * decision is an ordinary declared `type:'api'` action, its form is + * `ActionParamDialog` (a comment and several attachments, ruling Q3-A), and + * whether a viewer sees one is the action's own `visible` predicate over + * the request's server-computed `viewer` block. The panel adds no + * per-action code, so a new decision action is metadata alone. + * + * ## No authorable props + * + * The panel reads the record context and nothing else: the request row the + * page bound (with its `viewer` and `decision_progress`, which the approvals + * get route serves and the data API does not) and the object's declared + * actions. objectstack-ai/objectstack#22472 proposes exactly this contract + * for the spec, a `ComponentPropsMap` row that is an empty strict object, so a + * misspelled key on an authored node is a validation finding instead of a + * silently ignored prop. + * + * Outside a `sys_approval_request` record page there is no request to decide + * on, and the panel renders nothing. + * + * ## After a decision + * + * The action has already POSTed and the runtime has already toasted. What the + * page owes is a re-read, as data and not as a remount (AGENTS.md #8): the + * request record (its status, `viewer` and tally change) and the timeline (a + * decision appends a `sys_approval_action` row), both through the + * invalidation bus their readers already subscribe to. + * + * ## Module-internal until the spec row exists + * + * Nothing registers this renderer yet, and nothing re-exports it from the + * package entry (claim amendment 3 on objectui#12045, `6081385696`). A + * registered type that page metadata can reference needs an arm in + * `@object-ui/types/zod`, and an arm is derived from the spec's + * `ComponentPropsMap` row, which `@objectstack/spec` 17.7.0 does not carry for + * this type (objectstack-ai/objectstack#22472 adds it on v18). Registering it + * without one is what `registered-types-validate-ratchet-10859` in + * `@object-ui/cli` refuses. So the registration under the name the spec seat + * settles, its side-effect import from `src/index.ts`, its + * `known-schema-types.ts` line and its zod arm land together in the page-mount + * step. Until then spec 17.7.0's lint and `objectui validate` both refuse an + * authored node of this type, consistently. + * + * It lives in app-shell rather than plugin-detail for the reason + * `record:approvals` does: the bar's runtime depends on `@object-ui/auth`, + * which plugin-detail deliberately does not pull in. + */ + +import * as React from 'react'; +import { notifyDataChanged, useRecordContext } from '@object-ui/react'; +import { cn } from '@object-ui/components'; +import { DecisionProgressIndicator } from '../approval-progress/DecisionProgressIndicator.js'; +import { DeclaredActionsBar } from '../DeclaredActionsBar.js'; +import { RECORD_APPROVAL_ACTION_LOCATION, SYS_APPROVAL_REQUEST_OBJECT } from '../recordApprovalActions.js'; +import type { ApprovalRequestLite } from '../../hooks/useRecordApprovals.js'; + +/** The timeline object a decision appends a row to. */ +const APPROVAL_ACTION_OBJECT = 'sys_approval_action'; + +export interface ApprovalDecisionPanelProps { + /** + * The request row as the approvals get route serves it: the dispatch record + * the declared actions run against (`{id}` resolves to it) and the `viewer` + * block their `visible` predicates read. + */ + request: ApprovalRequestLite & { viewer?: unknown }; + className?: string; +} + +/** The two parts over one request row. Module-internal: no package export. */ +export function ApprovalDecisionPanel({ request, className }: ApprovalDecisionPanelProps) { + const progress = request.decision_progress; + const eligible = (request.pending_approvers || []).length; + const requestId = String(request.id); + + const handleDecided = React.useCallback(() => { + notifyDataChanged({ objectName: SYS_APPROVAL_REQUEST_OBJECT, recordId: requestId }); + notifyDataChanged({ objectName: APPROVAL_ACTION_OBJECT }); + }, [requestId]); + + return ( +
+ {progress && } + +
+ ); +} + +const splitDesigner = (props: Record) => { + const { 'data-obj-id': id, 'data-obj-type': type, style } = props || {}; + return { 'data-obj-id': id, 'data-obj-type': type, style } as React.HTMLAttributes; +}; + +export interface ApprovalDecisionRendererProps { + schema?: Record; + className?: string; + [k: string]: unknown; +} + +/** The node renderer: the panel over the bound request, or nothing. */ +export const ApprovalDecisionRenderer: React.FC = ({ + schema: _schema, + className, + ...props +}) => { + const ctx = useRecordContext(); + const designer = splitDesigner(props); + const row = ctx?.objectName === SYS_APPROVAL_REQUEST_OBJECT ? ctx.data : undefined; + if (row == null || typeof row !== 'object' || (row as { id?: unknown }).id == null) return null; + return ( +
+ +
+ ); +}; diff --git a/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.test.tsx b/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.test.tsx new file mode 100644 index 0000000000..297d363c6b --- /dev/null +++ b/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.test.tsx @@ -0,0 +1,132 @@ +/** + * 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. + */ + +/** + * A2's record card is the details grid's face for a `referenceVia` pointer + * pair (objectui#12045, B1 of objectui#2763). + * + * The real details grid (`DetailSection` from `@object-ui/plugin-detail`) + * resolves the pair; this package's registration draws it as + * `RecordPreviewCard`, which reads the target through the console's adapter. + * Pinned on `sys_approval_request.record_id` and on a non-approval pair, + * `sys_activity.source_id`, whose sibling column is not called `object_name`. + * + * States are read from the card's `data-record-preview` attribute and the + * target from the adapter's own call log, never from copy. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { AdapterCtx, MetadataCtx } from '@object-ui/react'; +import { getCellRenderer, RECORD_POINTER_CARD_TYPE } from '@object-ui/fields'; +import { DetailSection } from '@object-ui/plugin-detail'; +import type { DetailViewSection } from '@object-ui/types'; +import { RecordPointerCardRenderer } from './record-pointer-card-renderer'; + +afterEach(() => cleanup()); + +const DEFS: Record = { + invoice: { + name: 'invoice', + label: 'Invoice', + nameField: 'subject', + highlightFields: ['amount'], + fields: { subject: { type: 'text', label: 'Subject' }, amount: { type: 'number', label: 'Amount' } }, + }, + sys_email: { + name: 'sys_email', + label: 'Email', + nameField: 'subject', + highlightFields: [], + fields: { subject: { type: 'text', label: 'Subject' } }, + }, +}; + +const RECORDS: Record> = { + 'invoice::inv_1': { id: 'inv_1', subject: 'Laptop refresh', amount: 4200 }, + 'sys_email::em_7': { id: 'em_7', subject: 'Re: renewal terms' }, +}; + +function mount(objectSchema: Record, fields: string[], data: Record) { + const findOne = vi.fn(async (objectName: string, id: string) => RECORDS[`${objectName}::${id}`] ?? null); + const metadata = { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem: async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null), + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, + }; + const utils = render( + + + ({ name, label: name })) } as DetailViewSection} + data={data} + objectSchema={objectSchema} + objectName="probe_12045" + /> + + , + ); + return { ...utils, findOne }; +} + +const cardState = (container: HTMLElement) => + container.querySelector('[data-record-preview]')?.getAttribute('data-record-preview'); + +describe('the record card is the pointer face (objectui#12045)', () => { + it('is what the registry answers for the pointer key once this package is loaded', () => { + expect(getCellRenderer(RECORD_POINTER_CARD_TYPE)).toBe(RecordPointerCardRenderer); + }); + + it('sys_approval_request.record_id draws the target invoice as a card', async () => { + const { container, findOne } = mount( + { + fields: { + object_name: { type: 'text', label: 'Object' }, + record_id: { type: 'text', label: 'Record', referenceVia: 'object_name' }, + }, + }, + ['record_id'], + { object_name: 'invoice', record_id: 'inv_1' }, + ); + await waitFor(() => expect(cardState(container)).toBe('readable')); + expect(findOne).toHaveBeenCalledTimes(1); + expect(findOne.mock.calls[0].slice(0, 2)).toEqual(['invoice', 'inv_1']); + expect(container.textContent).toContain('Laptop refresh'); + }); + + it('a non-approval pair, sys_activity.source_id, draws the record its own sibling names', async () => { + const { container, findOne } = mount( + { + fields: { + source_object: { type: 'text', label: 'Source Object' }, + source_id: { type: 'text', label: 'Source ID', referenceVia: 'source_object' }, + }, + }, + ['source_id'], + { source_object: 'sys_email', source_id: 'em_7', object_name: 'contact' }, + ); + await waitFor(() => expect(cardState(container)).toBe('readable')); + expect(findOne.mock.calls.map((call) => call.slice(0, 2))).toEqual([['sys_email', 'em_7']]); + expect(container.textContent).toContain('Re: renewal terms'); + }); + + it('control: a plain text field with no referenceVia draws no card and reads nothing', async () => { + const { container, findOne } = mount( + { fields: { object_name: { type: 'text' }, record_id: { type: 'text', label: 'Record' } } }, + ['record_id'], + { object_name: 'invoice', record_id: 'inv_1' }, + ); + expect(container.textContent).toContain('inv_1'); + expect(cardState(container)).toBeUndefined(); + expect(findOne).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.tsx b/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.tsx new file mode 100644 index 0000000000..b240aefdbb --- /dev/null +++ b/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.tsx @@ -0,0 +1,56 @@ +/** + * 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 record details grid's face for a `referenceVia` pointer pair + * (objectui#12045, B1 of objectui#2763): `RecordPreviewCard` (A2), registered + * under `@object-ui/fields`' `RECORD_POINTER_CARD_TYPE`. + * + * `@object-ui/fields` owns the rule (`resolveRecordPointer`) and the key, and + * keeps a default under it that draws the record id as text. `fields` cannot + * import this package, so the card is registered from here, through the + * published `registerFieldRenderer`, and imported for its side effect from the + * package entry (`src/index.ts`). A field that declares `referenceVia` then + * draws the record it points at wherever the details grid shows it: + * `sys_approval_request.record_id`, both pairs on `sys_activity`, and the other + * pointer fields the spec declares, with no page node and no authorable key. + * + * The card reads the target through the console's adapter, the same instance + * every other record read on the page goes through. It says nothing about why + * a target cannot be read; see the card's own header for why that matters. + */ + +import { registerFieldRenderer, RECORD_POINTER_CARD_TYPE, type CellRendererProps, type RecordPointer } from '@object-ui/fields'; +import { useAdapter } from '@object-ui/react'; +import { RecordPreviewCard } from './RecordPreviewCard.js'; + +/** The pair `resolveRecordPointer` hands the renderer as its `value`. */ +function asRecordPointer(value: unknown): RecordPointer | null { + if (value == null || typeof value !== 'object') return null; + const { objectName, recordId } = value as Partial; + return typeof objectName === 'string' && typeof recordId === 'string' ? { objectName, recordId } : null; +} + +/** + * Draws the record a pointer pair names as `RecordPreviewCard`. A value that is + * not a pair addresses nothing, and the card draws its `absent` state for it. + */ +export function RecordPointerCardRenderer({ value }: CellRendererProps) { + const dataSource = useAdapter(); + const pointer = asRecordPointer(value); + return ( + + ); +} + +registerFieldRenderer(RECORD_POINTER_CARD_TYPE, RecordPointerCardRenderer); diff --git a/packages/fields/README.md b/packages/fields/README.md index 126fa252c4..a54fdc496a 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -168,6 +168,39 @@ import { MaskedCellRenderer } from '@object-ui/fields'; const withheldCell = (value: unknown) => ; ``` +### Polymorphic pointer pairs (`referenceVia`) + +A `text` field that declares `@objectstack/spec`'s `referenceVia` is the id half +of a two-column pointer pair: its value is a record id of the object that the +sibling column it names holds, on the same row (`sys_approval_request.record_id` +with `referenceVia: 'object_name'`, or `sys_activity.source_id` with +`referenceVia: 'source_object'`). The target changes per row, and a cell +renderer gets one value and no row, so the pair is resolved by the caller that +holds the row (objectui#12045): + +- `resolveRecordPointer(field, row)` is the one place the rule lives. It + returns `{ objectName, recordId }` (a `RecordPointer`) for a `text` field with + a non-empty `referenceVia` whose row fills both halves, and `null` otherwise. +- `RECORD_POINTER_CARD_TYPE` is the registry key a resolved pair is drawn with, + the pair as the renderer's `value`. This package registers a default under it + that draws the record id as text, so nothing changes for a host that does not + register a face. `@object-ui/app-shell` registers its record preview card + there. + +The record details grid in `@object-ui/plugin-detail` is the call site today; +list cells are not one yet. + +```tsx +import { getCellRenderer, resolveRecordPointer, RECORD_POINTER_CARD_TYPE } from '@object-ui/fields'; + +function PointerValue({ field, row }: { field: { name: string; type: string; referenceVia?: string }; row: Record }) { + const pointer = resolveRecordPointer(field, row); + if (!pointer) return <>{String(row[field.name] ?? '')}; + const Face = getCellRenderer(RECORD_POINTER_CARD_TYPE); + return ; +} +``` + ### File uploads in line-item grids `GridField` (the master-detail line-items grid) supports `type: 'file'` columns: diff --git a/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx b/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx index 0b46b04a0e..39ae0b9097 100644 --- a/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx +++ b/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx @@ -288,6 +288,10 @@ const OBJECT_LITERAL_CENSUS: ReadonlyArray = ['secret', 'hunter2'], ['vector', [1, 2, 3]], ['grid', [[1]]], + ['record_pointer_card', { objectName: 'invoice', recordId: 'inv_1' }], ]; describe('objectui#8596 — an object literal is not a cell value, and these renderers invented one', () => { diff --git a/packages/fields/src/__tests__/resolveRecordPointer-12045.test.tsx b/packages/fields/src/__tests__/resolveRecordPointer-12045.test.tsx new file mode 100644 index 0000000000..e53ae7e7b9 --- /dev/null +++ b/packages/fields/src/__tests__/resolveRecordPointer-12045.test.tsx @@ -0,0 +1,78 @@ +/** + * 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. + */ + +/** + * `referenceVia` pointer pairs: the rule and the default face (objectui#12045). + * + * `resolveRecordPointer` is the one place the pair is read, and the default + * registered under `RECORD_POINTER_CARD_TYPE` is what a host with no richer + * face draws. The pairs below are two the spec declares on objectstack's + * system objects: `sys_approval_request.record_id` (sibling `object_name`) and + * `sys_activity.source_id` (sibling `source_object`), so the rule is pinned on + * a sibling name other than `object_name` too. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { + getCellRenderer, + resolveRecordPointer, + RECORD_POINTER_CARD_TYPE, + TextCellRenderer, +} from '../index'; + +afterEach(() => cleanup()); + +const RECORD_ID = { name: 'record_id', type: 'text', referenceVia: 'object_name' } as const; +const SOURCE_ID = { name: 'source_id', type: 'text', referenceVia: 'source_object' } as const; + +describe('resolveRecordPointer (objectui#12045)', () => { + it('reads the approval request pair: the id from the field, the object from its sibling', () => { + expect(resolveRecordPointer(RECORD_ID, { record_id: 'inv_1', object_name: 'invoice' })).toEqual({ + objectName: 'invoice', + recordId: 'inv_1', + }); + }); + + it('reads the sibling the field names, not a column called object_name', () => { + const row = { source_id: 'em_7', source_object: 'sys_email', object_name: 'contact', record_id: 'c_1' }; + expect(resolveRecordPointer(SOURCE_ID, row)).toEqual({ objectName: 'sys_email', recordId: 'em_7' }); + }); + + it.each([ + ['a field with no referenceVia', { name: 'record_id', type: 'text' }], + ['an empty referenceVia', { name: 'record_id', type: 'text', referenceVia: '' }], + ['a non-text field (the spec refuses referenceVia there)', { name: 'record_id', type: 'lookup', referenceVia: 'object_name' }], + ])('is null for %s', (_case, field) => { + expect(resolveRecordPointer(field, { record_id: 'inv_1', object_name: 'invoice' })).toBeNull(); + }); + + it.each([ + ['no row', null], + ['a blank id half', { record_id: ' ', object_name: 'invoice' }], + ['a missing object half', { record_id: 'inv_1' }], + ['a blank object half', { record_id: 'inv_1', object_name: '' }], + ['a non-string id half', { record_id: 42, object_name: 'invoice' }], + ])('is null for %s', (_case, row) => { + expect(resolveRecordPointer(RECORD_ID, row as Record | null)).toBeNull(); + }); +}); + +describe('the default face under RECORD_POINTER_CARD_TYPE (objectui#12045)', () => { + it('is a renderer of its own, not the total text fallback', () => { + expect(getCellRenderer(RECORD_POINTER_CARD_TYPE)).not.toBe(TextCellRenderer); + }); + + it('draws the record id as text, as the field drew before its pair was read', () => { + const Renderer = getCellRenderer(RECORD_POINTER_CARD_TYPE); + const { container } = render( + , + ); + expect(container.textContent).toBe('inv_1'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 252a69819f..bd77516ca2 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -3056,6 +3056,87 @@ export function registerFieldRenderer(type: string, renderer: React.FC> | null | undefined, +): RecordPointer | null { + if (!field || !row) return null; + if (field.type !== 'text') return null; + const via = field.referenceVia; + if (typeof via !== 'string' || via === '') return null; + const recordId = row[field.name]; + const objectName = row[via]; + if (typeof recordId !== 'string' || recordId.trim() === '') return null; + if (typeof objectName !== 'string' || objectName.trim() === '') return null; + return { objectName, recordId }; +} + +function isRecordPointer(value: unknown): value is RecordPointer { + if (value == null || typeof value !== 'object') return false; + const candidate = value as Partial; + return typeof candidate.objectName === 'string' && typeof candidate.recordId === 'string'; +} + +/** + * The default face for {@link RECORD_POINTER_CARD_TYPE}: the record id as + * text, which is what the pointer field drew before its pair was read. Any + * other value goes to the text cell unchanged. + */ +function RecordPointerTextCellRenderer({ value, field }: CellRendererProps): React.ReactElement { + return ; +} + /** * Format hints (e.g. `{ type: 'text', format: 'phone' }`) that should * map to a richer cell renderer than the bare type would imply. The @@ -3799,6 +3880,9 @@ registerFieldRenderer('master_detail', LookupCellRenderer); registerFieldRenderer('select', SelectCellRenderer); registerFieldRenderer('status', SelectCellRenderer); 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); // `owner` was registered here to the same UserCellRenderer until objectui#4814 // retired the spelling — see the TOMBSTONE below. `getCellRenderer('owner')` // now reports the prescription and falls to the text cell. diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index a9dc7b51b7..cb928a73c3 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -27,7 +27,14 @@ import { } from '@object-ui/components'; import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react'; import { SchemaRenderer, toRenderableSchema, useInlineEdit } from '@object-ui/react'; -import { getCellRenderer, resolveCellRendererType, coerceToSafeValue, TextAreaField } from '@object-ui/fields'; +import { + getCellRenderer, + resolveCellRendererType, + resolveRecordPointer, + RECORD_POINTER_CARD_TYPE, + coerceToSafeValue, + TextAreaField, +} from '@object-ui/fields'; import type { DetailViewSection as DetailViewSectionType, DetailViewField, FieldMetadata } from '@object-ui/types'; import { applyDetailAutoLayout } from './autoLayout'; import { useDetailTranslation } from './useDetailTranslation'; @@ -414,6 +421,22 @@ export const DetailSection: React.FC = ({ /> ); } + // A polymorphic pointer pair (`referenceVia`, objectui#12045): the field's + // value is a record id of the object the SIBLING column names on this + // row, so it is resolved here, where the row is in hand, by the one rule + // `@object-ui/fields` owns. The pair is the renderer's `value`; the + // default face draws the record id as text, and a host that can read + // records registers its own under the same key. `referenceVia` is read + // off the OBJECT field (the spec declares it there), and `type` off the + // enriched field, so a view that re-types the field keeps its own face. + const pointer = resolveRecordPointer( + { name: field.name, type: enrichedField.type, referenceVia: objectDefField?.referenceVia }, + data, + ); + if (pointer) { + const PointerRenderer = getCellRenderer(RECORD_POINTER_CARD_TYPE); + return ; + } // Use type-aware cell renderer; respect format hints (e.g. // text + format: 'phone' → PhoneCellRenderer with tel: link). const resolvedType = resolveCellRendererType(enrichedField as { type?: string; format?: string }) || field.type; diff --git a/packages/plugin-detail/src/__tests__/DetailSection.recordPointer-12045.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.recordPointer-12045.test.tsx new file mode 100644 index 0000000000..fc832468cd --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailSection.recordPointer-12045.test.tsx @@ -0,0 +1,125 @@ +/** + * 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 details grid draws a `referenceVia` pointer pair through the pointer + * face (objectui#12045). + * + * The grid holds the row, so it is where the pair is resolved: it asks + * `@object-ui/fields`' `resolveRecordPointer` and draws a pair with the + * renderer registered under `RECORD_POINTER_CARD_TYPE`, the pair as its + * `value`. Two pairs the spec declares on objectstack's system objects are + * pinned: `sys_approval_request.record_id` (sibling `object_name`) and + * `sys_activity.source_id` (sibling `source_object`). + * + * Without a host face the default draws the record id as text, the same text + * the row drew before the pair was read. With a face registered (as + * `@object-ui/app-shell` registers its record card), the face receives the + * resolved pair. A face is registered through the published + * `registerFieldRenderer`, which has no inverse; this file is a `.tsx` (the + * `dom` project, `isolate: true`) and puts the default back after each case. + */ + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { + getCellRenderer, + registerFieldRenderer, + RECORD_POINTER_CARD_TYPE, + type CellRendererProps, +} from '@object-ui/fields'; +import type { DetailViewSection } from '@object-ui/types'; +import { DetailSection } from '../DetailSection'; + +beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })); + +const DEFAULT_FACE = getCellRenderer(RECORD_POINTER_CARD_TYPE); + +afterEach(() => { + cleanup(); + registerFieldRenderer(RECORD_POINTER_CARD_TYPE, DEFAULT_FACE); +}); + +/** A face that prints the pair it was handed, so a pin reads it off the DOM. */ +const ProbeFace: React.FC = ({ value }) => ( + {JSON.stringify(value)} +); + +const APPROVAL_REQUEST = { + fields: { + object_name: { type: 'text', label: 'Object' }, + record_id: { type: 'text', label: 'Record', referenceVia: 'object_name' }, + process_name: { type: 'text', label: 'Process' }, + }, +}; + +const ACTIVITY = { + fields: { + source_object: { type: 'text', label: 'Source Object' }, + source_id: { type: 'text', label: 'Source ID', referenceVia: 'source_object' }, + record_id: { type: 'text', label: 'Record ID', referenceVia: 'object_name' }, + }, +}; + +function renderSection(objectSchema: Record, fields: string[], data: Record) { + return render( + ({ name, label: name })) } as DetailViewSection} + data={data} + objectSchema={objectSchema} + objectName="probe_12045" + />, + ); +} + +describe('DetailSection draws a referenceVia pointer pair through the pointer face (objectui#12045)', () => { + it('sys_approval_request.record_id: the face receives the pair read from the row', () => { + registerFieldRenderer(RECORD_POINTER_CARD_TYPE, ProbeFace); + const { getAllByTestId } = renderSection(APPROVAL_REQUEST, ['object_name', 'record_id', 'process_name'], { + object_name: 'invoice', + record_id: 'inv_1', + process_name: 'invoice_approval', + }); + const probes = getAllByTestId('pointer-probe'); + expect(probes, 'only the pointer field is drawn by the pointer face').toHaveLength(1); + expect(JSON.parse(probes[0].textContent ?? 'null')).toEqual({ objectName: 'invoice', recordId: 'inv_1' }); + }); + + it('a non-approval pair, sys_activity.source_id: the face reads the sibling the field names', () => { + registerFieldRenderer(RECORD_POINTER_CARD_TYPE, ProbeFace); + const { getAllByTestId } = renderSection(ACTIVITY, ['source_object', 'source_id'], { + source_object: 'sys_email', + source_id: 'em_7', + object_name: 'contact', + }); + const probes = getAllByTestId('pointer-probe'); + expect(probes).toHaveLength(1); + expect(JSON.parse(probes[0].textContent ?? 'null')).toEqual({ objectName: 'sys_email', recordId: 'em_7' }); + }); + + it('control: a half-addressed row is not a pair, and the field draws its stored text', () => { + registerFieldRenderer(RECORD_POINTER_CARD_TYPE, ProbeFace); + const { queryAllByTestId, container } = renderSection(ACTIVITY, ['source_id'], { + source_id: 'em_7', + source_object: '', + }); + expect(queryAllByTestId('pointer-probe')).toHaveLength(0); + expect(container.textContent).toContain('em_7'); + }); + + it('without a host face, the default draws the record id as text', () => { + const { container, queryAllByTestId } = renderSection(APPROVAL_REQUEST, ['record_id'], { + object_name: 'invoice', + record_id: 'inv_1', + }); + expect(queryAllByTestId('pointer-probe')).toHaveLength(0); + expect(container.textContent).toContain('inv_1'); + expect(container.textContent).not.toContain('[Object]'); + }); +}); 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 ee24bde01f..bc98a8d6d1 100644 --- a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx +++ b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx @@ -152,6 +152,11 @@ const CENSUS: ReadonlyArray v === 'fit'); - expect(fitting.length, 'the fitting side is not empty').toBe(35); + expect(fitting.length, 'the fitting side is not empty').toBe(36); for (const [type] of fitting) { const { container } = renderChip(type);