From 1eb4c263829d0bf9ae87fce3a2d582ff62d05d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 21:33:30 +0000 Subject: [PATCH 1/2] fix(plugin-detail): a record on a print page prints its own title field (objectui#12787) record:details hid the row the page header's H1 shows (declared name field, titleFormat match, candidate fallback). A print page refuses page:header, so a printed invoice carried no invoice number. On a print page, read through usePrintPage() as DetailView and DetailSection do, the dedupe no longer applies; off a print page nothing changes. Claude-Session: https://claude.ai/code/session_016Vv5qFjotEeCQN4PUiWwiT Co-authored-by: Claude --- .changeset/12787-print-title-field.md | 5 + ...ord-details.printTitleField-12787.test.tsx | 160 ++++++++++++++++++ .../src/renderers/record-details.tsx | 18 +- 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 .changeset/12787-print-title-field.md create mode 100644 packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx diff --git a/.changeset/12787-print-title-field.md b/.changeset/12787-print-title-field.md new file mode 100644 index 0000000000..94ac625e1d --- /dev/null +++ b/.changeset/12787-print-title-field.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-detail': patch +--- + +A record on a print page prints its own title field (objectui#12787). Off a print page, `record:details` hides the row that the page header's H1 already shows: the declared name field, the field a `titleFormat` resolves to, or the first title candidate that has a value. A print page refuses `page:header`, so nothing on paper showed the title, and a printed invoice had no invoice number. On a page that declares `print`, `record:details` now prints every field it declares, the title field included. Off a print page nothing changes. diff --git a/packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx b/packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx new file mode 100644 index 0000000000..9c93641852 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx @@ -0,0 +1,160 @@ +/** + * 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. + */ + +/** + * On a print page, `record:details` prints the record's own title field + * (objectui#12787). + * + * Off a print page the block hides the row the page header's H1 already + * shows: the declared name field, else the field a `titleFormat` collapses + * onto, else the first title candidate holding a value. That dedupe rests on + * the H1 being drawn. A page that declares `print` refuses `page:header`, so + * on paper nothing drew the title, and a printed invoice carried no invoice + * number. Under a `PrintPageProvider` (mounted by `PageRenderer` for such a + * page) the block prints every field it declares, the title field included, + * as it already does for collapsed sections (objectui#12717). + * + * Each rung of the ladder is pinned twice: on a print page the row and its + * value render, and off one (CONTROL) the row stays hidden as before. The + * header and the body agreeing off a print page is pinned elsewhere + * (`record-details.headerDedupeAgreement-9436.test.tsx`, objectui#10360). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { ActionProvider, RecordContextProvider, PrintPageProvider } from '@object-ui/react'; +import { RecordDetailsRenderer } from '../renderers/record-details'; + +/** Rung 1: the object declares its name field. */ +const declaredSchema = { + name: 'invoice', + label: 'Invoice', + nameField: 'invoice_no', + fields: { + invoice_no: { type: 'text', label: 'Invoice Number' }, + status: { type: 'text', label: 'Status' }, + total: { type: 'number', label: 'Total' }, + }, +}; +const declaredRecord = { id: 'i1', invoice_no: 'INV-1010', status: 'sent', total: 120 }; + +/** + * Rung 2: no declared pointer, and a `titleFormat` that collapses onto + * `contract_no` (a blank `name`), the objectui#10360 fixture. + */ +const templateSchema = { + name: 'contract', + label: 'Contract', + titleFormat: '{contract_no} - {name}', + fields: { + contract_no: { type: 'text', label: 'Contract No' }, + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + }, +}; +const templateRecord = { id: 'c1', contract_no: 'HT-2026-003', name: '', amount: 500 }; + +/** Rung 3: no pointer and no template; `name` is the first candidate. */ +const candidateSchema = { + name: 'ticket', + label: 'Ticket', + fields: { + name: { type: 'text', label: 'Ticket Name' }, + priority: { type: 'text', label: 'Priority' }, + owner_email: { type: 'text', label: 'Owner Email' }, + }, +}; +const candidateRecord = { id: 't1', name: 'Printer jam', priority: 'high', owner_email: 'a@b.c' }; + +/** + * Viewport pinned for the reason `record-details.dedupeEmptinessTrims-8350` + * states: on the desktop branch no fixture reaches `DetailSection`'s 4-row + * auto-hide minimum, so a row can only be missing because the dedupe dropped it. + */ +const DESKTOP_WIDTH = 1280; + +function renderBody(record: any, schema: any, print: boolean): string { + const body = ( + + + + ); + const { container } = render( + + {print ? {body} : body} + , + ); + return container.textContent ?? ''; +} + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: DESKTOP_WIDTH }); + // `useRecordEditable` probes `POST /api/v1/security/explain`; happy-dom + // would resolve that to a real socket (objectui#6640). + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true }), + text: async () => '{"allowed":true}', + })) as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('record:details on a print page prints the title field (objectui#12787)', () => { + it('declared name field: the invoice number prints', () => { + const text = renderBody(declaredRecord, declaredSchema, true); + expect(text).toContain('Invoice Number'); + expect(text).toContain('INV-1010'); + expect(text).toContain('Status'); + }); + + it('titleFormat: the row the template collapses onto prints', () => { + const text = renderBody(templateRecord, templateSchema, true); + expect(text).toContain('Contract No'); + expect(text).toContain('HT-2026-003'); + }); + + it('candidate fallback: the first candidate with a value prints', () => { + const text = renderBody(candidateRecord, candidateSchema, true); + expect(text).toContain('Ticket Name'); + expect(text).toContain('Printer jam'); + }); +}); + +describe('CONTROL: off a print page the H1 dedupe is unchanged (objectui#12787)', () => { + it('declared name field: the row stays hidden', () => { + const text = renderBody(declaredRecord, declaredSchema, false); + expect(text).not.toContain('Invoice Number'); + expect(text).not.toContain('INV-1010'); + expect(text).toContain('Status'); + }); + + it('titleFormat: the row stays hidden', () => { + const text = renderBody(templateRecord, templateSchema, false); + expect(text).not.toContain('Contract No'); + expect(text).not.toContain('HT-2026-003'); + expect(text).toContain('Amount'); + }); + + it('candidate fallback: the row stays hidden', () => { + const text = renderBody(candidateRecord, candidateSchema, false); + expect(text).not.toContain('Ticket Name'); + expect(text).not.toContain('Printer jam'); + expect(text).toContain('Priority'); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index c0e03d4cdb..1d12c88d93 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -13,7 +13,7 @@ */ import React from 'react'; -import { useRecordContext, useHighlightFieldNames, useSafeFieldLabel, usePredicateScope } from '@object-ui/react'; +import { useRecordContext, useHighlightFieldNames, useSafeFieldLabel, usePredicateScope, usePrintPage } from '@object-ui/react'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { RecordDetailsComponentProps } from '@object-ui/types'; @@ -323,6 +323,12 @@ const RecordDetailsBody: React.FC = ({ // instance via HighlightFieldsContext (used to dedupe them out of the grid). const liveHighlightNames = useHighlightFieldNames(); + // objectui#12787: on a page that declares `print` the H1 title dedupe below + // does not apply. Read the way `DetailView` and `DetailSection` read the + // same context, here with the other hooks because the returns below are + // conditional. + const isPrintPage = usePrintPage() !== null; + // Inline-edit save + OCC now live in the record-level // (objectui#2407 P1): it commits the whole draft in ONE atomic // `dataSource.update(..., { ifMatch: data.updated_at })` and reuses @@ -749,8 +755,16 @@ const RecordDetailsBody: React.FC = ({ // The declared pointer is read through core's one exported spelling, // `declaredNameField`, exactly as `PageHeaderRenderer` reads it: the same // expression on both halves, never a re-typed `??` chain. + // + // ⭐ On a print page none of this ladder runs (objectui#12787): the page + // refuses `page:header`, so no H1 draws the title and there is nothing to + // deduplicate against. `record:details` prints every field it declares, the + // title field included. Pinned in + // `__tests__/record-details.printTitleField-12787.test.tsx`. const declaredTitleField = declaredNameField(objSchema); - if (recordDisplayValueAt(data, declaredTitleField) !== undefined) { + if (isPrintPage) { + // Nothing hidden for the H1; `hideFields` and the highlights still apply. + } else if (recordDisplayValueAt(data, declaredTitleField) !== undefined) { hideFieldNames.add(declaredTitleField); } else { const titleFormat = objSchema?.titleFormat; From 05aab4292e3566704b3bd7b8a015980e0b51aca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 21:46:28 +0000 Subject: [PATCH 2/2] test(plugin-detail): type the print title-field fixture helper (objectui#12787) Claude-Session: https://claude.ai/code/session_016Vv5qFjotEeCQN4PUiWwiT Co-authored-by: Claude --- .../__tests__/record-details.printTitleField-12787.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx b/packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx index 9c93641852..5d3c5cff39 100644 --- a/packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx +++ b/packages/plugin-detail/src/__tests__/record-details.printTitleField-12787.test.tsx @@ -79,7 +79,11 @@ const candidateRecord = { id: 't1', name: 'Printer jam', priority: 'high', owner */ const DESKTOP_WIDTH = 1280; -function renderBody(record: any, schema: any, print: boolean): string { +function renderBody( + record: { id: string } & Record, + schema: { name: string; fields: Record }, + print: boolean, +): string { const body = (