From 2421f37c6bf7634533fa2ae288ac68e87a225bc1 Mon Sep 17 00:00:00 2001 From: Toivo Mattila Date: Thu, 23 Apr 2026 09:17:20 +0300 Subject: [PATCH 01/11] feat(print): hide organizer section hints in printed event form Improve browser print and Save as PDF for the volunteer event form (EventPage). Hide the three section subtitle blocks in print while they remain visible on screen. Tweak padding and form-row font size. Refs: PS-275 --- src/components/form/EventForm.tsx | 26 +++++++++++------- src/components/pages/events/print.scss | 38 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/components/form/EventForm.tsx b/src/components/form/EventForm.tsx index f47516f7..59fcd381 100644 --- a/src/components/form/EventForm.tsx +++ b/src/components/form/EventForm.tsx @@ -54,7 +54,7 @@ const EventForm: React.FC = ({ const unavailableDates = useAppSelector(unavailableDatesSelector); return ( - + = ({ - +
+ +
= ({ - +
+ +
@@ -222,10 +226,12 @@ const EventForm: React.FC = ({ tagName="h2" id="form.event.title.tools_and_suplies" /> - +
+ +
diff --git a/src/components/pages/events/print.scss b/src/components/pages/events/print.scss index 9e4d6c71..11d0210e 100644 --- a/src/components/pages/events/print.scss +++ b/src/components/pages/events/print.scss @@ -1,3 +1,13 @@ +/* + * --- Event form: printing and "Save as PDF" --- + * + * Only the event page (EventPage) loads this file. The form sits inside a + * div called `printable-area`. When someone prints or saves a PDF, the + * rules below hide most of the screen so only that div shows up on paper. + * Answers are shown as plain text in `.print-only` spots while the real + * input boxes are hidden. See Input.tsx for how that works. + */ + .print-only { display: none; } @@ -12,11 +22,39 @@ visibility: visible; } + /* + * Event form print: `printable-area` is the div around the form. Side + * padding adds a small gap so text is not right against the edge of the + * paper. + */ .printable-area { position: absolute; left: 0; top: 0; width: 100%; + box-sizing: border-box; + padding: 0 6mm; + max-width: 100%; + } + + /* + * Event form print: each field block uses a wrapper with class + * `.form-group` (from reactstrap). Slightly smaller letters help more + * lines fit on one page. + */ + .printable-area .form-group { + font-size: 11pt; + line-height: 1.35; + } + + /* + * Event form print: EventForm.tsx puts three short help paragraphs in + * `.event-form-print-skip` (text from form.event.subtitle.* in the + * language files). The user still sees them on the website when editing. + * They do not show on paper or in a PDF. + */ + .printable-area .event-form .event-form-print-skip { + display: none; } .printable .form-control, From 08cdc0cc6ab557891ec0c17c1e42e0df9279830a Mon Sep 17 00:00:00 2001 From: Toivo Mattila Date: Thu, 23 Apr 2026 11:09:33 +0300 Subject: [PATCH 02/11] fix(print): align event form rows in browser print Refs: PS-275 --- src/components/pages/events/print.scss | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/pages/events/print.scss b/src/components/pages/events/print.scss index 11d0210e..acb6b66f 100644 --- a/src/components/pages/events/print.scss +++ b/src/components/pages/events/print.scss @@ -142,6 +142,18 @@ padding: 0; } + /* + * Event form print: the printed copy is meant for contractors and city + * staff who read it on paper or PDF. The rows should line up on + * the left so the handout is easy to scan. On screen we use two columns + * with Bootstrap offsets. In print each column becomes full width, but + * those offsets still added extra left margin on only some rows. Clear + * the offset margin so every label and value starts on the same edge. + */ + .printable-area [class*='offset-'] { + margin-left: 0 !important; + } + .form-group { margin-bottom: 0.5rem; } @@ -155,4 +167,4 @@ margin-top: 0.5rem; margin-bottom: 0.5rem; } -} +} \ No newline at end of file From 118511970e995feb2caf04de2334fb494b888bab Mon Sep 17 00:00:00 2001 From: Toivo Mattila Date: Wed, 27 May 2026 13:17:12 +0300 Subject: [PATCH 03/11] fix(print): align event date fields in printed PDF Refs: PS-275 --- src/components/form/EventForm.tsx | 2 +- src/components/form/partitions/DateRange.tsx | 37 +++-- .../partitions/__tests__/DateRange.test.tsx | 130 ++++++++++++++++++ src/components/pages/events/print.scss | 39 +++--- 4 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 src/components/form/partitions/__tests__/DateRange.test.tsx diff --git a/src/components/form/EventForm.tsx b/src/components/form/EventForm.tsx index 59fcd381..f26e594b 100644 --- a/src/components/form/EventForm.tsx +++ b/src/components/form/EventForm.tsx @@ -280,7 +280,7 @@ const EventForm: React.FC = ({ /> - + { interface Props { errors: FormikErrors; - handleBlur: (_event: React.FocusEvent) => void; + handleBlur: (_event: React.FormEvent) => void; handleChange: ( event: | { target: { id: string; value: unknown } } @@ -51,8 +54,7 @@ const DateRange: React.FC = ({ unavailableDates, values, }) => { - const onChange = (id: string) => (value: Date | null) => { - if (!value) return; + const onChange = (id: string) => (value: Date) => { handleChange({ target: { id, @@ -75,8 +77,7 @@ const DateRange: React.FC = ({ const handleDateChange = (id: string, oldDate: Date | string | undefined) => - (value: Date | null) => { - if (!value) return; + (value: Date) => { const oldDateObj = ensureDate(oldDate); onChange(id)( oldDateObj ? setHours(value, oldDateObj.getHours()) : addHours(value, 9) @@ -87,7 +88,7 @@ const DateRange: React.FC = ({ const syntheticEvent = { currentTarget: { id }, preventDefault: () => {}, - } as React.FocusEvent; + } as React.FormEvent; handleBlur(syntheticEvent); }; @@ -139,9 +140,13 @@ const DateRange: React.FC = ({ ); const dateFormat = getDateFormat(locale); const timeFormat = getTimeFormat(locale); + const formatDateValue = (value: Date | undefined) => + value ? format(value, dateFormat) : ''; + const formatTimeValue = (value: Date | undefined) => + value ? formatTime(value, timeFormat, locale) : ''; return ( - <> +
= ({ > = ({ showMonthDropdown useShortMonthInDropdown /> + = ({ timeIntervals={timeIntervals} touched={!!touched.start_time} /> + @@ -196,6 +207,10 @@ const DateRange: React.FC = ({ > = ({ showMonthDropdown useShortMonthInDropdown /> + = ({ timeIntervals={timeIntervals} touched={!!touched.end_time} /> + - +
); }; diff --git a/src/components/form/partitions/__tests__/DateRange.test.tsx b/src/components/form/partitions/__tests__/DateRange.test.tsx new file mode 100644 index 00000000..dfe03a8f --- /dev/null +++ b/src/components/form/partitions/__tests__/DateRange.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { addDays, setHours, startOfDay, format } from 'date-fns'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { vi } from 'vitest'; + +import DateRange from '../DateRange'; +import formatTime from '../../../../utils/formatTime'; + +const datePickerMock = vi.fn(); +const timePickerMock = vi.fn(); +const printValueMock = vi.fn(); + +vi.mock('../../fields/DatePicker', () => ({ + default: (props: Record) => { + datePickerMock(props); + return ( +
+ ); + }, +})); + +vi.mock('../../fields/timePicker/TimePicker', () => ({ + default: (props: Record) => { + timePickerMock(props); + return
; + }, +})); + +vi.mock('../../fields/PrintValue', () => ({ + default: (props: Record) => { + printValueMock(props); + return
; + }, +})); + +const renderComponent = () => { + const startTime = setHours(addDays(startOfDay(new Date()), 8), 9); + const endTime = setHours(addDays(startOfDay(new Date()), 10), 17); + const unavailableDates = [addDays(startOfDay(new Date()), 12)]; + + return render( + + + + ); +}; + +describe('', () => { + it('keeps unavailable dates blocked and renders print-only date and time mirrors', () => { + datePickerMock.mockClear(); + timePickerMock.mockClear(); + printValueMock.mockClear(); + + const { container } = renderComponent(); + + const startPickerProps = datePickerMock.mock.calls[0][0] as { + excludeDates?: Date[]; + id: string; + }; + const endPickerProps = datePickerMock.mock.calls[1][0] as { + excludeDates?: Date[]; + id: string; + }; + + expect(screen.getByTestId('date_range_start_date')).toBeInTheDocument(); + expect(screen.getByTestId('date_range_end_date')).toBeInTheDocument(); + expect(startPickerProps.id).toBe('date_range_start_date'); + expect(endPickerProps.id).toBe('date_range_end_date'); + expect(startPickerProps.excludeDates).toHaveLength(1); + expect(endPickerProps.excludeDates).toHaveLength(1); + + const expectedStartDate = format( + setHours(addDays(startOfDay(new Date()), 8), 9), + 'dd/MM/yyyy' + ); + const expectedStartTime = formatTime( + setHours(addDays(startOfDay(new Date()), 8), 9), + 'h:mm a', + 'en' + ); + const expectedEndDate = format( + setHours(addDays(startOfDay(new Date()), 10), 17), + 'dd/MM/yyyy' + ); + const expectedEndTime = formatTime( + setHours(addDays(startOfDay(new Date()), 10), 17), + 'h:mm a', + 'en' + ); + + expect(screen.getAllByTestId('print-value')).toHaveLength(4); + expect(container.querySelectorAll('.printable')).toHaveLength(1); + expect(printValueMock.mock.calls[0][0]).toMatchObject({ + value: expectedStartDate, + }); + expect(printValueMock.mock.calls[1][0]).toMatchObject({ + value: expectedStartTime, + }); + expect(printValueMock.mock.calls[2][0]).toMatchObject({ + value: expectedEndDate, + }); + expect(printValueMock.mock.calls[3][0]).toMatchObject({ + value: expectedEndTime, + }); + }); +}); diff --git a/src/components/pages/events/print.scss b/src/components/pages/events/print.scss index acb6b66f..26747f8a 100644 --- a/src/components/pages/events/print.scss +++ b/src/components/pages/events/print.scss @@ -5,11 +5,12 @@ * div called `printable-area`. When someone prints or saves a PDF, the * rules below hide most of the screen so only that div shows up on paper. * Answers are shown as plain text in `.print-only` spots while the real - * input boxes are hidden. See Input.tsx for how that works. + * input boxes are hidden. See Input.tsx and DateRange.tsx for how that works. */ .print-only { display: none; + margin: 0; } @media print { @@ -48,10 +49,9 @@ } /* - * Event form print: EventForm.tsx puts three short help paragraphs in - * `.event-form-print-skip` (text from form.event.subtitle.* in the - * language files). The user still sees them on the website when editing. - * They do not show on paper or in a PDF. + * Event form print: EventForm.tsx uses `.event-form-print-skip` for + * helper copy and optional blocks that are useful on screen but should + * stay out of the contractor-facing PDF. */ .printable-area .event-form .event-form-print-skip { display: none; @@ -65,6 +65,7 @@ .printable .print-only { display: block; + margin: 0; } .form-check-input, @@ -125,21 +126,19 @@ margin: 0; } - .col, - .col-1, - .col-2, - .col-3, - .col-4, - .col-5, - .col-6, - .col-7, - .col-8, - .col-9, - .col-10, - .col-11, - .col-12 { + /* + * Event form print: the form uses responsive Bootstrap column classes + * (`col-md-*`, `col-lg-*`, etc.) in addition to the base `col-*` helpers. + * Reset all of them here so every row starts from the same left edge. + */ + .printable-area .row > .col, + .printable-area .row > [class^='col-'], + .printable-area .row > [class*=' col-'] { width: 100%; - padding: 0; + max-width: 100%; + padding-left: 0; + padding-right: 0; + flex: 0 0 100%; } /* @@ -167,4 +166,4 @@ margin-top: 0.5rem; margin-bottom: 0.5rem; } -} \ No newline at end of file +} From df35db97534368e6d285669d874dee527434537f Mon Sep 17 00:00:00 2001 From: Toivo Mattila Date: Wed, 27 May 2026 13:38:35 +0300 Subject: [PATCH 04/11] style(print): format date-fns imports in DateRange Use Prettier to format DateRange file Refs: PS-275 --- src/components/form/partitions/DateRange.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/form/partitions/DateRange.tsx b/src/components/form/partitions/DateRange.tsx index a6bdb29f..f0ca7bed 100644 --- a/src/components/form/partitions/DateRange.tsx +++ b/src/components/form/partitions/DateRange.tsx @@ -1,4 +1,11 @@ -import { addDays, addHours, format, setHours, startOfDay, subDays } from 'date-fns'; +import { + addDays, + addHours, + format, + setHours, + startOfDay, + subDays, +} from 'date-fns'; import fi from 'date-fns/locale/fi'; import sv from 'date-fns/locale/sv'; import { FormikErrors, FormikTouched } from 'formik'; From 57794f3c9db279c26f7659761049931718fbf8eb Mon Sep 17 00:00:00 2001 From: Toivo Mattila Date: Thu, 11 Jun 2026 12:20:18 +0300 Subject: [PATCH 05/11] refactor(print): make printed time labels more compact Refs: PS-275 --- src/components/form/EventForm.tsx | 10 +- .../form/__tests__/EventForm.test.tsx | 122 ++++++++++++++++++ src/components/form/partitions/DateRange.tsx | 36 +++++- src/components/form/partitions/Location.tsx | 5 + .../partitions/__tests__/DateRange.test.tsx | 3 + .../partitions/__tests__/Location.test.tsx | 70 ++++++++++ src/components/pages/events/print.scss | 55 +++++++- 7 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 src/components/form/__tests__/EventForm.test.tsx create mode 100644 src/components/form/partitions/__tests__/Location.test.tsx diff --git a/src/components/form/EventForm.tsx b/src/components/form/EventForm.tsx index f26e594b..aaa79787 100644 --- a/src/components/form/EventForm.tsx +++ b/src/components/form/EventForm.tsx @@ -135,7 +135,7 @@ const EventForm: React.FC = ({ selectedAddress={selectedAddress} selectedContractZone={selectedContractZone} /> - +
@@ -280,7 +280,13 @@ const EventForm: React.FC = ({ /> - + ({ + useAppSelector: (selector: unknown) => { + if (typeof selector === 'function') { + return selector({ + geo: { + addressCoordinates: null, + selectedAddress: null, + selectedContractZone: { id: 1 }, + unavailableDates: [], + }, + }); + } + + return undefined; + }, +})); + +vi.mock('../fields/Input', () => ({ + default: (props: Record) => ( +
+ ), +})); + +vi.mock('../fields/NumericInput', () => ({ + default: (props: Record) => ( +
+ ), +})); + +vi.mock('../partitions/DateRange', () => ({ + default: () =>
, +})); + +vi.mock('../partitions/Location', () => ({ + default: () =>
, +})); + +const renderComponent = (additionalInformation: string) => + render( + + + + ); + +describe('', () => { + it('renders additional information as a printable field when populated', () => { + const { container } = renderComponent('Muita lisätietoja toimitukselle'); + + const additionalInformation = container.querySelector( + '[data-testid="additional_information"]' + ); + + expect(additionalInformation).toBeInTheDocument(); + expect( + additionalInformation?.closest('.event-form-print-note') + ).toBeInTheDocument(); + expect( + additionalInformation?.closest('.event-form-print-skip') + ).not.toBeInTheDocument(); + expect( + additionalInformation?.closest('.event-form-print-note--empty') + ).not.toBeInTheDocument(); + }); + + it('hides empty additional information from the printable layout', () => { + const { container } = renderComponent(''); + + expect( + container.querySelector('[data-testid="additional_information"]') + ).toBeInTheDocument(); + expect( + container.querySelector('.event-form-print-note--empty') + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/form/partitions/DateRange.tsx b/src/components/form/partitions/DateRange.tsx index f0ca7bed..0bdf889a 100644 --- a/src/components/form/partitions/DateRange.tsx +++ b/src/components/form/partitions/DateRange.tsx @@ -151,11 +151,20 @@ const DateRange: React.FC = ({ value ? format(value, dateFormat) : ''; const formatTimeValue = (value: Date | undefined) => value ? formatTime(value, timeFormat, locale) : ''; + const formatPrintSummary = (labelId: string, value: string) => ( +

+ + {formatMessage({ id: labelId })} + + {value} +

+ ); return ( -
+
= ({ /> - + = ({ +
+ {formatPrintSummary( + 'form.event.partitions.date_range.start_date.label', + formatDateValue(selectedStartTime) + )} + {formatPrintSummary( + 'form.event.partitions.date_range.start_time.label', + formatTimeValue(selectedStartTime) + )} +
= ({ /> - + = ({ +
+ {formatPrintSummary( + 'form.event.partitions.date_range.end_date.label', + formatDateValue(selectedEndTime) + )} + {formatPrintSummary( + 'form.event.partitions.date_range.end_time.label', + formatTimeValue(selectedEndTime) + )} +
); }; diff --git a/src/components/form/partitions/Location.tsx b/src/components/form/partitions/Location.tsx index 15c3bfb8..2e206d7f 100644 --- a/src/components/form/partitions/Location.tsx +++ b/src/components/form/partitions/Location.tsx @@ -169,6 +169,11 @@ const Location: React.FC = ({ +

+ {formatMessage({ + id: 'form.event.field.trash_location.placeholder', + })} +