From b78ab082fae914af5f0c59338876ee9a5097e8bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:42:02 +0000 Subject: [PATCH] feat(fields,app-shell,plugin-detail): a pointer whose target the platform deleted draws a tombstone in the declared option's label resolveRecordPointer reads @objectstack/spec's referenceDeletedWhen beside referenceVia: on a row whose sibling select holds the declared option, the pair carries deletedLabel, that option's label from the caller's options. DetailSection passes the key and the sibling's options translated as the grid translates select options. The app-shell pointer face draws the tombstone in that label without mounting RecordPreviewCard: no read, no link, no id. The default text face draws the label in place of the id. Pins: the rule and default face (fields), the face under the real details grid (app-shell), and the four reference states on the served approval request page against the inbox sheet's pins (console). Claude-Session: https://claude.ai/code/session_01RXFSkyCWZHTfnnf5iKrQFN Co-authored-by: Claude --- .../12489-detail-section-deleted-when.md | 9 + .changeset/12489-pointer-card-tombstone.md | 9 + .../12489-record-pointer-deleted-label.md | 11 + ...e.servedPageReferenceStates-12489.test.tsx | 401 ++++++++++++++++++ content/docs/guide/fields.md | 10 + ...ter-card-renderer.tombstone-12489.test.tsx | 176 ++++++++ .../record-pointer-card-renderer.tsx | 39 +- packages/fields/README.md | 13 +- ...esolveRecordPointer.deleted-12489.test.tsx | 103 +++++ packages/fields/src/index.tsx | 81 +++- packages/plugin-detail/src/DetailSection.tsx | 18 +- 11 files changed, 854 insertions(+), 16 deletions(-) create mode 100644 .changeset/12489-detail-section-deleted-when.md create mode 100644 .changeset/12489-pointer-card-tombstone.md create mode 100644 .changeset/12489-record-pointer-deleted-label.md create mode 100644 apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx create mode 100644 packages/app-shell/src/views/record-preview/record-pointer-card-renderer.tombstone-12489.test.tsx create mode 100644 packages/fields/src/__tests__/resolveRecordPointer.deleted-12489.test.tsx diff --git a/.changeset/12489-detail-section-deleted-when.md b/.changeset/12489-detail-section-deleted-when.md new file mode 100644 index 0000000000..2502750f7d --- /dev/null +++ b/.changeset/12489-detail-section-deleted-when.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-detail': minor +--- + +The details grid passes a pointer field's `referenceDeletedWhen` to `resolveRecordPointer` (objectui#12489). + +`DetailSection` hands the rule the object field's `referenceDeletedWhen` beside `referenceVia`, and the sibling field's options translated exactly as the grid translates a `select` field's options. So the tombstone a pointer face draws reads as the option reads in its own cell, localized labels included. The predicate is evaluated in `@object-ui/fields`, not here. A field without the key draws as before. + +**Clause-②: yes (widening)**: the grid hands the pointer face one more state. No export, prop or type changes, and nothing narrows. diff --git a/.changeset/12489-pointer-card-tombstone.md b/.changeset/12489-pointer-card-tombstone.md new file mode 100644 index 0000000000..2858331c42 --- /dev/null +++ b/.changeset/12489-pointer-card-tombstone.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': minor +--- + +The record details grid draws a platform-deleted pointer target as a tombstone in the declared option's label (objectui#12489). + +The pointer face registered under `RECORD_POINTER_CARD_TYPE` is the caller `RecordPreviewCard` leaves "a deletion the platform asserts" to. When the pair it receives carries `deletedLabel` (the field's `referenceDeletedWhen` held on this row), it draws that label beside a wastebasket in the card's frame (`data-record-pointer="deleted"`), and does not mount the card: no read of the target, no definition read, no link, no record id. On the served approval request page, a request whose `cancel_reason` is `record_deleted` now shows "Related record deleted", or the localized label of that option, where it showed "This record cannot be opened". A target read that fails or answers no record still draws the card's cause-free text: only the declared assertion means deleted. + +**Clause-②: yes (widening)**: the face draws one more state. No export, prop, type or language-pack key changes, and nothing narrows. diff --git a/.changeset/12489-record-pointer-deleted-label.md b/.changeset/12489-record-pointer-deleted-label.md new file mode 100644 index 0000000000..d24b8b02d7 --- /dev/null +++ b/.changeset/12489-record-pointer-deleted-label.md @@ -0,0 +1,11 @@ +--- +'@object-ui/fields': minor +--- + +`resolveRecordPointer` reads `referenceDeletedWhen`, so a pointer pair can say the platform deleted its target (objectui#12489). + +`@objectstack/spec`'s `FieldSchema.referenceDeletedWhen: { field, value }` sits beside `referenceVia` and names a sibling `select` field and one of its options. On a row whose sibling holds that option, the platform has asserted that the record the pointer names was deleted. `resolveRecordPointer(field, row, optionsOf)` now evaluates that predicate in the same call that resolves the pair. When it holds, the pair comes back with `deletedLabel`, the declared option's label taken from `optionsOf(field)`, the option list the call site draws `select` cells with. With no `optionsOf`, or no option with that value, the pair comes back as before. The spec refuses an undeclared option at parse, and the raw option value is never used as the text. + +The default face under `RECORD_POINTER_CARD_TYPE` draws `deletedLabel` in place of the record id. A field without `referenceDeletedWhen`, or a row whose sibling holds anything else, draws exactly as before. + +**Clause-②: yes (widening)**: the exported `RecordPointer` type gains the optional `deletedLabel`, `resolveRecordPointer`'s field parameter gains the optional `referenceDeletedWhen`, and the function gains an optional third parameter, `optionsOf`. Nothing narrows: every existing argument and return shape still type-checks and resolves as before. No new export. diff --git a/apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx b/apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx new file mode 100644 index 0000000000..f7422da6f9 --- /dev/null +++ b/apps/console/src/services/approvalRequestsDataSource.servedPageReferenceStates-12489.test.tsx @@ -0,0 +1,401 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#12489 — on the page objectstack serves for an approval request, the + * target reference draws the four states the inbox side sheet draws: a link + * when the approver may read the record, no link when the read is refused, the + * tombstone when the platform deleted the record, and a cause-free text when + * the reference cannot be resolved. + * + * The sheet's pins are the reference, read and not changed: + * `ApprovalsInboxPage.recordLink.test.tsx`, + * `ApprovalsInboxPage.recordLinkRefused-11878.test.tsx`, + * `ApprovalsInboxPage.tombstone.test.tsx` and + * `ApprovalsInboxPage.unresolvableReference.test.tsx`, all under + * `apps/console/src/pages/system/`. + * + * The tombstone is the declaration ruling A of objectui#12489 put on the field + * that owns the pointer pair: `record_id` declares + * `referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }` + * beside `referenceVia` (`@objectstack/spec` `FieldSchema`; the services half + * is objectstack-ai/objectstack#23736). The request definition below carries + * that declaration and the `cancel_reason` options with the contract's own + * labels (`APPROVAL_CANCEL_REASON_LABELS`), the text the sheet's tombstone + * pin reads. The tombstone reads nothing and links nothing, so the target + * invoice's by-id read is counted on the adapter's `findOne`. + * + * The harness is `approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx`'s: + * the REAL `RecordDetailView` on the console's app-scoped record route, reading + * through `ApprovalRecordReads`, under the root `SchemaRendererProvider` as + * `ConsoleShell` mounts it, with `SERVED_PAGE` copied whole from objectstack's + * `plugin-approvals`. Only the HTTP transport and the console adapter are + * doubled. + * + * ⚠️ Out of this card, said so a reader does not take its absence as a pass: + * the page H1 is `display_title` (`{process_name} · {record_id}`), which shows + * the raw record id in every state; objectui#12484 carries it. The id pins + * below are scoped to the reference itself. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } 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, SchemaRendererProvider, useAdapter } from '@object-ui/react'; +import { I18nProvider, type I18nConfig } from '@object-ui/i18n'; +import { RecordDetailView } from '@object-ui/app-shell'; +import { PageSchema } from '@objectstack/spec/ui'; +import { APPROVAL_CANCEL_REASON_LABELS, APPROVAL_STATUS_LABELS } from '@objectstack/spec/contracts'; +import { APPROVAL_ACTION_OBJECT, APPROVAL_REQUEST_OBJECT } from './approvalRequestsDataSource'; +import { ApprovalRecordReads } from '../AppContent'; + +const APP = 'probe_app'; +const REQ = 'req_12489'; +const TARGET_TITLE = 'INV-1010'; + +const INVOICE_DEF = { + name: 'invoice', + label: 'Invoice', + nameField: 'name', + fields: { + name: { type: 'text', label: 'Invoice Number' }, + amount: { type: 'currency', label: 'Invoice Amount' }, + }, +}; +const REQUEST_DEF = { + name: APPROVAL_REQUEST_OBJECT, + label: 'Approval Request', + pluralLabel: 'Approval Requests', + nameField: 'display_title', + titleFormat: '{process_name} · {record_id}', + highlightFields: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'], + fields: { + id: { label: 'Request ID', type: 'text', readonly: true, group: 'System' }, + display_title: { label: 'Title', type: 'formula', returnType: 'text', group: 'Target' }, + process_name: { label: 'Source', type: 'text', group: 'Target' }, + object_name: { label: 'Object', type: 'text', group: 'Target' }, + record_id: { + label: 'Record ID', type: 'text', referenceVia: 'object_name', group: 'Target', + referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }, + }, + submitter_id: { label: 'Submitter', type: 'lookup', reference: 'sys_user', group: 'Target' }, + status: { + label: 'Status', type: 'select', group: 'State', + options: Object.entries(APPROVAL_STATUS_LABELS).map(([value, label]) => ({ value, label })), + }, + cancel_reason: { + label: 'Cancel Reason', type: 'select', group: 'State', + options: Object.entries(APPROVAL_CANCEL_REASON_LABELS).map(([value, label]) => ({ value, label })), + }, + current_step: { label: 'Current Step', type: 'text', group: 'State' }, + updated_at: { label: 'Updated At', type: 'datetime', group: 'System' }, + }, + actions: [], + enable: { apiMethods: ['get', 'list'] }, +}; +const ACTION_DEF = { + name: APPROVAL_ACTION_OBJECT, + label: 'Approval Action', + pluralLabel: 'Approval Actions', + nameField: 'display_title', + fields: { + id: { label: 'Action ID', type: 'text', readonly: true }, + request_id: { label: 'Request', type: 'lookup', reference: APPROVAL_REQUEST_OBJECT }, + created_at: { label: 'Created At', type: 'datetime' }, + }, + enable: { apiMethods: ['get', 'list'] }, +}; +const USER_DEF = { name: 'sys_user', label: 'User', nameField: 'name', fields: { name: { label: 'Name', type: 'text' } } }; +const DEFS: Record = { + [APPROVAL_REQUEST_OBJECT]: REQUEST_DEF, + [APPROVAL_ACTION_OBJECT]: ACTION_DEF, + sys_user: USER_DEF, + invoice: INVOICE_DEF, +}; + +/** objectstack `plugin-approvals`' `SysApprovalRequestDetailPage`, whole. */ +const SERVED_PAGE = { + name: 'sys_approval_request_detail', + label: 'Approval Request', + type: 'record', + object: 'sys_approval_request', + template: 'default', + kind: 'slotted', + isDefault: true, + regions: [], + slots: { + actions: [{ type: 'record:approval_decision' }], + highlights: { + type: 'record:highlights', + properties: { + fields: ['process_name', 'object_name', 'status', 'current_step', 'submitter_id', 'updated_at'], + }, + }, + tabs: { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, + { + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], + }, + ], + }, + }, + discussion: [], + }, +}; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +/** The request row the approvals get answers, per case. */ +let served: Record = {}; +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(); + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}`) return json(200, served); + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}/actions`) return json(200, { data: [] }); + return json(200, { success: true, data: [] }); +}); +vi.stubGlobal('fetch', transport); + +const httpError = (status: number, code: string) => Object.assign(new Error(code), { httpStatus: status, status, code }); + +/** How the target invoice's by-id read answers, per case. */ +let targetAnswer: (id: string) => Promise = async () => null; +const host = { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async (resource: string, id: string) => (resource === 'invoice' ? targetAnswer(id) : 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, USER_DEF, INVOICE_DEF], dashboards: [], reports: [], + pages: [SERVED_PAGE], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem: vi.fn(async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null)), + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; +const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; +/** A console whose loaded bundle localizes the platform's option label, as `/api/v1/i18n` serves it. */ +const ZH: I18nConfig = { + defaultLanguage: 'zh-CN', + detectBrowserLanguage: false, + resources: { + 'zh-CN': { + platform: { + objects: {}, + fieldOptions: { [APPROVAL_REQUEST_OBJECT]: { cancel_reason: { record_deleted: '关联记录已删除' } } }, + }, + }, + }, +}; + +/** `DefaultAppContent`'s generic record route: the view over `useAdapter()`. */ +function GenericRecordRoute() { + return {}} />; +} + +function openServedPage(row: Record, i18n: I18nConfig = EN) { + served = { + id: REQ, process_name: 'invoice_approval', object_name: 'invoice', current_step: 'manager_review', + updated_at: '2026-10-02T09:00:00Z', viewer: { can_act: false, is_submitter: false, can_override: false }, + pending_approvers: [], ...row, + }; + return render( + + + + + + + + } /> + + + + + + + , + ); +} + +/** Wait for the page, then for the target card to settle out of `loading`. */ +async function settledCard(container: HTMLElement): Promise { + await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 }); + await waitFor(() => { + const card = container.querySelector('[data-record-preview]'); + expect(card).not.toBeNull(); + expect(card?.getAttribute('data-record-preview')).not.toBe('loading'); + }, { timeout: 8000 }); + return container.querySelector('[data-record-preview]') as HTMLElement; +} + +/** The app-scoped record path of the target. */ +const recordPath = (id: string) => `/apps/${APP}/invoice/record/${encodeURIComponent(id)}`; + +/** Every anchor on the page whose href resolves to `path`, query aside. */ +const linksTo = (path: string) => + Array.from(document.body.querySelectorAll('a[href]')).filter( + (a) => new URL(a.getAttribute('href') as string, 'http://test.local').pathname === path, + ); + +/** The Details tab the reference sits in. */ +const detailsTab = (el: Element) => el.closest('[role="tabpanel"]') as HTMLElement | null; + +/** Invoice by-id reads the page made. */ +const targetReads = () => host.findOne.mock.calls.filter(([resource]) => resource === 'invoice'); + +const DELETED = APPROVAL_CANCEL_REASON_LABELS.record_deleted; +const UNRESOLVABLE = 'This record cannot be opened'; + +afterEach(() => { + cleanup(); + host.findOne.mockClear(); + transport.mockClear(); +}); + +describe('the served request page draws the four reference states the sheet draws (objectui#12489)', () => { + it('the served page parses as a spec page', () => { + expect(PageSchema.safeParse(SERVED_PAGE).success).toBe(true); + }); + + it('readable (sheet: recordLink): one link to the record path, inside the card, named by its title', async () => { + const id = 'Rd4bl3Rec12489QQ'; + targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 }); + const { container } = openServedPage({ record_id: id, status: 'pending', record_title: TARGET_TITLE }); + const card = await settledCard(container); + + expect(card.getAttribute('data-record-preview')).toBe('readable'); + const links = linksTo(recordPath(id)); + expect(links).toHaveLength(1); + expect(card.contains(links[0])).toBe(true); + expect(links[0].textContent).toContain(TARGET_TITLE); + expect(container.querySelector('[data-record-pointer="deleted"]')).toBeNull(); + }); + + it('refused (sheet: recordLinkRefused-11878): the target read rejects 403, and no link to it anywhere', async () => { + const id = 'R3fus3dRec12489Q'; + targetAnswer = async () => { throw httpError(403, 'PERMISSION_DENIED'); }; + const { container } = openServedPage({ record_id: id, status: 'pending', record_title: 'INV-2020' }); + const card = await settledCard(container); + + expect(card.getAttribute('data-record-preview')).toBe('unreadable'); + expect(card.querySelectorAll('a[href]')).toHaveLength(0); + expect(linksTo(recordPath(id))).toHaveLength(0); + expect(container.textContent).not.toContain(DELETED); + }); + + it('deleted (sheet: tombstone): the platform-asserted deletion draws the option label, reads nothing, links nothing', async () => { + const id = 'D3l3t3dRec12489Q'; + targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 }); + const { container } = openServedPage({ + record_id: id, status: 'cancelled', cancel_reason: 'record_deleted', record_title: 'INV-3030', + }); + await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 }); + const tombstone = await waitFor(() => { + const found = container.querySelector('[data-record-pointer="deleted"]'); + expect(found).not.toBeNull(); + return found as HTMLElement; + }, { timeout: 8000 }); + + expect(tombstone.textContent).toBe(DELETED); + expect(container.querySelector('[data-record-preview]')).toBeNull(); + expect(targetReads()).toHaveLength(0); + expect(linksTo(recordPath(id))).toHaveLength(0); + expect(tombstone.querySelectorAll('a[href]')).toHaveLength(0); + // The bare id never shows at the reference: not in the tombstone, not in the Details tab. + expect(tombstone.innerHTML).not.toContain(id); + const tab = detailsTab(tombstone); + expect(tab).not.toBeNull(); + expect(tab?.textContent).not.toContain(id); + }); + + it("deleted, in a console whose bundle localizes the option: the tombstone is the option's localized label", async () => { + const id = 'D3l3t3dRec12489Z'; + const { container } = openServedPage({ record_id: id, status: 'cancelled', cancel_reason: 'record_deleted' }, ZH); + await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 }); + await waitFor(() => { + expect(container.querySelector('[data-record-pointer="deleted"]')?.textContent).toBe('关联记录已删除'); + }, { timeout: 8000 }); + expect(targetReads()).toHaveLength(0); + }); + + it('unresolvable (sheet: unresolvableReference): the cause-free text, no link, no id in the card', async () => { + const id = 'Un0penRec12489QQ'; + targetAnswer = async () => null; + const { container } = openServedPage({ record_id: id, status: 'approved' }); + const card = await settledCard(container); + + expect(card.getAttribute('data-record-preview')).toBe('unreadable'); + expect(card.textContent).toBe(UNRESOLVABLE); + expect(card.querySelectorAll('a[href]')).toHaveLength(0); + expect(linksTo(recordPath(id))).toHaveLength(0); + expect(card.innerHTML).not.toContain(id); + expect(container.textContent).not.toContain(DELETED); + }); + + it('CONTROL: a cancelled row with no cancel_reason is not a deletion: the card reads the target', async () => { + const id = 'Canc3ll3dRec1248'; + targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 }); + const { container } = openServedPage({ record_id: id, status: 'cancelled' }); + const card = await settledCard(container); + + expect(card.getAttribute('data-record-preview')).toBe('readable'); + expect(container.querySelector('[data-record-pointer="deleted"]')).toBeNull(); + expect(targetReads().map(([, rid]) => rid)).toContain(id); + }); + + it('CONTROL: an unreadable target the platform did not flag gets no tombstone (a failed read is not a deletion)', async () => { + const id = 'N0tFl4ggedRec124'; + targetAnswer = async () => { throw httpError(404, 'RECORD_NOT_FOUND'); }; + const { container } = openServedPage({ record_id: id, status: 'cancelled' }); + const card = await settledCard(container); + + expect(card.getAttribute('data-record-preview')).toBe('unreadable'); + expect(container.querySelector('[data-record-pointer="deleted"]')).toBeNull(); + expect(container.textContent).not.toContain(DELETED); + }); +}); diff --git a/content/docs/guide/fields.md b/content/docs/guide/fields.md index 216482c58b..71bf8b54de 100644 --- a/content/docs/guide/fields.md +++ b/content/docs/guide/fields.md @@ -150,6 +150,16 @@ record the reader cannot read is drawn with no link, and the card never says why. Nothing is authored for this: the `referenceVia` declaration on the field is the whole contract. List cells are not a call site yet. +When the platform deletes the record a pointer names, it can say so on the row. +A pointer field that also declares `referenceDeletedWhen: { field, value }` +names a sibling `select` field and the option the platform writes there, as +`sys_approval_request.record_id` declares +`{ field: 'cancel_reason', value: 'record_deleted' }`. On a row holding that +option the details grid draws a tombstone in the option's own label (localized +when the console's bundle localizes it), reads nothing and links nothing. A +record the card cannot read on any other row still gets the cause-free text: +only the declaration means deleted. + ## Naming a registry item by name (`position-ref`, `permission-ref`) A `text` field that stores the **machine name** of one item of a metadata diff --git a/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.tombstone-12489.test.tsx b/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.tombstone-12489.test.tsx new file mode 100644 index 0000000000..624189f021 --- /dev/null +++ b/packages/app-shell/src/views/record-preview/record-pointer-card-renderer.tombstone-12489.test.tsx @@ -0,0 +1,176 @@ +/** + * 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 pointer face draws a platform-asserted deletion as a tombstone + * (objectui#12489). + * + * `RecordPreviewCard` leaves "a deletion the platform asserts" to its caller, + * and the pointer renderer is that caller. The real details grid + * (`DetailSection`) resolves the pair through `resolveRecordPointer`, which + * reads the field's `referenceDeletedWhen`; on a row that holds the declared + * option, this renderer draws the option's own label, reads nothing and links + * nothing. + * + * The object below declares `record_id` the way objectstack's `plugin-approvals` + * does: `referenceVia: 'object_name'` and + * `referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }`. + * Reads are counted on the adapter's own `findOne` spy; links under a host that + * builds record URLs, so a missing link is not just a missing builder. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { AdapterCtx, MetadataCtx, RelatedRecordActionsProvider } from '@object-ui/react'; +import { I18nProvider, type I18nConfig } from '@object-ui/i18n'; +import { DetailSection } from '@object-ui/plugin-detail'; +import type { DetailViewSection } from '@object-ui/types'; +import './record-pointer-card-renderer'; + +beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })); +afterEach(() => cleanup()); + +const HOST_OBJECT = 'sys_approval_request'; +const DELETED_LABEL = 'Related record deleted'; + +const REQUEST_SCHEMA = { + fields: { + object_name: { type: 'text', label: 'Object' }, + record_id: { + type: 'text', + label: 'Record', + referenceVia: 'object_name', + referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }, + }, + cancel_reason: { + type: 'select', + label: 'Cancel Reason', + options: [ + { value: 'record_deleted', label: DELETED_LABEL }, + { value: 'withdrawn', label: 'Withdrawn' }, + ], + }, + }, +}; + +const DEFS: Record = { + invoice: { + name: 'invoice', + label: 'Invoice', + nameField: 'subject', + highlightFields: [], + fields: { subject: { type: 'text', label: 'Subject' } }, + }, +}; + +function mount(data: Record, wrap: (ui: React.ReactElement) => React.ReactElement = (ui) => ui) { + const findOne = vi.fn(async (_objectName: string, id: string) => (id === 'inv_1' ? { id, subject: 'Laptop refresh' } : null)); + const metadata = { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem: vi.fn(async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null)), + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, + }; + const host = { + resolve: () => ({}), + recordHref: (objectName: string, recordId: string | number) => + `/apps/probe_app/${objectName}/record/${encodeURIComponent(String(recordId))}`, + openRecord: vi.fn(), + }; + const utils = render( + wrap( + + + + + + + , + ), + ); + return { ...utils, findOne, getItem: metadata.getItem }; +} + +const DELETED_ROW = { object_name: 'invoice', record_id: 'inv_1', status: 'cancelled', cancel_reason: 'record_deleted' }; + +describe('the pointer face draws the platform-asserted deletion (objectui#12489)', () => { + it("draws the tombstone in the declared option's label", () => { + const { container } = mount(DELETED_ROW); + const tombstone = container.querySelector('[data-record-pointer="deleted"]'); + expect(tombstone).not.toBeNull(); + expect(tombstone?.textContent).toBe(DELETED_LABEL); + }); + + it('reads nothing: no record read and no definition read for the target', async () => { + const { findOne, getItem } = mount(DELETED_ROW); + // Give a read that would be made a chance to land before counting. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(findOne).not.toHaveBeenCalled(); + expect(getItem).not.toHaveBeenCalled(); + }); + + it('links nothing and does not mount the card, under a host that builds record links', () => { + const { container } = mount(DELETED_ROW); + expect(container.querySelectorAll('a[href]')).toHaveLength(0); + expect(container.querySelector('[data-record-preview]')).toBeNull(); + }); + + it('does not show the record id inside the tombstone', () => { + const { container } = mount(DELETED_ROW); + const tombstone = container.querySelector('[data-record-pointer="deleted"]') as HTMLElement; + expect(tombstone.textContent).not.toContain('inv_1'); + expect(tombstone.innerHTML).not.toContain('inv_1'); + }); + + it("the label goes through the grid's option translation: a localized bundle names the tombstone", async () => { + const ZH: I18nConfig = { + defaultLanguage: 'zh-CN', + detectBrowserLanguage: false, + resources: { + 'zh-CN': { + platform: { + objects: {}, + fieldOptions: { [HOST_OBJECT]: { cancel_reason: { record_deleted: '关联记录已删除' } } }, + }, + }, + }, + }; + const { container } = mount(DELETED_ROW, (ui) => ( + {ui} + )); + await waitFor(() => + expect(container.querySelector('[data-record-pointer="deleted"]')?.textContent).toBe('关联记录已删除'), + ); + }); + + it('CONTROL: another option of the sibling draws the card and reads the target', async () => { + const { container, findOne } = mount({ ...DELETED_ROW, cancel_reason: 'withdrawn' }); + await waitFor(() => + expect(container.querySelector('[data-record-preview]')?.getAttribute('data-record-preview')).toBe('readable'), + ); + expect(container.querySelector('[data-record-pointer="deleted"]')).toBeNull(); + expect(findOne.mock.calls.map((call) => call.slice(0, 2))).toEqual([['invoice', 'inv_1']]); + }); + + it('CONTROL: an unreadable target on a row the platform did not flag stays cause-free, with no tombstone', async () => { + const { container } = mount({ object_name: 'invoice', record_id: 'inv_404', status: 'approved' }); + await waitFor(() => + expect(container.querySelector('[data-record-preview]')?.getAttribute('data-record-preview')).toBe('unreadable'), + ); + expect(container.querySelector('[data-record-pointer="deleted"]')).toBeNull(); + expect(container.textContent).not.toContain(DELETED_LABEL); + }); +}); 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 index b240aefdbb..6fd5252ab6 100644 --- 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 @@ -23,26 +23,57 @@ * 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. + * + * ## The tombstone: this renderer is the caller the card defers to (objectui#12489) + * + * The card's header leaves "a deletion the platform asserts" to its caller. + * This renderer is that caller. When the pair arrives with `deletedLabel` + * (the field's `referenceDeletedWhen` predicate held on this row, decided by + * `resolveRecordPointer`), it draws the tombstone in that label and does not + * mount the card: no read of the target, no link. The text is the declared + * option's own label, as the details grid's options spell it; this package + * authors no string for it. Every other pair draws the card exactly as before. */ +import { Card, CardContent } from '@object-ui/components'; import { registerFieldRenderer, RECORD_POINTER_CARD_TYPE, type CellRendererProps, type RecordPointer } from '@object-ui/fields'; import { useAdapter } from '@object-ui/react'; +import { Trash2 } from 'lucide-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; + const { objectName, recordId, deletedLabel } = value as Partial; + if (typeof objectName !== 'string' || typeof recordId !== 'string') return null; + return typeof deletedLabel === 'string' ? { objectName, recordId, deletedLabel } : { objectName, recordId }; +} + +/** + * The tombstone for a target the platform asserted deleted: the declared + * option's label, beside a wastebasket, in the card's frame. It reads nothing + * and links nothing, and it does not show the record id. + */ +function DeletedRecordPointer({ label }: { label: string }) { + return ( + + + + + ); } /** - * 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. + * Draws the record a pointer pair names as `RecordPreviewCard`, or the + * tombstone when the pair says the platform deleted the target. 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); + if (pointer?.deletedLabel !== undefined) return ; return ( cleanup()); + +const RECORD_ID = { + name: 'record_id', + type: 'text', + referenceVia: 'object_name', + referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }, +} as const; + +const CANCEL_REASON_OPTIONS = [ + { value: 'record_deleted', label: 'Related record deleted' }, + { value: 'withdrawn', label: 'Withdrawn' }, +]; +const optionsOf = (name: string) => (name === 'cancel_reason' ? CANCEL_REASON_OPTIONS : undefined); + +const DELETED_ROW = { record_id: 'inv_1', object_name: 'invoice', status: 'cancelled', cancel_reason: 'record_deleted' }; + +describe('resolveRecordPointer reads referenceDeletedWhen (objectui#12489)', () => { + it('a row holding the declared option comes back with that option label', () => { + expect(resolveRecordPointer(RECORD_ID, DELETED_ROW, optionsOf)).toEqual({ + objectName: 'invoice', + recordId: 'inv_1', + deletedLabel: 'Related record deleted', + }); + }); + + it('the label is the one the call site supplies, so a localized option label is the tombstone', () => { + const localized = () => [{ value: 'record_deleted', label: '关联记录已删除' }]; + expect(resolveRecordPointer(RECORD_ID, DELETED_ROW, localized)?.deletedLabel).toBe('关联记录已删除'); + }); + + it.each([ + ['another option of the sibling', { ...DELETED_ROW, cancel_reason: 'withdrawn' }], + ['no value on the sibling', { record_id: 'inv_1', object_name: 'invoice', status: 'pending' }], + ['a null sibling', { ...DELETED_ROW, cancel_reason: null }], + ])('%s: the pair as before, with no deletedLabel', (_case, row) => { + expect(resolveRecordPointer(RECORD_ID, row, optionsOf)).toEqual({ objectName: 'invoice', recordId: 'inv_1' }); + }); + + it('a field with no referenceDeletedWhen: the pair as before, whatever the row holds', () => { + const { referenceDeletedWhen: _omit, ...plain } = RECORD_ID; + expect(resolveRecordPointer(plain, DELETED_ROW, optionsOf)).toEqual({ objectName: 'invoice', recordId: 'inv_1' }); + }); + + it('no options supplied: no label to draw, so no deletedLabel', () => { + expect(resolveRecordPointer(RECORD_ID, DELETED_ROW)).toEqual({ objectName: 'invoice', recordId: 'inv_1' }); + }); + + it('⛔ never the raw option value: an option list without that value yields no deletedLabel', () => { + const others = () => [{ value: 'withdrawn', label: 'Withdrawn' }]; + const pointer = resolveRecordPointer(RECORD_ID, DELETED_ROW, others); + expect(pointer).toEqual({ objectName: 'invoice', recordId: 'inv_1' }); + }); + + it('a half-addressed row stays null even when the predicate holds', () => { + expect(resolveRecordPointer(RECORD_ID, { ...DELETED_ROW, object_name: '' }, optionsOf)).toBeNull(); + }); +}); + +describe('the default face draws the tombstone label in place of the id (objectui#12489)', () => { + it('draws deletedLabel, not the record id', () => { + const Renderer = getCellRenderer(RECORD_POINTER_CARD_TYPE); + const { container } = render( + , + ); + expect(container.textContent).toBe('Related record deleted'); + }); + + it('CONTROL: a pair with no deletedLabel still draws the record id', () => { + 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 acef3f63e2..2683639dc2 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -3175,9 +3175,11 @@ export function registerFieldRenderer(type: string, renderer: React.FC ReadonlyArray<{ readonly value?: unknown; readonly label?: unknown }> | null | undefined; + /** * Resolve the record a `referenceVia` pointer field points at on one row, or * `null` when the field is not a pointer or the row does not address a record. @@ -3210,10 +3233,35 @@ export interface RecordPointer { * - Both halves must be non-blank strings. A half-addressed row is not a * pointer this can draw, and the caller falls back to the field's ordinary * face (the stored text). + * + * ## A deletion the platform asserts: `referenceDeletedWhen` (objectui#12489) + * + * When the field also declares `referenceDeletedWhen: { field, value }` and + * this row's sibling `field` holds exactly `value`, the pair comes back with + * `deletedLabel`: the label of that declared option, looked up in + * `optionsOf(field)`. The call site supplies `optionsOf` because it holds the + * object definition and the option labels the rest of its grid draws with + * (localized ones included), so the tombstone and the sibling `select`'s own + * cell say the same thing. + * + * - Only the declared assertion marks a target deleted. A target read that + * fails or answers no record says nothing about why: the platform answers a + * deleted record and one outside the reader's row set alike, on purpose. + * - No `optionsOf`, or no option with that value among the sibling's options, + * means no label to draw, and the pair comes back without `deletedLabel`. + * The spec refuses a predicate whose value is not a declared option, so the + * second case is not reachable from spec-valid metadata. ⛔ Never the raw + * option value as the text: the tombstone is the option's own label. */ export function resolveRecordPointer( - field: { readonly name: string; readonly type?: string | null; readonly referenceVia?: string | null } | null | undefined, + field: { + readonly name: string; + readonly type?: string | null; + readonly referenceVia?: string | null; + readonly referenceDeletedWhen?: ReferenceDeletedWhen | null; + } | null | undefined, row: Readonly> | null | undefined, + optionsOf?: OptionsOf, ): RecordPointer | null { if (!field || !row) return null; if (field.type !== 'text') return null; @@ -3223,7 +3271,21 @@ export function resolveRecordPointer( const objectName = row[via]; if (typeof recordId !== 'string' || recordId.trim() === '') return null; if (typeof objectName !== 'string' || objectName.trim() === '') return null; - return { objectName, recordId }; + const deletedLabel = resolveDeletedLabel(field.referenceDeletedWhen, row, optionsOf); + return deletedLabel === null ? { objectName, recordId } : { objectName, recordId, deletedLabel }; +} + +/** The declared option's label when the row holds the deletion predicate, else `null`. */ +function resolveDeletedLabel( + when: ReferenceDeletedWhen | null | undefined, + row: Readonly>, + optionsOf: OptionsOf | undefined, +): string | null { + if (!when || typeof when.field !== 'string' || typeof when.value !== 'string') return null; + if (row[when.field] !== when.value) return null; + const option = optionsOf?.(when.field)?.find((candidate) => candidate?.value === when.value); + const label = option?.label; + return typeof label === 'string' && label.trim() !== '' ? label : null; } function isRecordPointer(value: unknown): value is RecordPointer { @@ -3234,11 +3296,14 @@ function isRecordPointer(value: unknown): value is RecordPointer { /** * 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. + * text, which is what the pointer field drew before its pair was read, or, on + * a row where the platform asserted the target deleted, the declared option's + * label (`deletedLabel`, objectui#12489) in place of the id. Any other value + * goes to the text cell unchanged. */ function RecordPointerTextCellRenderer({ value, field }: CellRendererProps): React.ReactElement { - return ; + const text = isRecordPointer(value) ? (value.deletedLabel ?? value.recordId) : value; + return ; } /** diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 856ef124f6..ddaeb43772 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -476,9 +476,25 @@ export const DetailSection: React.FC = ({ // 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. + // `referenceDeletedWhen` rides beside it (objectui#12489): the rule + // decides whether this row asserts the target deleted, and takes the + // option's label from the sibling's options translated exactly as this + // grid translates a `select` field's options above, so the tombstone + // reads as that option reads in its own cell. const pointer = resolveRecordPointer( - { name: field.name, type: enrichedField.type, referenceVia: objectDefField?.referenceVia }, + { + name: field.name, + type: enrichedField.type, + referenceVia: objectDefField?.referenceVia, + referenceDeletedWhen: objectDefField?.referenceDeletedWhen, + }, data, + (siblingName) => { + const options = objectSchema?.fields?.[siblingName]?.options; + return objectName && Array.isArray(options) && options.length > 0 + ? translateOptions(objectName, siblingName, options) + : options; + }, ); if (pointer) { const PointerRenderer = getCellRenderer(RECORD_POINTER_CARD_TYPE);