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);