diff --git a/client/e2e/help/trip-lists.guide.ts b/client/e2e/help/trip-lists.guide.ts index d29bb432e..62e7a6f65 100644 --- a/client/e2e/help/trip-lists.guide.ts +++ b/client/e2e/help/trip-lists.guide.ts @@ -75,6 +75,10 @@ const shareOverlay = (page: Page): Locator => page.locator('div[role="presentati /** The bulk-import dialog: its own portal, with none of the shared backdrop classes. */ const importCard = (page: Page): Locator => page.getByText('Import Packing List', { exact: true }).locator('xpath=..') +/** The Export button's menu: print, Markdown and CSV. */ +const exportMenu = (page: Page): Locator => page.getByRole('menu') +/** The printable page inside the preview, a srcdoc frame of its own. */ +const printFrame = (page: Page) => page.frameLocator('iframe[title^="Packing List"]') /** The bags column. Total weight is a span two divs inside it, under the rule. */ const bagSidebar = (page: Page): Locator => page.getByText('Total weight', { exact: true }).locator('xpath=ancestor::div[3]') @@ -367,7 +371,7 @@ const SCRIPTS: Record = { }, // Clicking it would open the operating system's file chooser, which is // not part of the picture; the button is shown, not pressed. - only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT' })), + only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT/MD' })), { target: p => importCard(p).getByRole('button', { name: /^Import \d+$/ }), act: async p => { @@ -380,6 +384,39 @@ const SCRIPTS: Record = { ], cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)), }, + 'export-packing-list': { + guide: guide('export-packing-list'), + start: openLists, + steps: [ + { + target: p => p.getByRole('button', { name: 'Export', exact: true }), + act: async p => { + await p.getByRole('button', { name: 'Export', exact: true }).click() + await expect(exportMenu(p)).toBeVisible() + await settle(p) + }, + }, + // The two file entries download at once; the step shows them, it does not press them. + only(exportMenu), + { + target: p => exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }), + act: async p => { + await exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }).click() + await expect(printFrame(p).locator('h1')).toBeVisible({ timeout: 15_000 }) + // The page carries the app's own Poppins; the result picture waits for it. + await printFrame(p).locator('body').evaluate(async () => { await document.fonts.ready }) + await settle(p) + }, + }, + // Pressing it opens the operating system's print dialog, which is not part + // of the picture; the button is shown, not pressed. The result picture is the + // preview itself. + only(p => p.getByRole('button', { name: 'Print or save as PDF' })), + ], + cleanup: async p => { + await p.getByRole('button', { name: 'Close', exact: true }).click() + }, + }, 'share-packing-item': { guide: guide('share-packing-item'), start: openLists, diff --git a/client/public/help-media/apply-packing-template/result.webp b/client/public/help-media/apply-packing-template/result.webp index f3ea3ebd1..ca0a4554e 100644 Binary files a/client/public/help-media/apply-packing-template/result.webp and b/client/public/help-media/apply-packing-template/result.webp differ diff --git a/client/public/help-media/apply-packing-template/step-1.webp b/client/public/help-media/apply-packing-template/step-1.webp index 7b1437b47..e7eaf9b72 100644 Binary files a/client/public/help-media/apply-packing-template/step-1.webp and b/client/public/help-media/apply-packing-template/step-1.webp differ diff --git a/client/public/help-media/apply-packing-template/step-2.webp b/client/public/help-media/apply-packing-template/step-2.webp index 46d3b4a02..5219966cf 100644 Binary files a/client/public/help-media/apply-packing-template/step-2.webp and b/client/public/help-media/apply-packing-template/step-2.webp differ diff --git a/client/public/help-media/apply-packing-template/step-4.webp b/client/public/help-media/apply-packing-template/step-4.webp index 0de9283e7..6d508124f 100644 Binary files a/client/public/help-media/apply-packing-template/step-4.webp and b/client/public/help-media/apply-packing-template/step-4.webp differ diff --git a/client/public/help-media/check-off-packing/result.webp b/client/public/help-media/check-off-packing/result.webp index 41a2e228f..ef5d17888 100644 Binary files a/client/public/help-media/check-off-packing/result.webp and b/client/public/help-media/check-off-packing/result.webp differ diff --git a/client/public/help-media/check-off-packing/step-3.webp b/client/public/help-media/check-off-packing/step-3.webp index 111a11edf..c8ca7c4d1 100644 Binary files a/client/public/help-media/check-off-packing/step-3.webp and b/client/public/help-media/check-off-packing/step-3.webp differ diff --git a/client/public/help-media/check-off-packing/step-4.webp b/client/public/help-media/check-off-packing/step-4.webp index f1d40dd39..860c1c92f 100644 Binary files a/client/public/help-media/check-off-packing/step-4.webp and b/client/public/help-media/check-off-packing/step-4.webp differ diff --git a/client/public/help-media/check-off-packing/step-5.webp b/client/public/help-media/check-off-packing/step-5.webp index ec14d59ac..88300fbe1 100644 Binary files a/client/public/help-media/check-off-packing/step-5.webp and b/client/public/help-media/check-off-packing/step-5.webp differ diff --git a/client/public/help-media/ctx/trip-lists.webp b/client/public/help-media/ctx/trip-lists.webp index cbe18e585..e7ae237d4 100644 Binary files a/client/public/help-media/ctx/trip-lists.webp and b/client/public/help-media/ctx/trip-lists.webp differ diff --git a/client/public/help-media/export-packing-list/result.webp b/client/public/help-media/export-packing-list/result.webp new file mode 100644 index 000000000..cb3e60087 Binary files /dev/null and b/client/public/help-media/export-packing-list/result.webp differ diff --git a/client/public/help-media/export-packing-list/step-1.webp b/client/public/help-media/export-packing-list/step-1.webp new file mode 100644 index 000000000..29bd7e4a1 Binary files /dev/null and b/client/public/help-media/export-packing-list/step-1.webp differ diff --git a/client/public/help-media/export-packing-list/step-2.webp b/client/public/help-media/export-packing-list/step-2.webp new file mode 100644 index 000000000..3edeae7e9 Binary files /dev/null and b/client/public/help-media/export-packing-list/step-2.webp differ diff --git a/client/public/help-media/export-packing-list/step-3.webp b/client/public/help-media/export-packing-list/step-3.webp new file mode 100644 index 000000000..1d00fd0dc Binary files /dev/null and b/client/public/help-media/export-packing-list/step-3.webp differ diff --git a/client/public/help-media/export-packing-list/step-4.webp b/client/public/help-media/export-packing-list/step-4.webp new file mode 100644 index 000000000..a273adf7e Binary files /dev/null and b/client/public/help-media/export-packing-list/step-4.webp differ diff --git a/client/public/help-media/import-packing-list/result.webp b/client/public/help-media/import-packing-list/result.webp index 697d1226d..bc1d629c5 100644 Binary files a/client/public/help-media/import-packing-list/result.webp and b/client/public/help-media/import-packing-list/result.webp differ diff --git a/client/public/help-media/import-packing-list/step-1.webp b/client/public/help-media/import-packing-list/step-1.webp index d06d79a33..ba6959f3e 100644 Binary files a/client/public/help-media/import-packing-list/step-1.webp and b/client/public/help-media/import-packing-list/step-1.webp differ diff --git a/client/public/help-media/import-packing-list/step-2.webp b/client/public/help-media/import-packing-list/step-2.webp index ee3cf3680..a3cb7b1b8 100644 Binary files a/client/public/help-media/import-packing-list/step-2.webp and b/client/public/help-media/import-packing-list/step-2.webp differ diff --git a/client/public/help-media/import-packing-list/step-3.webp b/client/public/help-media/import-packing-list/step-3.webp index e6ca381a5..4022ff24b 100644 Binary files a/client/public/help-media/import-packing-list/step-3.webp and b/client/public/help-media/import-packing-list/step-3.webp differ diff --git a/client/public/help-media/import-packing-list/step-4.webp b/client/public/help-media/import-packing-list/step-4.webp index 49c28b06b..3e9acea6a 100644 Binary files a/client/public/help-media/import-packing-list/step-4.webp and b/client/public/help-media/import-packing-list/step-4.webp differ diff --git a/client/public/help-media/packing-bags/result.webp b/client/public/help-media/packing-bags/result.webp index 059e61460..c3cfc07d7 100644 Binary files a/client/public/help-media/packing-bags/result.webp and b/client/public/help-media/packing-bags/result.webp differ diff --git a/client/public/help-media/packing-bags/step-1.webp b/client/public/help-media/packing-bags/step-1.webp index bfd4f0abb..c7b0c6473 100644 Binary files a/client/public/help-media/packing-bags/step-1.webp and b/client/public/help-media/packing-bags/step-1.webp differ diff --git a/client/public/help-media/packing-bags/step-2.webp b/client/public/help-media/packing-bags/step-2.webp index 28df4098b..0839ee8fe 100644 Binary files a/client/public/help-media/packing-bags/step-2.webp and b/client/public/help-media/packing-bags/step-2.webp differ diff --git a/client/public/help-media/packing-bags/step-3.webp b/client/public/help-media/packing-bags/step-3.webp index 859b62f6e..90387e89e 100644 Binary files a/client/public/help-media/packing-bags/step-3.webp and b/client/public/help-media/packing-bags/step-3.webp differ diff --git a/client/public/help-media/packing-bags/step-5.webp b/client/public/help-media/packing-bags/step-5.webp index 6fb54ceb0..86deecb0b 100644 Binary files a/client/public/help-media/packing-bags/step-5.webp and b/client/public/help-media/packing-bags/step-5.webp differ diff --git a/client/public/help-media/packing-bags/step-6.webp b/client/public/help-media/packing-bags/step-6.webp index 159578154..be0e6f3d1 100644 Binary files a/client/public/help-media/packing-bags/step-6.webp and b/client/public/help-media/packing-bags/step-6.webp differ diff --git a/client/public/help-media/packing-categories/result.webp b/client/public/help-media/packing-categories/result.webp index 10370ac99..5d65ff47a 100644 Binary files a/client/public/help-media/packing-categories/result.webp and b/client/public/help-media/packing-categories/result.webp differ diff --git a/client/public/help-media/packing-categories/step-1.webp b/client/public/help-media/packing-categories/step-1.webp index 2825146d2..8bb6403c8 100644 Binary files a/client/public/help-media/packing-categories/step-1.webp and b/client/public/help-media/packing-categories/step-1.webp differ diff --git a/client/public/help-media/share-packing-item/step-1.webp b/client/public/help-media/share-packing-item/step-1.webp index 38496b5dc..547c6df31 100644 Binary files a/client/public/help-media/share-packing-item/step-1.webp and b/client/public/help-media/share-packing-item/step-1.webp differ diff --git a/client/public/help-media/share-packing-item/step-4.webp b/client/public/help-media/share-packing-item/step-4.webp index 1556afcf2..9a49ea26a 100644 Binary files a/client/public/help-media/share-packing-item/step-4.webp and b/client/public/help-media/share-packing-item/step-4.webp differ diff --git a/client/scripts/theme-lint.mjs b/client/scripts/theme-lint.mjs index adb72e0e7..fa980454e 100644 --- a/client/scripts/theme-lint.mjs +++ b/client/scripts/theme-lint.mjs @@ -27,7 +27,7 @@ if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1); const EXEMPT = [ /Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/, /MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/, - /ReservationOverlay/, /\.test\./, /\.spec\./, + /ReservationOverlay/, /packingPrintDocument/, /\.test\./, /\.spec\./, ]; const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/; diff --git a/client/src/components/Packing/PackingExportMenu.test.tsx b/client/src/components/Packing/PackingExportMenu.test.tsx new file mode 100644 index 000000000..4f25b869c --- /dev/null +++ b/client/src/components/Packing/PackingExportMenu.test.tsx @@ -0,0 +1,100 @@ +// FE-PACKEXPORTMENU-001 to FE-PACKEXPORTMENU-006 +import { beforeEach, describe, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { fireEvent, render, screen } from '../../../tests/helpers/render' +import PackingExportMenu from './PackingExportMenu' +import PackingPrintPreview from './PackingPrintPreview' +import type { PackingExport } from './usePackingExport' + +const packingExport: PackingExport = { + hasItems: true, + exportMarkdown: vi.fn(), + exportCsv: vi.fn(async () => {}), + openPrint: vi.fn(async () => {}), + printHtml: null, + printTitle: 'Packing List: Lisbon & Porto', + closePrint: vi.fn(), +} +vi.mock('./usePackingExport', () => ({ usePackingExport: () => packingExport })) + +const renderMenu = () => render() + +beforeEach(() => { + vi.clearAllMocks() + packingExport.hasItems = true + packingExport.printHtml = null +}) + +describe('PackingExportMenu', () => { + it('FE-PACKEXPORTMENU-001: renders nothing while the view has no items', () => { + packingExport.hasItems = false + const { container } = renderMenu() + expect(container).toBeEmptyDOMElement() + }) + + it('FE-PACKEXPORTMENU-002: is an icon button named Export that opens a menu of three ways out', async () => { + const user = userEvent.setup() + renderMenu() + const button = screen.getByRole('button', { name: 'Export' }) + expect(button).toHaveAttribute('title', 'Export') + expect(button).toHaveAttribute('aria-expanded', 'false') + await user.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + expect(screen.getAllByRole('menuitem').map(i => i.textContent)).toEqual([ + 'Print or save as PDF', 'Markdown checklist (.md)', 'CSV for import (.csv)', + ]) + }) + + it('FE-PACKEXPORTMENU-003: runs the chosen export and closes the menu', async () => { + const user = userEvent.setup() + renderMenu() + for (const [label, action] of [ + ['Print or save as PDF', packingExport.openPrint], + ['Markdown checklist (.md)', packingExport.exportMarkdown], + ['CSV for import (.csv)', packingExport.exportCsv], + ] as const) { + await user.click(screen.getByRole('button', { name: 'Export' })) + await user.click(screen.getByRole('menuitem', { name: label })) + expect(action).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + } + }) + + it('FE-PACKEXPORTMENU-004: closes on Escape and on a click anywhere else', async () => { + const user = userEvent.setup() + renderMenu() + await user.click(screen.getByRole('button', { name: 'Export' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Export' })) + fireEvent.mouseDown(document.body) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + + it('FE-PACKEXPORTMENU-005: keeps the preview open even if the view has been emptied meanwhile', () => { + packingExport.hasItems = false + packingExport.printHtml = '

Lisbon

' + renderMenu() + expect(screen.getByTitle('Packing List: Lisbon & Porto')).toBeInTheDocument() + }) +}) + +describe('PackingPrintPreview', () => { + it('FE-PACKEXPORTMENU-006: shows the page in a frame, prints that frame, and closes', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const { rerender } = render() + expect(screen.queryByTitle('Packing List')).not.toBeInTheDocument() + + rerender() + const frame = screen.getByTitle('Packing List') as HTMLIFrameElement + expect(frame.getAttribute('srcdoc')).toBe('List') + expect(frame.getAttribute('sandbox')).toBe('allow-same-origin allow-modals') + const print = vi.fn() + Object.defineProperty(frame, 'contentWindow', { value: { print }, configurable: true }) + await user.click(screen.getByRole('button', { name: 'Print or save as PDF' })) + expect(print).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/client/src/components/Packing/PackingExportMenu.tsx b/client/src/components/Packing/PackingExportMenu.tsx new file mode 100644 index 000000000..7822df1f2 --- /dev/null +++ b/client/src/components/Packing/PackingExportMenu.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from 'react' +import type { LucideIcon } from 'lucide-react' +import { FileSpreadsheet, FileText, Printer, Upload } from 'lucide-react' +import { useTranslation } from '../../i18n' +import PackingPrintPreview from './PackingPrintPreview' +import { usePackingExport, type PackingView } from './usePackingExport' + +interface PackingExportMenuProps { + tripId: number + view: PackingView + style: React.CSSProperties + className?: string +} + +function MenuItem({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) { + return ( + + ) +} + +/** + * The list's way out, beside Import in the Lists header (#875, #1420): print or save + * as PDF, a Markdown checklist, or CSV in the import's own format. Covers the view on + * screen and renders nothing while that view is empty. + */ +export default function PackingExportMenu({ tripId, view, style, className }: PackingExportMenuProps): React.ReactElement | null { + const { t } = useTranslation() + const packingExport = usePackingExport(tripId, view) + const [open, setOpen] = useState(false) + const menuRef = useRef(null) + + useEffect(() => { + if (!open) return + const onPointer = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', onPointer) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onPointer) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + if (!packingExport.hasItems && !packingExport.printHtml) return null + + const choose = (action: () => void | Promise) => () => { + setOpen(false) + void action() + } + + return ( + // A flex box, so the button stretches to the row height like its neighbours in the header. +
+ + {open && ( +
+ + + +
+ )} + +
+ ) +} diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 46b465052..43975e4ac 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -546,8 +546,8 @@ describe('PackingListPanel', () => { const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/); expect(textarea).toBeInTheDocument(); - // "Load CSV/TXT" button is present inside the modal - expect(screen.getByText('Load CSV/TXT')).toBeInTheDocument(); + // "Load CSV/TXT/MD" button is present inside the modal + expect(screen.getByText('Load CSV/TXT/MD')).toBeInTheDocument(); // Close by clicking backdrop (covers the onClick on the backdrop div) const modalTitle = screen.getByText('Import Packing List'); @@ -1496,7 +1496,21 @@ describe('PackingListPanel', () => { await waitFor(() => expect(createBody).toMatchObject({ name: 'New Bag' })); }); - it('FE-COMP-PACKING-069: Load CSV/TXT button clicks the hidden file input', async () => { + it('FE-COMP-PACKING-082: the import dialog explains the Markdown form, takes .md files and counts a pasted checklist (#875)', async () => { + const user = userEvent.setup(); + const { container } = render(); + await user.click(container.querySelector('svg.lucide-download')!.closest('button')!); + await screen.findByText('Import Packing List'); + + expect(screen.getByText(/A Markdown list works too/)).toBeInTheDocument(); + expect(document.querySelector('input[type="file"]')).toHaveAttribute('accept', '.csv,.txt,.md,.markdown,text/markdown'); + fireEvent.change(screen.getByPlaceholderText(/Hygiene, Toothbrush/), { + target: { value: '# Packing List\n## Clothing\n- [x] 3x Socks\n- [ ] Rain jacket (350 g)\nA note' }, + }); + expect(screen.getByRole('button', { name: 'Import 2' })).toBeInTheDocument(); + }); + + it('FE-COMP-PACKING-069: Load CSV/TXT/MD button clicks the hidden file input', async () => { const user = userEvent.setup(); const { container } = render(); @@ -1510,8 +1524,8 @@ describe('PackingListPanel', () => { expect(fileInput).toBeTruthy(); const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); - // Click the "Load CSV/TXT" button - await user.click(screen.getByText('Load CSV/TXT')); + // Click the "Load CSV/TXT/MD" button + await user.click(screen.getByText('Load CSV/TXT/MD')); expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); diff --git a/client/src/components/Packing/PackingListPanelImportModal.tsx b/client/src/components/Packing/PackingListPanelImportModal.tsx index a40e7f506..773164935 100644 --- a/client/src/components/Packing/PackingListPanelImportModal.tsx +++ b/client/src/components/Packing/PackingListPanelImportModal.tsx @@ -1,6 +1,7 @@ import { createPortal } from 'react-dom' import { FileDown } from 'lucide-react' import type { PackingState } from './usePackingListPanel' +import { PACKING_IMPORT_ACCEPT } from './packingListPanel.constants' export function BulkImportModal(S: PackingState) { const { setShowImportModal, t, importText, setImportText, csvInputRef, handleCsvFile, handleBulkImport, parseImportLines } = S @@ -16,7 +17,11 @@ export function BulkImportModal(S: PackingState) { display: 'flex', flexDirection: 'column', gap: 14, }}>
{t('packing.importTitle')}
-
{t('packing.importHint')}
+
+ {t('packing.importHint')} +
+ {t('packing.importHintMarkdown')} +
- + + +
+ )} + > + {html != null && ( +