diff --git a/.changeset/12029-record-preview-card.md b/.changeset/12029-record-preview-card.md new file mode 100644 index 0000000000..4c5befa0aa --- /dev/null +++ b/.changeset/12029-record-preview-card.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +--- + +A compact record-preview card for any `(object_name, record_id)` pair, kept inside the package for the approval surfaces to compose (objectui#12029, the first child of objectui#2763). + +The card resolves the target object's definition and draws the record the way the record page's header draws it: the title from the shared record-title ladder (`getRecordDisplayName`), the object's label above it, and the object's highlight fields (`highlightFields`, else the same derivation the record page uses) through the record page's own highlights strip. A field the loaded permission policy denies is not drawn and is not expanded. + +It has four states: + +- **Loading** while the definition or the record read is in flight. +- **Readable** when the read returns the record. +- **Unreadable** for every other answer: no definition, no record, a refused read or a failed one. All of them render the same card, with the Approvals Inbox's cause-free sentence (`approvalsInbox.recordUnresolvable`), so it never says whether the record was deleted or is hidden from the viewer. +- **Absent** when the pair names no record. It renders the empty-value dash and reads nothing. + +Cost: one definition read per object, shared by every card of that object through the metadata provider's cache, and one record read per card. The card re-reads in place when the record is invalidated. + +No surface uses the card yet. **Clause-②: no.** Nothing on the package entry changes: the card is not exported, registers no component type, and adds no language-pack key. diff --git a/packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx b/packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx new file mode 100644 index 0000000000..14ee4fb45b --- /dev/null +++ b/packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx @@ -0,0 +1,332 @@ +/** + * 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. + */ + +/** + * RecordPreviewCard (objectui#12029) — the four states, the title rule, the + * shared definition read, and the two isolation properties the module header + * claims (field-level security, the host's inline-edit session). + * + * States are read from the card's `data-record-preview` attribute, never from + * its copy: the unreadable pin compares whole renderings across causes instead + * of looking for (or forbidding) particular words, because "says nothing about + * why" is a property of the rendering being the SAME for every cause. + */ + +import * as React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { InlineEditProvider, MetadataCtx, notifyDataChanged, useInlineEdit } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import { MetadataProvider } from '../../providers/MetadataProvider'; +import { RecordPreviewCard, type RecordPreviewSource } from './RecordPreviewCard'; + +const OBJECT = 'invoice_12029'; + +const DEF = { + name: OBJECT, + label: 'Invoice', + // The declared title pointer. The record ALSO carries a `name`, which the + // type-aware derivation would pick — so a card that skipped the declared + // rung would title the record with the wrong field. + nameField: 'subject', + highlightFields: ['amount', 'note'], + fields: { + name: { type: 'text', label: 'Code' }, + subject: { type: 'text', label: 'Subject' }, + amount: { type: 'number', label: 'Amount' }, + note: { type: 'text', label: 'Note' }, + account: { type: 'lookup', reference: 'account', label: 'Account' }, + }, +}; + +const RECORD = { + id: 'INV-1', + name: 'INV-0001', + subject: 'Laptop refresh', + amount: 4200, + note: 'Quarterly batch', +}; + +/** A metadata context whose `getItem` serves `defs` and counts its calls. */ +function metadataStub(defs: Record) { + const getItem = vi.fn(async (type: string, name: string) => + (type === 'object' ? (defs[name] ?? null) : null)); + const value = { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, + }; + return { value, getItem }; +} + +function source(impl: (objectName: string, id: string, params?: unknown) => unknown) { + const findOne = vi.fn(async (objectName: string, id: string, params?: unknown) => impl(objectName, id, params)); + return { findOne } satisfies RecordPreviewSource; +} + +function mount(ui: React.ReactElement, defs: Record = { [OBJECT]: DEF }) { + const { value, getItem } = metadataStub(defs); + const utils = render({ui}); + return { ...utils, getItem }; +} + +const stateOf = (container: HTMLElement) => + container.querySelector('[data-record-preview]')?.getAttribute('data-record-preview'); + +async function settle(container: HTMLElement, expected: string) { + await waitFor(() => expect(stateOf(container)).toBe(expected)); +} + +describe('RecordPreviewCard — absent (objectui#12029)', () => { + it.each([ + ['no record id', OBJECT, null], + ['a blank record id', OBJECT, ' '], + ['no object name', '', 'INV-1'], + ['neither half', null, null], + ])('renders the empty-value placeholder and reads nothing for %s', async (_case, objectName, recordId) => { + const ds = source(() => RECORD); + const { container, getItem } = mount( + , + ); + expect(stateOf(container)).toBe('absent'); + expect(container.querySelector('[data-slot="empty-value"]')).not.toBeNull(); + await act(async () => {}); + expect(getItem).not.toHaveBeenCalled(); + expect(ds.findOne).not.toHaveBeenCalled(); + }); +}); + +describe('RecordPreviewCard — loading and readable (objectui#12029)', () => { + it('stays loading until the record read answers, then draws the record', async () => { + let answer: (value: unknown) => void = () => {}; + const ds = source(() => new Promise((resolve) => { answer = resolve; })); + const { container } = mount(); + + expect(stateOf(container)).toBe('loading'); + await waitFor(() => expect(ds.findOne).toHaveBeenCalledTimes(1)); + expect(stateOf(container)).toBe('loading'); + expect(container.querySelector('[role="status"][aria-busy="true"]')).not.toBeNull(); + + await act(async () => { answer({ ...RECORD }); }); + await settle(container, 'readable'); + }); + + it('titles the record by the declared nameField, heads it with the object label, and draws the declared highlight fields', async () => { + const ds = source(() => ({ ...RECORD })); + const { container } = mount(); + await settle(container, 'readable'); + + expect(screen.getByTitle('Laptop refresh')).toHaveTextContent('Laptop refresh'); + expect(screen.queryByText('INV-0001')).toBeNull(); + expect(screen.getByText('Invoice')).toBeInTheDocument(); + expect(screen.getByText('Note')).toBeInTheDocument(); + expect(screen.getByText('Quarterly batch')).toBeInTheDocument(); + + // One read, the record page's request shape: no reference field is shown, + // so nothing is expanded and no params are sent. + expect(ds.findOne).toHaveBeenCalledTimes(1); + expect(ds.findOne).toHaveBeenCalledWith(OBJECT, 'INV-1'); + }); + + it('expands a reference field only when the card shows it', async () => { + const withAccount = { ...DEF, highlightFields: ['account', 'note'] }; + const ds = source(() => ({ ...RECORD, account: { id: 'A1', name: 'Northwind' } })); + const { container } = mount( + , + { [OBJECT]: withAccount }, + ); + await settle(container, 'readable'); + expect(ds.findOne).toHaveBeenCalledWith(OBJECT, 'INV-1', { $expand: ['account'] }); + }); + + it('re-reads in place when the record is invalidated, without falling back to loading', async () => { + let note = 'Quarterly batch'; + const ds = source(() => ({ ...RECORD, note })); + const { container } = mount(); + await settle(container, 'readable'); + + note = 'Approved batch'; + act(() => { notifyDataChanged({ objectName: OBJECT, recordId: 'INV-1' }); }); + expect(stateOf(container)).toBe('readable'); + await waitFor(() => expect(screen.getByText('Approved batch')).toBeInTheDocument()); + expect(ds.findOne).toHaveBeenCalledTimes(2); + }); +}); + +describe('RecordPreviewCard — unreadable says nothing about why (objectui#12029)', () => { + const causes: Array<[string, (objectName: string, id: string) => unknown]> = [ + // The adapter answers a 404 by resolving null — the platform's answer for a + // deleted record AND for one outside this viewer's row set. + ['the read resolved with no record', () => null], + ['the read was refused', () => { + throw Object.assign(new Error('Forbidden'), { httpStatus: 403, code: 'PERMISSION_DENIED' }); + }], + ['the read failed', () => { + throw Object.assign(new Error('HTTP 500 Internal Server Error'), { httpStatus: 500 }); + }], + ]; + + it('renders one identical card for every cause, and none of the record', async () => { + const renderings: string[] = []; + for (const [, impl] of causes) { + const ds = source(impl); + const { container, unmount } = mount(); + await settle(container, 'unreadable'); + expect(ds.findOne).toHaveBeenCalledTimes(1); + renderings.push(container.innerHTML); + unmount(); + } + + // A data source that throws synchronously instead of rejecting. + const throwing: RecordPreviewSource = { + findOne: vi.fn(() => { throw new Error('boom'); }), + }; + const sync = mount(); + await settle(sync.container, 'unreadable'); + renderings.push(sync.container.innerHTML); + sync.unmount(); + + // The object's definition is unavailable: no record read is made at all. + const ds = source(() => RECORD); + const noDef = mount(, {}); + await settle(noDef.container, 'unreadable'); + expect(noDef.getItem).toHaveBeenCalledWith('object', OBJECT); + expect(ds.findOne).not.toHaveBeenCalled(); + renderings.push(noDef.container.innerHTML); + + expect(renderings).toHaveLength(5); + expect(new Set(renderings).size).toBe(1); + expect(renderings[0]).not.toContain('Laptop refresh'); + expect(renderings[0]).not.toContain('Invoice'); + }); +}); + +describe('RecordPreviewCard — one definition read for N cards (objectui#12029)', () => { + /** The real provider over an adapter that counts by-name definition reads. */ + function makeAdapter(defs: Record, itemCalls: string[]) { + return { + clearCache: vi.fn(), + getClient: () => ({ + meta: { + getItems: (type: string) => Promise.resolve({ type, items: [] }), + getItem: (type: string, name: string) => { + itemCalls.push(`${type}/${name}`); + const doc = type === 'object' ? defs[name] : undefined; + return Promise.resolve({ item: doc ? structuredClone(doc) : null }); + }, + }, + }), + } as unknown as Parameters[0]['adapter']; + } + + it('shares the definition read across cards of one object; each card reads its own record', async () => { + const OTHER = 'ticket_12029'; + const itemCalls: string[] = []; + const adapter = makeAdapter({ + [OBJECT]: DEF, + [OTHER]: { name: OTHER, label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }, + }, itemCalls); + const ds = source((objectName, id) => + (objectName === OBJECT ? { ...RECORD, id, subject: `Invoice ${id}` } : { id, title: `Ticket ${id}` })); + + render( + + + + + + , + ); + + await waitFor(() => expect(document.querySelectorAll('[data-record-preview="readable"]')).toHaveLength(4)); + expect(screen.getByText('Invoice INV-2')).toBeInTheDocument(); + expect(screen.getByText('Ticket T-1')).toBeInTheDocument(); + + expect(itemCalls.filter((c) => c === `object/${OBJECT}`)).toHaveLength(1); + expect(itemCalls.filter((c) => c === `object/${OTHER}`)).toHaveLength(1); + expect(ds.findOne).toHaveBeenCalledTimes(4); + }); +}); + +describe('RecordPreviewCard — isolation (objectui#12029)', () => { + const PERMISSIONS = [ + { + object: OBJECT, + roles: { + viewer: { + actions: ['read'], + fieldPermissions: [ + { field: 'subject', read: false }, + { field: 'note', read: false }, + { field: 'account', read: false }, + ], + }, + }, + }, + ]; + const USER_ROLES = ['viewer']; + + it('does not draw a field the loaded policy denies, and the title falls through the ladder', async () => { + const ds = source(() => ({ ...RECORD })); + const { container } = mount( + + + , + ); + await settle(container, 'readable'); + + expect(screen.getByTitle('INV-0001')).toBeInTheDocument(); + expect(screen.queryByText('Laptop refresh')).toBeNull(); + expect(screen.queryByText('Quarterly batch')).toBeNull(); + }); + + it('does not expand a reference field the loaded policy denies', async () => { + const withAccount = { ...DEF, highlightFields: ['account', 'amount'] }; + const ds = source(() => ({ ...RECORD })); + const { container } = mount( + + + , + { [OBJECT]: withAccount }, + ); + await settle(container, 'readable'); + expect(ds.findOne).toHaveBeenLastCalledWith(OBJECT, 'INV-1'); + }); + + it('keeps a host inline-edit session out of the card', async () => { + /** Puts the HOST record's session into edit mode with a draft value for `note`. */ + function HostEditing() { + const inline = useInlineEdit(); + const started = React.useRef(false); + React.useEffect(() => { + if (started.current || !inline) return; + started.current = true; + inline.enter(); + inline.setField('note', 'Host draft value'); + }, [inline]); + return inline?.editing ? : null; + } + const ds = source(() => ({ ...RECORD })); + const { container } = mount( + + + + , + ); + await settle(container, 'readable'); + // The host session really is editing, with a draft for a field the card shows. + expect(screen.getByTestId('host-editing')).toBeInTheDocument(); + + expect(screen.getByText('Quarterly batch')).toBeInTheDocument(); + expect(screen.queryByText('Host draft value')).toBeNull(); + expect(container.querySelector('input, textarea')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx b/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx new file mode 100644 index 0000000000..578a746c80 --- /dev/null +++ b/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx @@ -0,0 +1,265 @@ +/** + * 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. + */ + +/** + * RecordPreviewCard — a compact card for any `(objectName, recordId)` pair + * (objectui#12029, the A2 child of objectui#2763). + * + * Polymorphic references are everywhere in the console — an approval request's + * target, an audit row, an activity entry, a comment — and until now nothing + * rendered one as a RECORD: the Approvals Inbox shows the target as a bare + * title. This card resolves the target object's definition and draws the + * record the way the record page's own header draws it. + * + * ## Module-internal on purpose + * + * Nothing re-exports this file from the package entry and it registers no + * component type. Registering it as a type page metadata can reference is the + * job of the first card that consumes it (objectui#2763's B1), which also owns + * any spec type that registration might need. + * + * ## No rule of its own: it composes the record page's rules + * + * - **Title** — `getRecordDisplayName` from `@object-ui/core`, the one ADR-0079 + * ladder (`nameField`, then its deprecated alias, then `titleFormat`, then + * type-aware derivation, then `Record #`). The record page's H1 and + * breadcrumb read the same function. + * - **Key fields** — `deriveHighlightFields(def, detectStatusField(def))` from + * `@object-ui/plugin-detail`, the exact call the synthesized record page uses + * for its highlights strip (`buildDefaultHighlights`): the object's declared + * `highlightFields` (ADR-0085) first, else the same heuristic. + * - **Field values** — `HeaderHighlight`, the strip the record page draws those + * fields with, so a value reads here exactly as it reads on the record page. + * - **Field-level security** — the served row passes through + * `withoutDeniedFields` before the title and the fields read it, as every + * surface that builds a display value from a whole row does (objectui#10594), + * and the `$expand` list is gated the way the record page gates its own + * (objectui#7230). + * + * The strip runs inside its own read-only `InlineEditProvider`. Without it a + * card mounted on a record page would join THAT page's edit session: the + * host's draft would be laid over this record's values, and an editable chip + * would write into the host record's draft. + * + * ## The four states, and what the card may say about each + * + * - `absent` — the pair addresses nothing (no object name or no record id). + * Nothing is read; the card renders the shared empty-value placeholder. + * - `loading` — the definition or the record read has not answered yet. + * - `readable` — the record read returned the record. + * - `unreadable` — every other answer: the object's definition is unavailable, + * there is no data source, the by-id read resolved with no record, or it was + * rejected (refused or failed). ONE rendering for all of them, and it says + * nothing about why — the Approvals Inbox's rule (objectui#5211, + * objectui#8631, objectui#11878; see `recordReadability.ts` and + * `unresolvableRecordReference.ts` in `apps/console`). The platform answers a + * by-id read of a record outside the viewer's row set with the same 404 it + * gives a deleted one, on purpose, so a card that told "deleted" apart from + * "not visible" would be an existence oracle. That is also why `absent` is + * about the REFERENCE and never about the record: the card cannot learn from + * its own read that a record is gone. A deletion the platform asserts (an + * approval's `record_deleted`) is the caller's to render, not this card's. + * + * ## Cost + * + * - The definition comes from `useMetadataItem('object', name)`: a cache hit + * when the console already holds that object, otherwise one by-name read that + * `MetadataProvider` de-duplicates in flight and caches, so N cards for one + * object share a single definition read. + * - The record is one `findOne` per card, the request shape the record page + * sends: no params, or `$expand` naming only the reference fields the card + * shows. The ObjectStack adapter shares concurrent identical reads + * (objectui#11699), but N cards for N different ids are N reads — a list that + * renders many cards should weigh that before it does. + * - The card re-reads in place when the record is invalidated on the data bus + * (`useDataInvalidation`), keeping what it shows until the answer lands. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, EmptyValue, cn } from '@object-ui/components'; +import { buildExpandFields, getRecordDisplayName, withoutDeniedFields } from '@object-ui/core'; +import { deriveHighlightFields, detectStatusField, HeaderHighlight } from '@object-ui/plugin-detail'; +import { usePermissions } from '@object-ui/permissions'; +import { + InlineEditProvider, + useDataInvalidation, + useMetadataItem, + useObjectLabel, + useObjectTranslation, +} from '@object-ui/react'; +import type { QueryParams } from '@object-ui/types'; +import { Link2Off, Loader2 } from 'lucide-react'; + +/** + * The one read the card makes. Structural, so the console's adapter satisfies + * it as it is and a test can hand in a counting stub. + */ +export interface RecordPreviewSource { + findOne(objectName: string, id: string, params?: QueryParams): Promise; +} + +export interface RecordPreviewCardProps { + /** The target's object name — the `object_name` half of the pair. */ + objectName?: string | null; + /** The target's record id — the `record_id` half of the pair. */ + recordId?: string | null; + /** Where the record is read from. Pass a stable instance (the adapter). */ + dataSource?: RecordPreviewSource | null; + className?: string; +} + +/** One record read's answer, tagged with the target it answered for. */ +type ReadAnswer = + | { key: string; status: 'readable'; record: Record } + | { key: string; status: 'unreadable' }; + +function present(value: string | null | undefined): value is string { + return typeof value === 'string' && value.trim() !== ''; +} + +export function RecordPreviewCard({ objectName, recordId, dataSource, className }: RecordPreviewCardProps) { + // The pair as primitives: every effect below keys on these strings, never on + // an object rebuilt per render (AGENTS.md #10). + const targetObject = present(objectName) && present(recordId) ? objectName : null; + const targetRecord = targetObject !== null ? (recordId as string) : null; + const targetKey = targetObject !== null ? `${targetObject}::${targetRecord}` : null; + + const { item: fetchedDef, loading: defLoading } = useMetadataItem('object', targetObject); + // `useMetadataItem` answers for the PREVIOUS name during the one render + // between a name change and its effect; a definition for another object is + // treated as not answered yet rather than drawn against this record. + const def = + fetchedDef && targetObject !== null && (fetchedDef.name === undefined || fetchedDef.name === targetObject) + ? fetchedDef + : null; + const defPending = targetObject !== null && (defLoading || (!!fetchedDef && !def)); + const defReady = !!def && !defPending; + + const perms = usePermissions(); + const invalidation = useDataInvalidation(targetObject ?? undefined, targetRecord ?? undefined); + + // Memoised for cost only; nothing below depends on the identity returned. + const highlightNames = useMemo( + () => (def ? deriveHighlightFields(def, detectStatusField(def)) : []), + [def], + ); + // What the read SENDS, held as a string so the effect keys on the data it + // sends rather than on the objects that data is derived from. + const expandable = def ? buildExpandFields(def.fields, highlightNames) : []; + const expandKey = JSON.stringify( + targetObject === null || !perms?.isLoaded + ? expandable + : expandable.filter((f) => perms.checkField(targetObject, f, 'read')), + ); + + const [answer, setAnswer] = useState(null); + + useEffect(() => { + if (targetObject === null || targetRecord === null || targetKey === null) return; + if (!defReady || !dataSource?.findOne) return; + let cancelled = false; + const expand: string[] = JSON.parse(expandKey); + // The executor runs synchronously, so a data source that THROWS rather than + // rejecting lands in the same branch as a rejection. + const read = new Promise((resolve) => { + resolve( + expand.length > 0 + ? dataSource.findOne(targetObject, targetRecord, { $expand: expand }) + : dataSource.findOne(targetObject, targetRecord), + ); + }); + read.then( + (record) => { + if (cancelled) return; + setAnswer( + record && typeof record === 'object' && !Array.isArray(record) + ? { key: targetKey, status: 'readable', record: record as Record } + : { key: targetKey, status: 'unreadable' }, + ); + }, + () => { + // Deliberately not classified: every rejection is drawn as the same + // cause-free state (see the module header). + if (!cancelled) setAnswer({ key: targetKey, status: 'unreadable' }); + }, + ); + return () => { + cancelled = true; + }; + }, [targetObject, targetRecord, targetKey, defReady, expandKey, dataSource, invalidation]); + + const { t } = useObjectTranslation(); + const { objectLabel } = useObjectLabel(); + + if (targetObject === null) { + return ; + } + + const current = answer && answer.key === targetKey ? answer : null; + const loading = defPending || (!!def && !!dataSource?.findOne && current === null); + + if (loading) { + return ( + + + + + ); + } + + if (def && current?.status === 'readable') { + const row = withoutDeniedFields(current.record, perms, targetObject); + const title = getRecordDisplayName(def, row); + const typeLabel = objectLabel({ name: targetObject, label: def.label || targetObject }); + return ( + + + {typeLabel} + + {title} + + + + + ({ name, label: def.fields?.[name]?.label }))} + data={row} + objectName={targetObject} + objectSchema={def} + className="border-b-0 pb-0" + /> + + + + ); + } + + // `unreadable`: everything that is neither loading nor a returned record. + // The Approvals Inbox's cause-free sentence (objectui#8631), read from the + // same key so the two surfaces cannot say different things. + const unreadableLabel = String(t('approvalsInbox.recordUnresolvable', { + defaultValue: 'This record cannot be opened', + })); + return ( + + + + + ); +}