refactor(pdf): the markdown rows and styles in a module of their own; the shared stop's markdown test beside its component

This commit is contained in:
Maurice
2026-10-24 09:27:52 +02:00
parent 7e189acd71
commit 3e3d51082e
5 changed files with 71 additions and 42 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
+4 -16
View File
@@ -24,7 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
import { onlyMyPlan } from './pdfScope'
import { createMarkdownHtml } from '../shared/markdownHtml'
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
/**
* Every day starts a new page by default. On a trip of short days that prints
@@ -232,10 +232,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
let hasTransportNotes = false
const loc = _locale || undefined
const tr = _t || (k => k)
// A place's description and notes are markdown in the app, so the export renders
// them the same way instead of printing the raw syntax (#2692).
const markdown = createMarkdownHtml()
const mdBlock = (text: string) => `<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`
const markdown = createPdfMarkdown()
const mdBlock = markdown.row
// The store read is the fallback, not the source: a caller that forgets the
// prop still prints the reader's own format instead of silently reverting.
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
@@ -841,17 +839,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
.info-spacer { width: 13px; flex-shrink: 0; }
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
.info-text.muted { color: #94a3b8; }
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
.md > :first-child { margin-top: 0; }
.md > :last-child { margin-bottom: 0; }
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
.md ul, .md ol { padding-inline-start: 14px; }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
.md table { border-collapse: collapse; }
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
.md a { color: inherit; text-decoration: underline; }
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
${PDF_MARKDOWN_CSS}
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
+30
View File
@@ -0,0 +1,30 @@
import { createMarkdownHtml } from '../shared/markdownHtml';
/**
* A place's description and notes are markdown in the app, so the export
* renders them the same way instead of printing the raw syntax (#2692): one
* info row per text, the HTML from the app's own sanitized pipeline.
*/
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
const markdown = createMarkdownHtml();
return {
row: (text) =>
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
dispose: markdown.dispose,
};
}
/** The markdown's own spacing, lists, tables and links at the export's small size. */
export const PDF_MARKDOWN_CSS = `
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
.md > :first-child { margin-top: 0; }
.md > :last-child { margin-bottom: 0; }
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
.md ul, .md ol { padding-inline-start: 14px; }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
.md table { border-collapse: collapse; }
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
.md a { color: inherit; text-decoration: underline; }
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
`;
-25
View File
@@ -1657,31 +1657,6 @@ describe('SharedTripPage', () => {
await waitFor(() => expect(screen.getByText('Somewhere 1')).toBeInTheDocument());
expect(screen.queryByText('1 h')).toBeNull();
});
// #2692: the description and notes are markdown in the planner, so the shared page renders them too.
it('renders markdown in the description and notes, and keeps raw HTML as text', async () => {
const marked = {
21: [{
id: 304, day_id: 21, order_index: 0, notes: '**Bring** cash',
place: {
id: 504, name: 'Market',
description: ['## Opening', '', '| Day | Hours |', '| --- | --- |', '| Sat | 8 to 14 |', '', '<img src=x onerror=alert(1)>'].join('\n'),
address: 'Market Square 1', lat: null, lng: null, duration_minutes: 0, notes: ['- fruit', '- bread'].join('\n'),
website: null, phone: null, category_id: null, price: null, place_time: null, end_time: null,
image_url: null, transport_mode: 'walking', category: null, tags: [],
},
}],
};
await open('markdown-token', payload({ days, assignments: marked }));
await waitFor(() => expect(screen.getByText('Market Square 1')).toBeInTheDocument());
expect(screen.getByRole('heading', { name: 'Opening' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: '8 to 14' })).toBeInTheDocument();
expect(screen.getByText('Bring').tagName).toBe('STRONG');
expect(screen.getByText('fruit').tagName).toBe('LI');
expect(document.querySelector('img[src="x"]')).toBeNull();
expect(screen.queryByText('## Opening')).toBeNull();
});
});
describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => {
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { SharedPlaceDetails } from './SharedPlaceDetails';
/** FE-PAGE-SHARED-PLACE-001: a stop's description and notes on a shared trip, read as the planner shows them (#2692). */
describe('SharedPlaceDetails', () => {
it('FE-PAGE-SHARED-PLACE-001: renders markdown in the description and notes, and keeps raw HTML as text', () => {
render(
<SharedPlaceDetails
place={{
name: 'Market',
address: 'Market Square 1',
description: [
'## Opening',
'',
'| Day | Hours |',
'| --- | --- |',
'| Sat | 8 to 14 |',
'',
'<img src=x onerror=alert(1)>',
].join('\n'),
notes: ['- fruit', '- bread'].join('\n'),
}}
assignmentNotes="**Bring** cash"
/>
);
expect(screen.getByRole('heading', { name: 'Opening' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: '8 to 14' })).toBeInTheDocument();
expect(screen.getByText('Bring').tagName).toBe('STRONG');
expect(screen.getByText('fruit').tagName).toBe('LI');
expect(document.querySelector('img[src="x"]')).toBeNull();
expect(screen.queryByText('## Opening')).toBeNull();
});
});