Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/12787-print-title-field.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* 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: { id: string } & Record<string, unknown>,
schema: { name: string; fields: Record<string, unknown> },
print: boolean,
): string {
const body = (
<RecordContextProvider
objectName={schema.name}
recordId={record.id}
data={record}
objectSchema={schema}
>
<RecordDetailsRenderer schema={{ fields: Object.keys(schema.fields) } as never} />
</RecordContextProvider>
);
const { container } = render(
<ActionProvider>
{print ? <PrintPageProvider value={{ paperSize: 'A4' }}>{body}</PrintPageProvider> : body}
</ActionProvider>,
);
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');
});
});
18 changes: 16 additions & 2 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -323,6 +323,12 @@ const RecordDetailsBody: React.FC<RecordDetailsRendererProps> = ({
// 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 <InlineEditSaveBar>
// (objectui#2407 P1): it commits the whole draft in ONE atomic
// `dataSource.update(..., { ifMatch: data.updated_at })` and reuses
Expand Down Expand Up @@ -749,8 +755,16 @@ const RecordDetailsBody: React.FC<RecordDetailsRendererProps> = ({
// 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;
Expand Down
Loading