diff --git a/.changeset/12717-print-empty-fields.md b/.changeset/12717-print-empty-fields.md new file mode 100644 index 0000000000..5f18cfde16 --- /dev/null +++ b/.changeset/12717-print-empty-fields.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-detail': patch +--- + +A record details section on a print page prints every field it declares, with no "Show N empty fields" button on paper (objectui#12717). + +On screen, a section that has some empty fields hides them and offers a "Show N empty fields" toggle. That density rule is the renderer's own, not something the page declares, and on a page that declares `print` it put the toggle on paper while the empty fields stayed off it. On a print page the rule is now off: every declared field prints, an empty one as its placeholder dash, and there is no toggle. A section authored with `hideEmpty: true` whose fields are all empty still prints nothing, as it declares. Off a print page nothing changes. diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 4124dcc3a1..576f471d0f 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -184,8 +184,10 @@ export const DetailSection: React.FC = ({ // draws open: Radix unmounts a closed section's content, so a section // collapsed on screen never reaches paper, and `record:details` is in the // printable block subset because it prints every field it declares. Off a - // print page the toggle is unchanged. - const isCollapsed = usePrintPage() === null && collapsedState; + // print page the toggle is unchanged. Read once, above every early return: + // the empty-field heuristic below reads the same answer (objectui#12717). + const isPrintPage = usePrintPage() !== null; + const isCollapsed = !isPrintPage && collapsedState; const [copiedField, setCopiedField] = React.useState(null); const [visibleCount, setVisibleCount] = React.useState(undefined); const [showEmptyOverride, setShowEmptyOverride] = React.useState(false); @@ -298,11 +300,22 @@ export const DetailSection: React.FC = ({ // fields) and a lower empty ratio (≥25%) now trigger auto-hide so pages // start dense by default rather than sparse. Mobile remains the most // aggressive variant since vertical real estate is scarce. + // + // objectui#12717 — off on a page that declares `print`. The heuristic is a + // SCREEN density call nobody authored (it reads the viewport and a ratio), + // and its "Show N empty fields" toggle is a control that reached paper while + // the empty rows it hid did not. `record:details` is in the printable block + // subset because it prints every field it declares (objectui#11958), so on + // paper every declared row prints and, with nothing hidden, there is no + // toggle. ⛔ Not a `print:hidden` on the button: the rows would still be + // missing. The AUTHORED all-empty hide below is not gated: it is what the + // section declares, and it leaves no control behind. const isMobile = useIsMobile(); const AUTO_HIDE_MIN_FIELDS = isMobile ? 3 : 4; const AUTO_HIDE_RATIO = isMobile ? 0.2 : 0.25; const filledCount = section.fields.length - emptyCount; const shouldAutoHideEmpty = + !isPrintPage && !isEditing && section.fields.length >= AUTO_HIDE_MIN_FIELDS && emptyCount / section.fields.length >= AUTO_HIDE_RATIO && diff --git a/packages/plugin-detail/src/__tests__/detailSectionPrintEmptyToggle-12717.test.tsx b/packages/plugin-detail/src/__tests__/detailSectionPrintEmptyToggle-12717.test.tsx new file mode 100644 index 0000000000..057a6b0475 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/detailSectionPrintEmptyToggle-12717.test.tsx @@ -0,0 +1,92 @@ +/** + * 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. + */ + +/** + * objectui#12717 — on a page that declares `print`, a detail section prints + * every field it declares and no "Show N empty fields" toggle. + * + * The empty-field auto-hide is a screen density heuristic nobody authored. On + * paper it hid the empty rows and left its interactive toggle behind, while + * `record:details` is in the printable block subset because it prints every + * field it declares (objectui#11958). Under a `PrintPageProvider` the heuristic + * is off: every declared row draws and there is no toggle. The AUTHORED + * all-empty hide (`hideEmpty: true`) keeps doing what it declares on paper: the + * section draws nothing. Off a print page all of it is unchanged. + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { SchemaRenderer, PrintPageProvider } from '@object-ui/react'; +// Module scope: importing the package index runs the registrations under test. +import '../index'; + +/** Four declared rows, two of them empty: the heuristic fires on screen. */ +const sparseSection = { + type: 'detail-section', + title: 'Invoice Details', + fields: [ + { name: 'number', label: 'Number' }, + { name: 'customer', label: 'Customer' }, + { name: 'po_number', label: 'PO Number' }, + { name: 'notes', label: 'Notes' }, + ], +} as never; + +const SPARSE_RECORD = { number: 'INV-001', customer: 'Acme', po_number: null, notes: '' }; + +const allEmptyHiddenSection = { + type: 'detail-section', + title: 'Shipping Address', + hideEmpty: true, + fields: [ + { name: 'street', label: 'Street' }, + { name: 'city', label: 'City' }, + ], +} as never; + +const PRINT = { paperSize: 'A4' } as never; + +const toggle = () => screen.queryByRole('button', { name: /empty fields/i }); + +afterEach(() => cleanup()); + +describe('a detail section on a print page prints every declared field (objectui#12717)', () => { + it('under a print page: no empty-field toggle, and every declared row draws, empty ones included', () => { + render( + + + , + ); + expect(toggle()).toBeNull(); + expect(screen.queryByText(/empty fields/i)).toBeNull(); + for (const label of ['Number', 'Customer', 'PO Number', 'Notes']) { + expect(screen.getByText(label)).toBeTruthy(); + } + expect(screen.getAllByTitle('No value')).toHaveLength(2); + }); + + it('under a print page: an authored `hideEmpty: true` all-empty section draws nothing', () => { + const { container } = render( + + + , + ); + expect(screen.queryByText('Shipping Address')).toBeNull(); + expect(screen.queryByText('Street')).toBeNull(); + expect(toggle()).toBeNull(); + expect(container.textContent).toBe(''); + }); + + it('CONTROL — off a print page the heuristic hides the empty rows behind the toggle', () => { + render(); + expect(toggle()?.textContent).toMatch(/Show 2 empty fields/); + expect(screen.getByText('Number')).toBeTruthy(); + expect(screen.queryByText('PO Number')).toBeNull(); + expect(screen.queryByText('Notes')).toBeNull(); + }); +});