diff --git a/.changeset/tidy-pens-invite.md b/.changeset/tidy-pens-invite.md new file mode 100644 index 000000000..2f731b565 --- /dev/null +++ b/.changeset/tidy-pens-invite.md @@ -0,0 +1,28 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-report': patch +'@object-ui/i18n': patch +--- + +fix(print): `window.print()` produces a usable page, and the Print buttons say what they do + +The list, report and dashboard Print controls were bare `window.print()` calls with no +print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail, +toasts — with the data table clipped to a single viewport. With no label to the contrary +they were being accepted against "export to PDF" requirements, which they have never been. + +- `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell + chrome, prints the active content area full-width, releases the viewport-height flex chain + so long tables paginate instead of clipping, repeats table headers on every sheet, and + neutralises dark mode (which otherwise prints white-on-white). One sheet serves list, + report and dashboard. +- The list and report Print buttons carry a tooltip and accessible name stating that they + open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`, + translated in all ten locale packs). +- The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" — + it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by + `dashboardActions.printDialogOpening`). + +No control was removed and no headless detection was added. A real print/PDF primitive +remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED). diff --git a/packages/app-shell/src/__tests__/print-stylesheet-4462.test.ts b/packages/app-shell/src/__tests__/print-stylesheet-4462.test.ts new file mode 100644 index 000000000..d1b48da8d --- /dev/null +++ b/packages/app-shell/src/__tests__/print-stylesheet-4462.test.ts @@ -0,0 +1,241 @@ +/** + * 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#4462 — `window.print()` must produce a usable page, and the Print + * controls must say what they do. + * + * ## The defect + * + * Three surfaces call a bare `window.print()` (`plugin-list/src/ListView.tsx`, + * `plugin-report/src/ReportViewer.tsx`, `app-shell/src/views/DashboardView.tsx`). + * With no print stylesheet the browser printed the entire console — sidebar, + * top bar, chat rail, toasts — with the data table clipped to one viewport, + * because the shell is a viewport-height flex chain. The reporting project was + * accepting the control against an "export to PDF" requirement; the real + * print/PDF primitive was closed NOT_PLANNED as objectstack#1301. + * + * ## ⚠ WHAT THIS FILE CANNOT ASSERT — read before adding a case here + * + * **No assertion in this repo's vitest suites can observe print RENDERING.** + * `@media print` never applies under happy-dom or jsdom: neither implements a + * layout engine or media-type emulation, so `window.matchMedia('print')` is a + * stub and `getComputedStyle()` returns the screen cascade whatever the sheet + * says. Consequently the following are NOT pinned anywhere and must not be + * claimed to be: + * + * - that the sidebar/top bar are actually absent from the printed output; + * - that a long table paginates rather than clipping; + * - that a rule still MATCHES the live DOM. A selector here is a string; if + * `AppShell` (packages/layout) or the shadcn sidebar primitive changes its + * markup, the rule silently stops matching and this file stays green. + * + * What IS pinned is the structural half: the sheet ships, the `@media print` + * block exists, and each load-bearing rule is present in it — so DELETING a + * rule reds. That, plus the two call-site pins below, is the whole mechanical + * claim. Real print rendering is verified once, by hand, through a browser's + * print preview (Playwright `page.emulateMedia({ media: 'print' })`). + * + * ## Direction, predicted before running (#4118) + * + * Every case here fails on `origin/main`: the `@media print` block does not + * exist there at all, and `DashboardView` still reads the + * `dashboardActions.pdfPreparing` key. Removing any single selector from the + * sheet reds exactly the case naming it, and no other. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// packages/app-shell/src/__tests__ -> packages/app-shell/src +const appShellSrc = path.resolve(here, '..'); +// packages/app-shell/src/__tests__ -> repo root +const repoRoot = path.resolve(here, '../../../..'); + +const STYLESHEET = path.join(appShellSrc, 'styles.css'); + +/** + * Return the body of the first `@media print { … }` block, brace-balanced. + * + * A regex cannot do this: the block nests `@page { … }` and a dozen rule + * bodies, so `/@media print\{[^}]*\}/` would stop at the first inner `}` and + * silently "find" a block containing almost nothing — which is exactly the + * shape that makes every `toContain` below pass for the wrong reason. + */ +function printBlock(css: string): string { + const at = css.indexOf('@media print'); + if (at === -1) return ''; + const open = css.indexOf('{', at); + if (open === -1) return ''; + let depth = 0; + for (let i = open; i < css.length; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') { + depth--; + if (depth === 0) return css.slice(open + 1, i); + } + } + return ''; +} + +const css = readFileSync(STYLESHEET, 'utf8'); +const block = printBlock(css); + +/** + * The console chrome that must be hidden on paper, each with the markup it + * targets. Listed one-per-case so a failure names the piece of chrome that + * came back, not "the selector list changed". + */ +const HIDDEN_CHROME: ReadonlyArray = [ + ['.group\\/sidebar-wrapper > header', "AppShell's full-width top bar"], + ['[data-collapsible][data-side][data-state]', 'the shadcn Sidebar root (incl. its layout gap)'], + ["[data-sidebar='sidebar']", 'the sidebar panel / mobile sheet'], + ["[data-sidebar='rail']", 'the sidebar resize rail'], + ["[data-sidebar='trigger']", 'the sidebar toggle button'], + ["[data-testid='chat-dock-panel']", 'the docked chat rail'], + ["[data-testid='chat-dock-mobile-sheet']", 'the chat bottom sheet'], + ["[data-testid='console-chatbot-fab']", 'the floating chat launcher'], + ["[data-testid='draft-preview-bar']", 'the draft-preview bar'], + ["[data-testid='unpublished-app-bar']", 'the unpublished-app bar'], + ['[data-sonner-toaster]', 'toasts'], + ['[data-radix-popper-content-wrapper]', 'open popovers / dropdowns / menus'], + ["[role='tooltip']", 'tooltips'], + ['[data-print-hide]', 'in-content toolbars that opt out via the shared marker'], +]; + +describe('objectui#4462 — the shared print stylesheet', () => { + it('ships an @media print block in the app-shell styles entry', () => { + // Non-vacuity for every `toContain` below: without this, an emptied or + // renamed stylesheet would make each of them assert over `''`. + expect(css).toContain('@media print'); + expect(block.length).toBeGreaterThan(400); + }); + + it('is the entry host apps import, so the rules reach the console chrome', () => { + // The sheet is only worth anything where it is actually pulled in. The + // console is the surface #4462 was reported against. + const consoleCss = readFileSync( + path.join(repoRoot, 'apps/console/src/index.css'), + 'utf8', + ); + expect(consoleCss).toContain("@import '@object-ui/app-shell/styles.css';"); + + const pkg = JSON.parse( + readFileSync(path.join(repoRoot, 'packages/app-shell/package.json'), 'utf8'), + ); + expect(pkg.exports['./styles.css']).toBe('./src/styles.css'); + }); + + describe('hides the console chrome', () => { + for (const [selector, what] of HIDDEN_CHROME) { + it(`hides ${what} — ${selector}`, () => { + expect(block).toContain(selector); + }); + } + + it('groups them under a single `display: none !important` declaration', () => { + // The selectors above are only load-bearing if something hides them. + // Anchored on the LAST selector of the group so a rule appended after + // `[data-print-hide]` cannot silently take the declaration with it. + expect(block).toMatch( + /\[data-print-hide\]\s*\{\s*display:\s*none\s*!important;\s*\}/, + ); + }); + + it('does NOT hide modal dialogs — an open record overlay is the content being printed', () => { + expect(block).not.toContain("[role='dialog']"); + expect(block).not.toContain('[role="dialog"]'); + }); + }); + + describe('prints the active content area full-width and unclipped', () => { + it('releases the viewport-height chain on html/body', () => { + expect(block).toMatch(/html,\s*body\s*\{[^}]*height:\s*auto\s*!important;/); + expect(block).toMatch(/html,\s*body\s*\{[^}]*overflow:\s*visible\s*!important;/); + }); + + it('releases it on the SidebarProvider wrapper', () => { + expect(block).toMatch( + /\.group\\\/sidebar-wrapper\s*\{[^}]*overflow:\s*visible\s*!important;/, + ); + }); + + it('gives
full width, auto height and visible overflow', () => { + const mainRule = /(^|\n)\s*main\s*\{([^}]*)\}/.exec(block); + expect(mainRule).not.toBeNull(); + const body = mainRule![2]; + expect(body).toMatch(/width:\s*100%\s*!important;/); + expect(body).toMatch(/height:\s*auto\s*!important;/); + expect(body).toMatch(/max-height:\s*none\s*!important;/); + expect(body).toMatch(/overflow:\s*visible\s*!important;/); + }); + + it('un-clips every bounded box inside the content, by Tailwind class substring', () => { + // This is what reaches ListView's `overflow-hidden` root, its + // `flex-1 min-h-0 overflow-hidden` view container and the grid's + // `overflow-auto` scroller without any plugin-local print rule. AGENTS.md + // §2 bans inline `style={{}}` and CSS modules, so the constraint is + // always a CLASS and always reachable this way. + expect(block).toMatch( + /main \[class\*='overflow-'\]\s*\{\s*overflow:\s*visible\s*!important;\s*\}/, + ); + expect(block).toMatch( + /main \[class\*='max-h-'\]\s*\{\s*max-height:\s*none\s*!important;\s*\}/, + ); + expect(block).toContain("main [class*='h-full']"); + expect(block).toContain("main [class*='h-screen']"); + expect(block).toContain("main [class*='h-svh']"); + }); + }); + + describe('paginates tables instead of clipping them', () => { + it('repeats the header row on every sheet', () => { + expect(block).toMatch(/main thead\s*\{\s*display:\s*table-header-group;\s*\}/); + expect(block).toMatch(/main tfoot\s*\{\s*display:\s*table-footer-group;\s*\}/); + }); + + it('keeps a row from being split across a page break', () => { + expect(block).toMatch(/main tr,\s*\n?\s*main img\s*\{\s*break-inside:\s*avoid;\s*\}/); + }); + + it('sets a page margin', () => { + expect(block).toMatch(/@page\s*\{\s*margin:\s*\d+mm;\s*\}/); + }); + }); + + it('neutralises dark mode, which otherwise prints white-on-white', () => { + // Browsers drop background fills by default but keep foreground colours, + // so a dark-themed console prints light text onto white paper. + expect(block).toMatch(/\.dark,\s*\n?\s*\.dark \*\s*\{/); + }); +}); + +describe('objectui#4462 — the dashboard print action stops claiming a PDF export', () => { + const dashboardView = readFileSync( + path.join(appShellSrc, 'views/DashboardView.tsx'), + 'utf8', + ); + + it('announces the browser print dialog, not a PDF export', () => { + expect(dashboardView).toContain("t('dashboardActions.printDialogOpening')"); + }); + + it('no longer reads the retired `pdfPreparing` key', () => { + // The key is gone from all ten packs; a leftover reader would render the + // raw key string in the toast. + expect(dashboardView).not.toContain('pdfPreparing'); + }); + + it('still calls window.print() — the ruling forbids removal and headless gating', () => { + expect(dashboardView).toContain('window.print()'); + expect(dashboardView).not.toMatch(/headless|navigator\.webdriver/i); + }); +}); diff --git a/packages/app-shell/src/styles.css b/packages/app-shell/src/styles.css index 5723c68c6..d195d71f3 100644 --- a/packages/app-shell/src/styles.css +++ b/packages/app-shell/src/styles.css @@ -63,3 +63,171 @@ transition: transform 100ms cubic-bezier(0.4, 0, 0.2, 1); } } + +/* =========================================================================== + * Print layout (objectui#4462). + * + * ## The defect + * + * Three surfaces expose a Print control backed by a bare `window.print()` + * (`plugin-list/src/ListView.tsx`, `plugin-report/src/ReportViewer.tsx`, + * `app-shell/src/views/DashboardView.tsx`). With no print stylesheet the + * 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. + * + * ## Why it lives HERE and not in each plugin + * + * The chrome being hidden belongs to the app shell, not to any plugin — one + * mechanism serves list, report and dashboard alike, and a plugin cannot + * reach a sidebar it does not render. This file is the centralized styles + * entry host apps pull in (`apps/console/src/index.css` does exactly that). + * + * REACH, measured: `apps/console` imports this file, so the console — where + * the chrome that provoked #4462 lives — is covered. `@object-ui/components` + * and `@object-ui/runner` deliberately do NOT import it (a components→app-shell + * import is circular; runner has no app-shell dependency — both say so in + * their own `index.css` headers), so a runner-hosted app keeps the old + * behaviour until it opts in. That is a pre-existing split in this file's + * reach, not something this block introduces. + * + * ## Failure mode + * + * These are structural selectors over markup this repo owns but that lives in + * other packages. If `AppShell` (packages/layout) or the shadcn sidebar + * primitive changes its DOM, a rule silently stops matching and that piece of + * chrome comes back on paper — no error, no UI breakage on screen. The + * selector list is pinned in `src/__tests__/print-stylesheet-4462.test.ts` + * so a DELETED rule fails CI; a rule that merely stops MATCHING cannot be + * caught from a stylesheet, and `@media print` does not execute under + * happy-dom at all (that limitation is stated in the test file). + * ======================================================================== */ +@media print { + @page { + margin: 12mm; + } + + /* 1. Release the viewport-height chain. `h-svh`/`overflow-hidden` on the + * shell is what clips a long table down to one screen on paper. */ + html, + body { + height: auto !important; + min-height: 0 !important; + overflow: visible !important; + } + + /* 2. Hide the console chrome. + * + * - `.group\/sidebar-wrapper > header` is AppShell's full-width top bar + * (packages/layout/src/AppShell.tsx). Scoped as a DIRECT child of the + * SidebarProvider wrapper so the many in-content `
` elements + * (record pages, studio panels, activity timeline) still print. + * - `[data-collapsible][data-side][data-state]` is the shadcn Sidebar + * root (packages/components/src/ui/sidebar.tsx). All three attributes + * together, because that root is the only element carrying the triple — + * and it must be the element hidden, not just the visible panel: its + * first child is the layout GAP that reserves the sidebar's width. + * - the `data-testid` hooks are the shell's floating surfaces (chat rail, + * its mobile sheet, the FAB) and the two preview bars. + * - `[data-sonner-toaster]` and `[data-radix-popper-content-wrapper]` are + * transient overlays that would otherwise be stamped onto page 1. + * Modal dialogs are deliberately NOT hidden: an open record overlay is + * the content the user is looking at when they hit Print. */ + .group\/sidebar-wrapper > header, + [data-collapsible][data-side][data-state], + [data-sidebar='sidebar'], + [data-sidebar='rail'], + [data-sidebar='trigger'], + [data-testid='chat-dock-panel'], + [data-testid='chat-dock-mobile-sheet'], + [data-testid='console-chatbot-fab'], + [data-testid='draft-preview-bar'], + [data-testid='unpublished-app-bar'], + [data-sonner-toaster], + [data-radix-popper-content-wrapper], + [role='tooltip'], + [data-print-hide] { + display: none !important; + } + + /* 3. The active content area prints full-width. */ + .group\/sidebar-wrapper { + display: block !important; + height: auto !important; + min-height: 0 !important; + overflow: visible !important; + } + + main { + display: block !important; + width: 100% !important; + max-width: none !important; + height: auto !important; + max-height: none !important; + overflow: visible !important; + padding: 0 !important; + } + + /* 4. Un-clip every bounded box inside the printed content. + * + * Attribute substring matches on the class list, because that is where + * the constraint lives: AGENTS.md §2 bans inline `style={{}}` and CSS + * modules, so every `overflow-*` / `h-full` / `max-h-*` in the render + * tree is a Tailwind utility CLASS. `ListView`'s root + * (`flex flex-col h-full … overflow-hidden`), its view container + * (`flex-1 min-h-0 … overflow-hidden`) and the grid's scroller + * (`flex-1 min-h-0 overflow-auto`) are all reached this way, which is + * why plugin-list needs no print rules of its own. */ + main [class*='overflow-'] { + overflow: visible !important; + } + + main [class*='max-h-'] { + max-height: none !important; + } + + main [class*='h-full'], + main [class*='h-screen'], + main [class*='h-svh'] { + height: auto !important; + } + + /* 5. Tables paginate instead of clipping: header row repeats on every + * sheet, rows are not split across a page break. */ + main table { + width: 100% !important; + } + + main thead { + display: table-header-group; + } + + main tfoot { + display: table-footer-group; + } + + main tr, + main img { + break-inside: avoid; + } + + main h1, + main h2, + main h3 { + break-after: avoid; + } + + /* 6. Dark mode on paper is unreadable: browsers drop background fills by + * default but keep the light foreground colours, so the page prints + * white-on-white. Only fires while `.dark` is actually on . */ + .dark, + .dark * { + background-color: transparent !important; + color: #000 !important; + box-shadow: none !important; + border-color: #ccc !important; + } +} diff --git a/packages/app-shell/src/views/DashboardView.tsx b/packages/app-shell/src/views/DashboardView.tsx index 1175e0800..0806580c7 100644 --- a/packages/app-shell/src/views/DashboardView.tsx +++ b/packages/app-shell/src/views/DashboardView.tsx @@ -95,8 +95,19 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { const scriptHandlers = useMemo Promise | ActionResult>>( () => ({ + // 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 + // it is the identifier server-driven dashboard metadata declares — + // renaming it is a spec-side change, not a copy fix. + // + // The page the dialog then prints is made usable by the shared + // `@media print` sheet in `../styles.css`. export_dashboard_pdf: async () => { - toast.info(t('dashboardActions.pdfPreparing')); + toast.info(t('dashboardActions.printDialogOpening')); try { window.print(); return { success: true }; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 319986783..7d295ffd5 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -112,6 +112,7 @@ const ar = { editInStudio: "التعديل في Studio", record: "سجل", retry: "إعادة المحاولة", + printDialogHint: "يفتح مربع حوار الطباعة في المتصفح (ليس تصديرًا إلى PDF)", }, actions: { decisionOutput: { @@ -2468,7 +2469,7 @@ const ar = { deleteFailed: "تعذر حذف العرض", }, dashboardActions: { - pdfPreparing: "جارٍ تحضير تصدير PDF…", + printDialogOpening: "جارٍ فتح مربع حوار الطباعة في المتصفح (ليس تصديرًا إلى PDF)", exportFailed: "فشل التصدير: {{message}}", forecastSoon: "عرض التوقعات قادم قريباً", }, diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 13f18cd3b..075b36b2e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -108,6 +108,7 @@ const de = { editInStudio: "Im Studio bearbeiten", record: "Datensatz", retry: "Erneut versuchen", + printDialogHint: "Öffnet den Druckdialog Ihres Browsers (kein PDF-Export)", }, actions: { decisionOutput: { @@ -2461,7 +2462,7 @@ const de = { deleteFailed: "Ansicht konnte nicht gelöscht werden", }, dashboardActions: { - pdfPreparing: "PDF-Export wird vorbereitet…", + printDialogOpening: "Druckdialog Ihres Browsers wird geöffnet (kein PDF-Export)", exportFailed: "Export fehlgeschlagen: {{message}}", forecastSoon: "Prognoseansicht kommt bald", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 0d3a60f3e..324e6c9e4 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -124,6 +124,7 @@ const en = { editInStudio: 'Edit in studio', record: 'Record', retry: 'Retry', + printDialogHint: 'Opens your browser’s print dialog (not a PDF export)', }, actions: { decisionOutput: { @@ -2715,7 +2716,7 @@ const en = { deleteFailed: 'Failed to delete view', }, dashboardActions: { - pdfPreparing: 'Preparing PDF export…', + printDialogOpening: 'Opening your browser’s print dialog (not a PDF export)', exportFailed: 'Export failed: {{message}}', forecastSoon: 'Forecast view coming soon', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8039bfd4d..a0a5439b2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -107,6 +107,7 @@ const es = { editInStudio: "Editar en Studio", record: "Registro", retry: "Reintentar", + printDialogHint: "Abre el cuadro de diálogo de impresión de tu navegador (no es una exportación a PDF)", }, actions: { decisionOutput: { @@ -2465,7 +2466,7 @@ const es = { deleteFailed: "No se pudo eliminar la vista", }, dashboardActions: { - pdfPreparing: "Preparando exportación PDF…", + printDialogOpening: "Abriendo el cuadro de diálogo de impresión de tu navegador (no es una exportación a PDF)", exportFailed: "Error al exportar: {{message}}", forecastSoon: "La vista de pronóstico llegará pronto", }, diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 7ea6cc0e2..78a3a4939 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -108,6 +108,7 @@ const fr = { editInStudio: "Modifier dans Studio", record: "Enregistrement", retry: "Réessayer", + printDialogHint: "Ouvre la boîte de dialogue d'impression de votre navigateur (ce n'est pas un export PDF)", }, actions: { decisionOutput: { @@ -2463,7 +2464,7 @@ const fr = { deleteFailed: "Impossible de supprimer la vue", }, dashboardActions: { - pdfPreparing: "Préparation de l'export PDF…", + printDialogOpening: "Ouverture de la boîte de dialogue d'impression de votre navigateur (ce n'est pas un export PDF)", exportFailed: "Échec de l'export : {{message}}", forecastSoon: "La vue de prévision arrive bientôt", }, diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index fd0ec6b30..025e1c895 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -108,6 +108,7 @@ const ja = { editInStudio: "Studio で編集", record: "レコード", retry: "再試行", + printDialogHint: "ブラウザーの印刷ダイアログを開きます(PDF エクスポートではありません)", }, actions: { decisionOutput: { @@ -2461,7 +2462,7 @@ const ja = { deleteFailed: "ビューの削除に失敗しました", }, dashboardActions: { - pdfPreparing: "PDFエクスポートを準備中…", + printDialogOpening: "ブラウザーの印刷ダイアログを開いています(PDF エクスポートではありません)", exportFailed: "エクスポートに失敗しました:{{message}}", forecastSoon: "予測ビューは近日公開予定", }, diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 2b531f364..a8fd36f0d 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -108,6 +108,7 @@ const ko = { editInStudio: "Studio에서 편집", record: "레코드", retry: "다시 시도", + printDialogHint: "브라우저의 인쇄 대화 상자를 엽니다(PDF 내보내기가 아닙니다)", }, actions: { decisionOutput: { @@ -2460,7 +2461,7 @@ const ko = { deleteFailed: "보기 삭제 실패", }, dashboardActions: { - pdfPreparing: "PDF 내보내기 준비 중…", + printDialogOpening: "브라우저의 인쇄 대화 상자를 여는 중입니다(PDF 내보내기가 아닙니다)", exportFailed: "내보내기 실패: {{message}}", forecastSoon: "예측 보기가 곧 출시됩니다", }, diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 2693426a6..89d149f98 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -107,6 +107,7 @@ const pt = { editInStudio: "Editar no Studio", record: "Registro", retry: "Tentar novamente", + printDialogHint: "Abre a caixa de diálogo de impressão do navegador (não é uma exportação para PDF)", }, actions: { decisionOutput: { @@ -2460,7 +2461,7 @@ const pt = { deleteFailed: "Não foi possível excluir a exibição", }, dashboardActions: { - pdfPreparing: "Preparando exportação PDF…", + printDialogOpening: "Abrindo a caixa de diálogo de impressão do navegador (não é uma exportação para PDF)", exportFailed: "Falha na exportação: {{message}}", forecastSoon: "A exibição de previsão está chegando em breve", }, diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index cee831bb1..b0f77d08c 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -114,6 +114,7 @@ const ru = { editInStudio: "Редактировать в Studio", record: "Запись", retry: "Повторить", + printDialogHint: "Открывает диалог печати браузера (это не экспорт в PDF)", }, actions: { decisionOutput: { @@ -2472,7 +2473,7 @@ const ru = { deleteFailed: "Не удалось удалить представление", }, dashboardActions: { - pdfPreparing: "Подготовка экспорта PDF…", + printDialogOpening: "Открывается диалог печати браузера (это не экспорт в PDF)", exportFailed: "Ошибка экспорта: {{message}}", forecastSoon: "Прогнозное представление скоро будет", }, diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 36f07472a..3534c1468 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -115,6 +115,7 @@ const zh = { editInStudio: '在 Studio 中编辑', record: '记录', retry: '重试', + printDialogHint: '打开浏览器打印对话框(不是导出 PDF)', }, actions: { decisionOutput: { @@ -2604,7 +2605,7 @@ const zh = { deleteFailed: '删除视图失败', }, dashboardActions: { - pdfPreparing: '正在准备 PDF 导出…', + printDialogOpening: '正在打开浏览器打印对话框(不是导出 PDF)', exportFailed: '导出失败:{{message}}', forecastSoon: '预测视图即将上线', }, diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 1bdd3710b..a82790143 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -525,6 +525,13 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.allRecords': 'All Records', 'list.share': 'Share', 'list.print': 'Print', + // objectui#4462 — the Print button's tooltip/aria sentence. Borrowed from + // `common.*` (like the `table.*`/`grid.*`/`detail.*` rows here) because the + // identical sentence labels the report viewer's Print button and the + // dashboard's print action: one control semantic, one translation. Byte- + // identical to `en.common.printDialogHint` — enforced by + // `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx`. + 'common.printDialogHint': 'Opens your browser’s print dialog (not a PDF export)', 'list.hideFieldsTitle': 'Hide Fields', 'table.rowsPerPage': 'Rows per page', 'grid.toolbar.densityMode': 'Density', @@ -2327,7 +2334,16 @@ export const ListView = React.forwardRef(({ )} -
+ {/* `data-print-hide`: the tool cluster is pure interaction (print, + share, export, filter, sort, density, search) and reads as clutter + on paper. The RULE lives in the shared print sheet + (`@object-ui/app-shell/styles.css`, objectui#4462) — this is only + the marker it needs, because the cluster carries no stable + selector of its own and its Tailwind class string is not + distinguishable from a content div's. The left half of the toolbar + (view tabs + active filter chips) deliberately still prints: it + says WHICH slice of the data is on the page. */} +
{/* Visualization switcher — compact dropdown (Airtable-style "List ▾"), first slot of the right tool cluster so the whole toolbar stays a single row. */} @@ -2817,19 +2833,30 @@ export const ListView = React.forwardRef(({ )} - {/* Print */} - {schema.allowPrinting && ( - - )} + {/* Print — hands off to the browser's own print dialog against the + shared `@media print` sheet in `@object-ui/app-shell/styles.css`. + It is NOT a PDF export (that primitive is objectstack#1301, + closed NOT_PLANNED), and it was being accepted against "export + PDF" requirements precisely because nothing said so + (objectui#4462). `aria-label` + `title` carry that sentence — + same two-attribute shape as the density button below. */} + {schema.allowPrinting && (() => { + const printHint = t('common.printDialogHint'); + return ( + + ); + })()} {/* --- Separator: Print/Share/Export | Search --- */} {(() => { diff --git a/packages/plugin-list/src/__tests__/ListView.printButtonSemantics.test.tsx b/packages/plugin-list/src/__tests__/ListView.printButtonSemantics.test.tsx new file mode 100644 index 000000000..9445a64ee --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.printButtonSemantics.test.tsx @@ -0,0 +1,167 @@ +/** + * 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#4462 — the list toolbar's Print button says what it does. + * + * ## The defect + * + * The button was a bare `window.print()` with a bare "Print" label and no + * tooltip. Acceptance testers at the reporting project read it as the + * product's "export to PDF" entry point; in headless browsers and several + * embedded WebViews `window.print()` is a silent no-op, so the same button + * also reads as simply broken. The real print/PDF primitive was closed + * NOT_PLANNED as objectstack#1301, so the fix is honesty, not a feature: + * the control keeps working and now states that it opens the BROWSER's print + * dialog and is not a PDF export. + * + * ## ⚠ What this file cannot assert + * + * Nothing here observes the printed PAGE. `@media print` does not apply under + * happy-dom (no layout engine, no media emulation), so the shared print + * stylesheet that makes the resulting page usable is pinned STRUCTURALLY, in + * `packages/app-shell/src/__tests__/print-stylesheet-4462.test.ts`, and that + * file states the same limitation at greater length. This file pins only what + * a DOM assertion can actually see: the button carries the sentence, through + * the i18n channel, on the accessible name and the tooltip. + * + * ## Path under test, and the one it does not cover + * + * Provider-less — no `I18nProvider` — which is `createSafeTranslation`'s + * fallback path and therefore reads `LIST_DEFAULT_TRANSLATIONS`. The + * provider-mounted path reads `en.common.printDialogHint`, and the two are + * held byte-identical by + * `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` (#4401), so + * asserting the map row here pins both without mounting `createI18n` — whose + * registration as react-i18next's module-global default survives `cleanup()` + * and would leak into the sibling cases (the reason + * `ListView.overlayTitleI18n` and `…NoProviderFallback` are two files). + * + * ## Direction, predicted before running (#4118) + * + * All four cases fail on `origin/main`: the button has no `title` and no + * `aria-label` there, and `LIST_DEFAULT_TRANSLATIONS` has no + * `common.printDialogHint` row — so the accessible name is just "Print" and + * the raw key would render if the row were dropped. + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ListView, LIST_DEFAULT_TRANSLATIONS } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const HINT_KEY = 'common.printDialogHint'; + +beforeAll(() => { + Object.defineProperty(window, 'localStorage', { + value: (() => { + let store: Record = {}; + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = v; }, + clear: () => { store = {}; }, + removeItem: (k: string) => { delete store[k]; }, + }; + })(), + configurable: true, + }); +}); + +function makeDataSource() { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as any; +} + +function renderList(schema: Partial, ds: any) { + return render( + + + , + ); +} + +describe('ListView — Print button semantics (objectui#4462)', () => { + it('carries the print-dialog sentence as its tooltip', async () => { + const ds = makeDataSource(); + renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + const btn = screen.getByTestId('print-button'); + const hint = LIST_DEFAULT_TRANSLATIONS[HINT_KEY]; + + expect(btn).toHaveAttribute('title', hint); + // Not the raw key: a missing defaults-map row degrades to the key string, + // which would satisfy a bare "has a title" assertion. + expect(btn.getAttribute('title')).not.toBe(HINT_KEY); + }); + + it('names the disambiguation on its accessible name, not just the tooltip', async () => { + // `title` alone is not exposed to every assistive technology, and it never + // reaches touch users. The accessible name is the one channel that always + // carries it. + const ds = makeDataSource(); + renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + const btn = screen.getByTestId('print-button'); + const accessibleName = btn.getAttribute('aria-label') ?? ''; + + expect(accessibleName).toContain(LIST_DEFAULT_TRANSLATIONS['list.print']); + expect(accessibleName).toContain(LIST_DEFAULT_TRANSLATIONS[HINT_KEY]); + }); + + it('says "browser" and "not a PDF export" — the two facts the issue is about', () => { + // Pins MEANING, not just presence: a tooltip reading "Print this view" + // would pass both cases above and leave the misreading exactly where it + // was. Case-insensitive substring, so wording may still be edited. + const hint = LIST_DEFAULT_TRANSLATIONS[HINT_KEY]; + expect(hint).toBeTypeOf('string'); + expect(hint.toLowerCase()).toContain('browser'); + expect(hint.toLowerCase()).toContain('print dialog'); + expect(hint.toLowerCase()).toContain('not a pdf export'); + }); + + it('still opens the browser dialog — no removal, no headless gating', async () => { + // The ruling on #4462 forbids both alternatives the issue offered as + // fallbacks. Clicking must still reach `window.print()`. + const printSpy = vi.fn(); + Object.defineProperty(window, 'print', { value: printSpy, configurable: true }); + + const ds = makeDataSource(); + renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId('print-button')); + expect(printSpy).toHaveBeenCalledTimes(1); + }); + + it('marks the tool cluster for the shared print sheet to hide', async () => { + // The rule lives in `@object-ui/app-shell/styles.css`; this is the marker + // it selects on. The left half of the toolbar (view tabs, active filter + // chips) is deliberately NOT marked — it says which slice of the data is + // on the page. + const ds = makeDataSource(); + const { container } = renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + const cluster = container.querySelector('[data-print-hide]'); + expect(cluster).not.toBeNull(); + expect(cluster).toContainElement(screen.getByTestId('print-button')); + }); +}); diff --git a/packages/plugin-report/src/ReportViewer.tsx b/packages/plugin-report/src/ReportViewer.tsx index d731cfacd..14289d3e6 100644 --- a/packages/plugin-report/src/ReportViewer.tsx +++ b/packages/plugin-report/src/ReportViewer.tsx @@ -14,6 +14,7 @@ import { Download, Printer, RefreshCw } from 'lucide-react'; import { exportReport } from './ReportExportEngine'; import { formatValue } from './formatValue'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; +import { useSafeTranslate } from '@object-ui/i18n'; // --------------------------------------------------------------------------- // Client-side grouping utility @@ -98,9 +99,11 @@ export const ReportViewer: React.FC = ({ schema, onRefresh }) showToolbar = true, allowExport = true, allowPrint = true, - loading = false + loading = false } = schema; + const tt = useSafeTranslate(); + const handleExport = (format: string) => { if (!report) { console.warn('ReportViewer: Cannot export, no report defined'); @@ -111,6 +114,16 @@ export const ReportViewer: React.FC = ({ schema, onRefresh }) ); }; + // objectui#4462 — hands off to the browser's own print dialog against the + // shared `@media print` sheet in `@object-ui/app-shell/styles.css`. It is + // NOT a PDF export (objectstack#1301, closed NOT_PLANNED); the neighbouring + // "PDF" button IS one, which is exactly why this button has to say what it + // does. `useSafeTranslate` is this package's existing i18n channel (see + // `DatasetReportRenderer.tsx`) — a per-call hook that needs no defaults + // map, so the English fallback lives at the call site and must stay + // byte-identical to `en.common.printDialogHint`. + const printHint = tt('common.printDialogHint', 'Opens your browser’s print dialog (not a PDF export)'); + const handlePrint = () => { window.print(); }; @@ -209,7 +222,11 @@ export const ReportViewer: React.FC = ({ schema, onRefresh })

{report.description}

)}
-
+ {/* `data-print-hide` marks the button cluster only — the title and + description block beside it must stay on the printed page. The + rule itself lives in the shared print sheet + (`@object-ui/app-shell/styles.css`, objectui#4462). */} +
{report.refreshInterval && ( )} {allowPrint && ( - diff --git a/packages/plugin-report/src/__tests__/ReportViewer.printButtonSemantics.test.tsx b/packages/plugin-report/src/__tests__/ReportViewer.printButtonSemantics.test.tsx new file mode 100644 index 000000000..a6ccc7614 --- /dev/null +++ b/packages/plugin-report/src/__tests__/ReportViewer.printButtonSemantics.test.tsx @@ -0,0 +1,121 @@ +/** + * 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#4462 — the report toolbar's Print button says what it does. + * + * ## Why this surface is the sharpest case of the defect + * + * `ReportViewer`'s toolbar puts Print immediately beside a **PDF** button that + * really does export a PDF (`handleExport('pdf')` → `ReportExportEngine`). + * Two adjacent controls, one of which produces a file and one of which opens + * the browser's print dialog, both previously labelled with a bare noun. The + * issue reports the predictable outcome: Print being accepted against an + * "export to PDF" requirement. objectstack#1301 closed the real print/PDF + * primitive NOT_PLANNED, so the fix is to make the two controls legible, not + * to build a third thing. + * + * ## ⚠ What this file cannot assert + * + * Nothing here observes the printed PAGE — `@media print` never applies under + * happy-dom (no layout engine, no media-type emulation). The shared stylesheet + * that makes `window.print()` produce a usable page is pinned structurally in + * `packages/app-shell/src/__tests__/print-stylesheet-4462.test.ts`, which + * states the limitation in full. This file pins the DOM-observable half only. + * + * ## The i18n channel used here, and why it is not a defaults map + * + * `plugin-report` has no `createSafeTranslation` table; its existing channel is + * `useSafeTranslate` from `@object-ui/i18n` (already used by + * `DatasetReportRenderer.tsx`), a per-call hook that takes the English default + * at the call site. So the sentence renders from the call-site fallback when no + * `I18nProvider` is mounted — which is what these cases exercise — and from + * `en.common.printDialogHint` when one is. Those two strings must stay + * byte-identical; the case below asserts that directly against the pack rather + * than trusting the duplication. + * + * ## Direction, predicted before running (#4118) + * + * Every case fails on `origin/main`: the button carries neither `title` nor + * `aria-label` there, and `common.printDialogHint` exists in no pack. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { en } from '@object-ui/i18n'; +import { ReportViewer } from '../ReportViewer'; + +const schema: any = { + type: 'report-viewer', + showToolbar: true, + allowExport: true, + allowPrint: true, + data: [], + report: { + title: 'Pipeline by stage', + description: 'Open opportunities', + fields: [], + sections: [], + showExportButtons: true, + }, +}; + +/** The button whose only text node is "Print" (the "PDF" sibling is an export). */ +function printButton(): HTMLElement { + return screen.getByRole('button', { name: /print/i }); +} + +describe('ReportViewer — Print button semantics (objectui#4462)', () => { + it('carries the print-dialog sentence as its tooltip', () => { + render(); + const btn = printButton(); + + expect(btn).toHaveAttribute('title', en.common.printDialogHint); + expect(btn.getAttribute('title')).not.toBe('common.printDialogHint'); + }); + + it('names the disambiguation on its accessible name, not just the tooltip', () => { + render(); + const accessibleName = printButton().getAttribute('aria-label') ?? ''; + + expect(accessibleName).toContain('Print'); + expect(accessibleName).toContain(en.common.printDialogHint); + }); + + it('says "browser" and "not a PDF export" — the two facts the issue is about', () => { + // Pins MEANING: a tooltip reading "Print this report" would satisfy the + // two cases above and leave the misreading untouched. + const hint = en.common.printDialogHint; + expect(hint.toLowerCase()).toContain('browser'); + expect(hint.toLowerCase()).toContain('print dialog'); + expect(hint.toLowerCase()).toContain('not a pdf export'); + }); + + it('still opens the browser dialog — no removal, no headless gating', () => { + const printSpy = vi.fn(); + Object.defineProperty(window, 'print', { value: printSpy, configurable: true }); + + render(); + fireEvent.click(printButton()); + + expect(printSpy).toHaveBeenCalledTimes(1); + }); + + it('marks the button cluster — but not the report title — for the shared print sheet', () => { + // The rule lives in `@object-ui/app-shell/styles.css`. Marking the whole + // toolbar would take the report's own title and description off the paper, + // since they share that row. + const { container } = render(); + + const cluster = container.querySelector('[data-print-hide]'); + expect(cluster).not.toBeNull(); + expect(cluster).toContainElement(printButton()); + expect(cluster).not.toContainElement(screen.getByText('Pipeline by stage')); + }); +});