From cb04ebe6d2a1c2dd9cc4bc9cbbff4ce6857391ca Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 15:46:02 +0200 Subject: [PATCH] fix(trips): the trip form drops its complaint once you edit, and the typed date hint follows your language "Title is required" stayed on screen after the title was typed, until the next save. Any edit in the trip form, desktop and phone, now clears the last save's complaint; the next save checks again. The keyboard date field always hinted DD.MM.YYYY, while it was filled and read back in the user's own order: a Korean user saw "2026. 12. 10." under a German hint. The hint is now the locale's own pattern (YYYY. MM. DD. in Korean, MM/DD/YYYY in US English), and the field's name is translated. Refs #2604 --- .../components/Trips/TripFormModal.test.tsx | 22 ++++++++---- client/src/components/Trips/TripFormModal.tsx | 6 ++-- .../shared/CustomDateTimePicker.test.tsx | 34 +++++++++---------- .../shared/CustomDateTimePicker.tsx | 5 +-- .../screens/dashboard/MNewTripSheet.tsx | 12 ++++--- client/src/utils/dateInputPattern.test.ts | 18 ++++++++++ client/src/utils/dateInputPattern.ts | 15 ++++++++ .../mobile/dashboard/MNewTripSheet.test.tsx | 10 ++++++ 8 files changed, 90 insertions(+), 32 deletions(-) create mode 100644 client/src/utils/dateInputPattern.test.ts create mode 100644 client/src/utils/dateInputPattern.ts diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index 25e92f2a9..9e671b26f 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -116,6 +116,16 @@ describe('TripFormModal', () => { expect(await screen.findByText('Title is required')).toBeInTheDocument(); }); + it('FE-COMP-TRIPFORM-009b: typing a title clears the title error (#2604)', async () => { + const user = userEvent.setup(); + render(); + const [submitBtn] = screen.getAllByRole('button', { name: 'Create New Trip' }).slice(-1); + await user.click(submitBtn as HTMLElement); + expect(await screen.findByText('Title is required')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'P'); + expect(screen.queryByText('Title is required')).not.toBeInTheDocument(); + }); + it('FE-COMP-TRIPFORM-010: typing title and submitting calls onSave', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); @@ -416,7 +426,7 @@ describe('TripFormModal', () => { // Changing the start of a dated trip must go through the date-shift choice step (#1288). const changeStartDate = async (user: ReturnType, iso: string) => { await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[0]); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: iso } }); fireEvent.keyDown(input, { key: 'Enter' }); }; @@ -931,7 +941,7 @@ describe('TripFormModal', () => { await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Open Ended'); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[0]); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '2026-04-10' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -951,12 +961,12 @@ describe('TripFormModal', () => { await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Backwards'); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]); - const endInput = screen.getByPlaceholderText('DD.MM.YYYY'); + const endInput = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(endInput, { target: { value: '2026-03-01' } }); fireEvent.keyDown(endInput, { key: 'Enter' }); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[0]); - const startInput = screen.getByPlaceholderText('DD.MM.YYYY'); + const startInput = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(startInput, { target: { value: '2026-01-01' } }); fireEvent.keyDown(startInput, { key: 'Enter' }); @@ -1283,7 +1293,7 @@ describe('TripFormModal', () => { render(); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]); - const endInput = screen.getByPlaceholderText('DD.MM.YYYY'); + const endInput = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(endInput, { target: { value: '2036-01-01' } }); fireEvent.keyDown(endInput, { key: 'Enter' }); @@ -1326,7 +1336,7 @@ describe('TripFormModal', () => { }; const changeEndDate = async (user: ReturnType, iso: string) => { await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: iso } }); fireEvent.keyDown(input, { key: 'Enter' }); }; diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 024f171e3..7e5bd31ce 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -223,7 +223,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } } - const update = (field, value) => setFormData(prev => { + // An edit answers whatever the last save complained about, so the complaint + // goes with it rather than standing over the fixed field until the next save. + const update = (field, value) => { setError(''); setFormData(prev => { const next = { ...prev, [field]: value } if (field === 'start_date' && value) { if (!prev.end_date || prev.end_date < value) { @@ -235,7 +237,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } } return next - }) + }) } /* The dashboard's shapes, in the app's own tokens. `.trek-dash` deliberately scopes its palette to that page, so none of its diff --git a/client/src/components/shared/CustomDateTimePicker.test.tsx b/client/src/components/shared/CustomDateTimePicker.test.tsx index 7b258a6d5..288555cd5 100644 --- a/client/src/components/shared/CustomDateTimePicker.test.tsx +++ b/client/src/components/shared/CustomDateTimePicker.test.tsx @@ -104,14 +104,14 @@ describe('CustomDatePicker', () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /enter date manually/i })); - expect(screen.getByPlaceholderText('DD.MM.YYYY')).toBeTruthy(); + expect(screen.getByRole('textbox', { name: 'Enter date manually' })).toBeTruthy(); }); it('FE-COMP-DATEPICKER-012: text input accepts ISO format YYYY-MM-DD', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '2026-07-04' } }); fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith('2026-07-04'); @@ -121,7 +121,7 @@ describe('CustomDatePicker', () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '17.07.2026' } }); fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith('2026-07-17'); @@ -131,7 +131,7 @@ describe('CustomDatePicker', () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '31.02.2026' } }); fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).not.toHaveBeenCalled(); @@ -141,7 +141,7 @@ describe('CustomDatePicker', () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '29.02.2024' } }); fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalledWith('2024-02-29'); @@ -151,9 +151,9 @@ describe('CustomDatePicker', () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.keyDown(input, { key: 'Escape' }); - expect(screen.queryByPlaceholderText('DD.MM.YYYY')).toBeNull(); + expect(screen.queryByRole('textbox', { name: 'Enter date manually' })).toBeNull(); expect(screen.getAllByRole('button').length).toBeGreaterThan(0); }); @@ -183,7 +183,7 @@ describe('CustomDatePicker', () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '2026-03-25' } }); fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).not.toHaveBeenCalled(); @@ -503,22 +503,22 @@ describe('CustomDatePicker branches', () => { openCalendar(); fireEvent.click(screen.getByRole('button', { name: /enter date manually/i })); - expect(screen.getByPlaceholderText('DD.MM.YYYY')).toHaveProperty('value', ''); + expect(screen.getByRole('textbox', { name: 'Enter date manually' })).toHaveProperty('value', ''); }); it('FE-W5DP-010: submitting an empty manual entry does nothing', () => { render(); fireEvent.click(screen.getByRole('button', { name: /enter date manually/i })); - fireEvent.blur(screen.getByPlaceholderText('DD.MM.YYYY')); + fireEvent.blur(screen.getByRole('textbox', { name: 'Enter date manually' })); expect(onChange).not.toHaveBeenCalled(); - expect(screen.queryByPlaceholderText('DD.MM.YYYY')).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Enter date manually' })).not.toBeInTheDocument(); }); it('FE-W5DP-011: a manual entry without three number groups is ignored', () => { render(); fireEvent.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '17.07' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -528,7 +528,7 @@ describe('CustomDatePicker branches', () => { it('FE-W5DP-012: a manual entry already in locale order is taken as typed', () => { render(); fireEvent.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '07/17/2026' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -538,7 +538,7 @@ describe('CustomDatePicker branches', () => { it('FE-W5DP-013: a two-digit year is expanded into the 2000s', () => { render(); fireEvent.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '07/17/26' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -548,7 +548,7 @@ describe('CustomDatePicker branches', () => { it('FE-W5DP-014: an impossible day/month combination is rejected', () => { render(); fireEvent.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '13/45/2026' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -558,7 +558,7 @@ describe('CustomDatePicker branches', () => { it('FE-W5DP-015: a localised manual entry outside [min,max] is rejected', () => { render(); fireEvent.click(screen.getByRole('button', { name: /enter date manually/i })); - const input = screen.getByPlaceholderText('DD.MM.YYYY'); + const input = screen.getByRole('textbox', { name: 'Enter date manually' }); fireEvent.change(input, { target: { value: '03/25/2026' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -743,7 +743,7 @@ describe('CustomDatePicker branches', () => { fireEvent.click(keyboard); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.getByPlaceholderText('DD.MM.YYYY')).toHaveProperty('value', '03/15/2026'); + expect(screen.getByRole('textbox', { name: 'Enter date manually' })).toHaveProperty('value', '03/15/2026'); }); }); diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index 03997564b..f218d9a90 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -3,6 +3,7 @@ import { localIsoDate } from '../../utils/localDate'; import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from '../../i18n'; +import { dateInputPattern } from '../../utils/dateInputPattern'; import { useRemeasureSignal } from '../../hooks/useAnchoredPosition'; import { useWeekStartDay } from '../../hooks/useWeekStartDay'; import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'; @@ -276,8 +277,8 @@ export function CustomDatePicker({ if (e.key === 'Escape') setIsTyping(false); }} onBlur={handleTextSubmit} - placeholder="DD.MM.YYYY" - aria-label="Enter date manually" + placeholder={dateInputPattern(locale)} + aria-label={t('common.datepicker.enterManually')} style={{ width: '100%', padding: '8px 14px', diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index ce8d4fb5d..d0e4b93dd 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -76,8 +76,10 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda setPendingRemoval(null) }, [trip, open]) - // Moving the start keeps the trip length (same rule as TripFormModal). + // Moving the start keeps the trip length (same rule as TripFormModal). Every + // edit clears the last save's complaint, as on the desktop form. const changeStart = (value: string) => { + setError('') setEndDate(endDateForNewStart(startDate, endDate, value)) setStartDate(value) } @@ -154,7 +156,7 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda {t('dashboard.tripTitle')} canEditTrip && setTitle(e.target.value)} + onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }} readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} @@ -165,7 +167,7 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda {t('dashboard.tripDescription')}