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
18 changes: 18 additions & 0 deletions .changeset/11958-print-page-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/react': minor
'@object-ui/components': minor
'@object-ui/plugin-form': minor
'@object-ui/plugin-detail': minor
'@object-ui/app-shell': minor
'@object-ui/types': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-report': patch
---

A page that declares `print` now prints as a document, and a record page bound to such a page offers a Print action (objectui#11958, the objectui half of the print-page ruling on objectstack#8346, M1, zero server).

- **The print stylesheet.** `PageRenderer` mounts a stylesheet built from the page's `print` declaration, only for a page that declares it. Every rule sits inside `@media print`, so the page on screen is drawn as before. `paperSize` and `orientation` set `@page { size }` (A4 and portrait when omitted). `margins` set one `@page` margin per declared side, in millimetres. `pageNumbers` prints "page / pages" in the bottom-right margin. `repeatHeader` and `repeatFooter` repeat the page's own `header` and `footer` regions on every sheet. `repeatTableHeaders` (on when omitted) repeats a table's column headings on every sheet it runs onto. `avoidBreakInside` keeps each block of a region whole on one sheet when it fits. Only the print page reaches paper: the console chrome and the record view's own panels around the page are hidden, and the page runs onto as many sheets as it needs.
- **The printable blocks print whole.** On a print page, `record:line_items` no longer cuts lines off at its row cap in silence: when the child object has more lines than the cap (500 unless `limit` is set), the panel draws the cap's lines and the shared row-ceiling note under them, naming both numbers when the server reports a total. A collapsible `record:details` section draws open, because a closed section never reaches paper. Off a print page both blocks behave as before.
- **The record page's Print action.** When the record page bound to a record declares `print`, the record view shows a Print button that opens the browser's print preview of that page.
- **New exports from `@object-ui/react`:** `PrintPageProvider`, `usePrintPage` and `PrintPageContext`. `usePrintPage()` returns the enclosing page's `print` declaration, or `null` outside a print page.
- The source comments that described PDF export as declined now say where a printable document lives: a page that declares `print`.
1 change: 1 addition & 0 deletions content/docs/guide/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dashboard-filters",
"slotted-pages",
"react-pages",
"print-pages",
"console",
"console-architecture",
"flow-designer",
Expand Down
84 changes: 84 additions & 0 deletions content/docs/guide/print-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
title: Print Pages
description: A page that declares `print` is a printable document — an invoice, a delivery order, a letter — printed through the browser's print preview.
---

# Print Pages

A printable document is an ordinary page with one more key, `print`. There is
no separate template type: the page's own blocks are the document body, and its
own `header` and `footer` regions are the letterhead and the footer
(`@objectstack/spec`, ruling B′ on objectstack#8346).

```json
{
"name": "invoice_document",
"label": "Invoice",
"type": "record",
"object": "invoice",
"print": {
"paperSize": "A4",
"orientation": "portrait",
"margins": { "top": 18, "bottom": 18, "left": 15, "right": 15 },
"repeatHeader": true,
"pageNumbers": true,
"avoidBreakInside": true
},
"regions": [
{ "name": "header", "components": [{ "type": "element:text", "properties": { "content": "ACME Ltd" } }] },
{
"name": "main",
"components": [
{ "type": "record:details" },
{
"type": "record:line_items",
"properties": { "childObject": "invoice_line", "relationshipField": "invoice", "columns": [{ "name": "qty" }], "readonly": true }
}
]
},
{ "name": "footer", "components": [{ "type": "element:text", "properties": { "content": "Thank you for your business" } }] }
]
}
```

`print` is accepted on a `kind: 'full'` page of `type: 'record'`, `'home'` or
`'app'` with its blocks in `regions`. Every block in it must come from the
spec's printable block subset (`PRINTABLE_PAGE_COMPONENT_TYPES` in
`@objectstack/spec/ui`); `os validate`, `os build` and the metadata save door
refuse any other block inside a print page.

## What each key does on paper

`PageRenderer` mounts a print stylesheet for a page that declares `print`, and
only for it. Every rule sits inside `@media print`, so the page on screen is
drawn exactly as before — the console's page is the preview.

| Key | Print CSS |
| --- | --- |
| `paperSize`, `orientation` | `@page { size }` — A4 and portrait when omitted |
| `margins` | `@page` margin, one side per key, in millimetres; a side left out keeps the console default |
| `pageNumbers` | "page / pages" in the bottom-right margin box |
| `repeatHeader`, `repeatFooter` | the page's `header` / `footer` region repeated on every sheet |
| `repeatTableHeaders` | a table's column headings repeated on every sheet it runs onto — on when omitted |
| `avoidBreakInside` | `break-inside: avoid` on each block of a region |

Only the print page reaches paper: the console chrome and the record view's own
panels around the page are hidden, and the page runs onto as many sheets as it
needs.

## Blocks print whole

- `record:line_items` loads at most its row cap (500 unless `limit` is set). On
a print page, when the child object has more lines than that, the panel draws
the cap's lines and a note under them naming the cap and, when the server
reports one, the total — a printed copy never drops lines in silence.
- A collapsible `record:details` section draws open on a print page.

A block reads whether it is on a print page with `usePrintPage()` from
`@object-ui/react`, which returns the page's `print` declaration or `null`.

## The record page's Print action

When the record page bound to a record declares `print`, the record view shows
a **Print** button that opens the browser's print preview of that page. Saving
a PDF is the browser's own "Save as PDF" destination.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* 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#11958 — every block of the spec's printable subset has a renderer
* in the console.
*
* A page that declares `print` may hold only the blocks in
* `PRINTABLE_PAGE_COMPONENT_TYPES` (`@objectstack/spec/ui`), refused at the
* authoring doors otherwise. The set is read HERE from the spec's export, and
* no second list of it is kept anywhere in this repository: the spec's own
* doc on the constant names this card's print rendering as its second reader.
* When the spec widens the set, a block the console cannot draw fails here by
* name instead of printing as an unknown-component placeholder.
*
* The packages imported below are the ones that register those blocks; the
* console mounts all of them.
*/
import { describe, it, expect } from 'vitest';
import { PRINTABLE_PAGE_COMPONENT_TYPES } from '@objectstack/spec/ui';
import { ComponentRegistry } from '@object-ui/core';
import '@object-ui/components';
import '@object-ui/layout';
import '@object-ui/plugin-detail';
import '@object-ui/plugin-form';
import '@object-ui/plugin-dashboard';

describe('the printable block subset is renderable (objectui#11958)', () => {
it('reads a non-empty set from the spec', () => {
expect(PRINTABLE_PAGE_COMPONENT_TYPES.size).toBeGreaterThan(0);
});

it.each([...PRINTABLE_PAGE_COMPONENT_TYPES])('`%s` has a registered renderer', (type) => {
expect(ComponentRegistry.getKnownTypes()).toContain(type);
});

it('CONTROL — a type no package registers is absent, so the check can fail', () => {
expect(ComponentRegistry.getKnownTypes()).not.toContain('page:print-probe-11958');
});
});
10 changes: 7 additions & 3 deletions packages/app-shell/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,13 @@
* browser printed the WHOLE console: sidebar, top bar, chat rail, toasts —
* and the data table clipped to one viewport, because the shell is a
* viewport-height flex chain (`h-svh` on the SidebarProvider, `flex-1
* min-h-0 overflow-hidden` all the way down to the grid's scroller). A real
* print/PDF primitive was closed NOT_PLANNED as objectstack#1301, so this
* sheet is the fix: make the browser's own dialog produce a usable page.
* min-h-0 overflow-hidden` all the way down to the grid's scroller). This
* sheet is the fix: make the browser's own dialog produce a usable page. It
* prints a screen as shown; a printable DOCUMENT (an invoice, a delivery
* order, a letter) is a page that declares `print` (objectstack#8346, ruling
* B′), and `PageRenderer` in `@object-ui/components` mounts that page's own
* print sheet (`@page` size and margins, running header and footer) beside
* this one.
*
* ## Why it lives HERE and not in each plugin
*
Expand Down
8 changes: 4 additions & 4 deletions packages/app-shell/src/views/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
() => ({
// objectui#4462 — this handler has always been a bare `window.print()`,
// and the toast used to announce it as "Preparing PDF export…". No PDF
// was ever produced: a real print/PDF primitive is objectstack#1301,
// closed NOT_PLANNED. That copy was the single most literal instance of
// the "export to PDF" misreading the issue reports, so it now names what
// actually happens. The action ID stays `export_dashboard_pdf` because
// was ever produced: a dashboard prints as shown, and a printable
// document is a page that declares `print` (objectstack#8346). That copy
// was the single most literal instance of the "export to PDF"
// misreading the issue reports, so it now names what actually happens. The action ID stays `export_dashboard_pdf` because
// it is the identifier server-driven dashboard metadata declares —
// renaming it is a spec-side change, not a copy fix.
//
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* 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#11958 — the record page's "print" action (card ② of the ruling of
* record on objectstack#8346, M1: "a 'print' action on the record page that
* opens the browser's print preview of the bound page").
*
* The page bound to a record is the record page `RecordDetailView` renders for
* it (`usePageAssignment`). When that page declares `print`, the view offers a
* Print action that hands off to the browser's print preview, and the page is
* drawn as a print page (`PageRenderer` stamps the scoping attribute its print
* stylesheet reads). The control is the same page without `print`: no action,
* no print page.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { render, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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(),
}),
}));
// Orthogonal chrome — this file observes the print action and the page root only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';
import { NavigationProvider } from '../context/NavigationContext';

const OBJECT = 'invoice_11958';
const REC = 'INV-1';

const objectDef = {
name: OBJECT,
label: 'Invoice',
fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Number', type: 'text' } },
};

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async () => ({ id: REC, name: 'INV-0001' })),
create: vi.fn(async (_o: string, row: unknown) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async () => objectDef,
};
}

/** The record page bound to the object: the invoice document, with or without `print`. */
function boundPage(print?: Record<string, unknown>) {
return {
name: 'invoice_document',
label: 'Invoice',
type: 'record',
object: OBJECT,
...(print ? { print } : {}),
regions: [
{ name: 'header', components: [{ type: 'text', value: 'ACME Ltd' }] },
{ name: 'main', components: [{ type: 'text', value: 'Invoice body' }] },
],
};
}

function makeMetadata(pages: unknown[]) {
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as never;
}

async function mount(page: Record<string, unknown>) {
const ds = makeDataSource();
const utils = render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata([page])}>
<NavigationProvider>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
/>
</NavigationProvider>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() => expect(ds.findOne).toHaveBeenCalled());
await waitFor(() => expect(utils.container.querySelector('[data-page-type]')).not.toBeNull());
return utils;
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
// Unrelated chrome reaches for the platform API; serve it from a double so
// happy-dom never opens a socket (objectui#6640).
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — the record page’s print action opens the browser print preview of the bound print page (objectui#11958)', () => {
it('a bound page that declares `print` is drawn as a print page, with a Print action that calls the browser print preview', async () => {
// happy-dom ships no `window.print`; the browser's is stood in for here.
const print = vi.fn();
Object.defineProperty(window, 'print', { configurable: true, writable: true, value: print });
const { container, findByTestId } = await mount(boundPage({ paperSize: 'A4', repeatHeader: true }));
expect(container.querySelector('[data-print-page]')).not.toBeNull();
const action = await findByTestId('record-print-button');
expect(print).not.toHaveBeenCalled();
fireEvent.click(action);
expect(print).toHaveBeenCalledTimes(1);
});

it('CONTROL — the same bound page without `print` offers no Print action and is no print page', async () => {
const { container, queryByTestId } = await mount(boundPage());
expect(queryByTestId('record-print-button')).toBeNull();
expect(container.querySelector('[data-print-page]')).toBeNull();
});
});
Loading
Loading