mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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
This commit is contained in:
@@ -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(<TripFormModal {...defaultProps} />);
|
||||
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<typeof userEvent.setup>, 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(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
|
||||
|
||||
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<typeof userEvent.setup>, 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' });
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,14 +104,14 @@ describe('CustomDatePicker', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} min="2026-03-10" max="2026-03-20" />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} min="2026-03-10" max="2026-03-20" />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
<FieldLabel>{t('dashboard.tripTitle')}</FieldLabel>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => 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
|
||||
<FieldLabel>{t('dashboard.tripDescription')}</FieldLabel>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => canEditTrip && setDescription(e.target.value)}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')}
|
||||
rows={2}
|
||||
@@ -188,7 +190,7 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
<FieldLabel>{t('dashboard.endDate')}</FieldLabel>
|
||||
<CustomDatePicker
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) setEndDate(v) }}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
borderless
|
||||
style={{ marginTop: 3 }}
|
||||
@@ -203,7 +205,7 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
<FieldLabel>{t('dashboard.currency')}</FieldLabel>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={v => { if (canEditTrip) setCurrency(String(v)) }}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
||||
disabled={!canEditTrip}
|
||||
searchable
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dateInputPattern } from './dateInputPattern';
|
||||
|
||||
// The manual date field's hint has to read the way the field is filled and
|
||||
// parsed in the user's language; it used to say DD.MM.YYYY everywhere (#2604).
|
||||
describe('dateInputPattern', () => {
|
||||
it('follows the field order and separators of the locale', () => {
|
||||
expect(dateInputPattern('de-DE')).toBe('DD.MM.YYYY');
|
||||
expect(dateInputPattern('en-US')).toBe('MM/DD/YYYY');
|
||||
expect(dateInputPattern('en-GB')).toBe('DD/MM/YYYY');
|
||||
expect(dateInputPattern('ko-KR')).toBe('YYYY. MM. DD.');
|
||||
});
|
||||
|
||||
it('takes the shape only, never the locale digits', () => {
|
||||
expect(dateInputPattern('ar')).not.toMatch(/[٠-٩\d]/);
|
||||
expect(dateInputPattern('ar')).toContain('YYYY');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const LETTERS: Record<string, string> = { day: 'DD', month: 'MM', year: 'YYYY' };
|
||||
|
||||
/**
|
||||
* How a typed date is written in `locale`, as letters (DD, MM, YYYY) between the
|
||||
* locale's own separators. It is the hint of the manual date field, which is
|
||||
* pre-filled and read back in this same order, so a Korean user sees
|
||||
* "YYYY. MM. DD." rather than a German "DD.MM.YYYY". Direction marks are dropped:
|
||||
* the hint shows the shape, the field itself carries the script.
|
||||
*/
|
||||
export function dateInputPattern(locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
|
||||
.formatToParts(new Date(Date.UTC(2001, 5, 15)))
|
||||
.map((part) => LETTERS[part.type] ?? part.value.replace(/[]/g, ''))
|
||||
.join('');
|
||||
}
|
||||
@@ -124,6 +124,16 @@ describe('MNewTripSheet', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-003b: typing a title clears the title error (#2604)', () => {
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={vi.fn(async () => undefined)} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }))
|
||||
expect(screen.getByText('Title is required')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'P' } })
|
||||
expect(screen.queryByText('Title is required')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-NTSH-004: an end date before the start blocks the save', () => {
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={onSave} />);
|
||||
|
||||
Reference in New Issue
Block a user