diff --git a/src/layout/Datepicker/DatePickerInput.tsx b/src/layout/Datepicker/DatePickerInput.tsx index 1591e3feb..656ab552e 100644 --- a/src/layout/Datepicker/DatePickerInput.tsx +++ b/src/layout/Datepicker/DatePickerInput.tsx @@ -1,19 +1,20 @@ import React, { forwardRef, useEffect, useState } from 'react'; -import type { FocusEventHandler, RefObject } from 'react'; +import type { RefObject } from 'react'; import { Button, Textfield } from '@digdir/designsystemet-react'; import { CalendarIcon } from '@navikt/aksel-icons'; -import { format, isMatch, isValid } from 'date-fns'; +import { format, isValid } from 'date-fns'; import { useLanguage } from 'src/features/language/useLanguage'; import styles from 'src/layout/Datepicker/Calendar.module.css'; -import { DatepickerSaveFormatNoTimestamp, DatepickerSaveFormatTimestamp } from 'src/utils/dateHelpers'; +import { getSaveFormattedDateString, strictParseFormat, strictParseISO } from 'src/utils/dateHelpers'; export interface DatePickerInputProps { id: string; + formatString: string; + timeStamp: boolean; value?: string; - formatString?: string; - onBlur?: FocusEventHandler; + onValueChange?: (value: string) => void; onClick?: () => void; isDialogOpen?: boolean; readOnly?: boolean; @@ -21,39 +22,45 @@ export interface DatePickerInputProps { export const DatePickerInput = forwardRef( ( - { id, value, formatString, onBlur, isDialogOpen, readOnly, onClick }: DatePickerInputProps, + { id, value, formatString, timeStamp, onValueChange, isDialogOpen, readOnly, onClick }: DatePickerInputProps, ref: RefObject, ) => { - const [input, setInput] = useState(value ?? ''); - - const { langAsString } = useLanguage(); + const dateValue = value ? strictParseISO(value) : undefined; + const formattedDateValue = dateValue && isValid(dateValue) ? format(dateValue, formatString) : value; + const [inputValue, setInputValue] = useState(formattedDateValue ?? ''); useEffect(() => { - if (value) { - if (formatString && isMatch(value, formatString)) { - setInput(isValid(new Date(value)) ? format(value, formatString) : value); - } else if (isMatch(value, DatepickerSaveFormatNoTimestamp)) { - setInput(isValid(new Date(value)) ? format(value, formatString ?? 'dd.MM.yyyy') : value); - } else if (isMatch(value, DatepickerSaveFormatTimestamp)) { - setInput(isValid(new Date(value)) ? format(value, formatString ?? 'dd.MM.yyyy') : value); - } - } - }, [value, formatString]); + setInputValue(formattedDateValue ?? ''); + }, [formattedDateValue]); + + const saveValue = (e: React.ChangeEvent) => { + const stringValue = e.target.value; + const date = strictParseFormat(stringValue, formatString); + const valueToSave = getSaveFormattedDateString(date, timeStamp) ?? stringValue; + onValueChange && onValueChange(valueToSave); + }; - const handleInputChange = (e: React.ChangeEvent) => { - setInput(e.target.value); + const handleChange = (e: React.ChangeEvent) => { + const stringValue = e.target.value; + setInputValue(stringValue); + // If the date is valid, save immediately + if (isValid(strictParseFormat(stringValue, formatString))) { + saveValue(e); + } }; + const { langAsString } = useLanguage(); + return (
diff --git a/src/layout/Datepicker/DatepickerComponent.tsx b/src/layout/Datepicker/DatepickerComponent.tsx index 745a1f79f..4193d3be5 100644 --- a/src/layout/Datepicker/DatepickerComponent.tsx +++ b/src/layout/Datepicker/DatepickerComponent.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import { Modal, Popover } from '@digdir/designsystemet-react'; import { Grid } from '@material-ui/core'; -import { formatDate, isValid as isValidDate, parse, parseISO } from 'date-fns'; +import { formatDate, isValid as isValidDate } from 'date-fns'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; @@ -13,7 +13,13 @@ import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper' import styles from 'src/layout/Datepicker/Calendar.module.css'; import { DatePickerCalendar } from 'src/layout/Datepicker/DatePickerCalendar'; import { DatePickerInput } from 'src/layout/Datepicker/DatePickerInput'; -import { getDateConstraint, getDateFormat, getLocale, getSaveFormattedDateString } from 'src/utils/dateHelpers'; +import { + getDateConstraint, + getDateFormat, + getLocale, + getSaveFormattedDateString, + strictParseISO, +} from 'src/utils/dateHelpers'; import { useNodeItem } from 'src/utils/layout/useNodeItem'; import type { PropsFromGenericComponent } from 'src/layout'; @@ -37,7 +43,8 @@ export function DatepickerComponent({ node }: IDatepickerProps) { const { setValue, formData } = useDataModelBindings(dataModelBindings); const value = formData.simpleBinding; - const selectedDate = isValidDate(parseISO(value)) ? parseISO(value) : new Date(); + const dateValue = strictParseISO(value); + const dayPickerDate = dateValue && isValidDate(dateValue) ? dateValue : new Date(); const handleDayPickerSelect = (date: Date) => { if (date && isValidDate(date)) { @@ -47,13 +54,8 @@ export function DatepickerComponent({ node }: IDatepickerProps) { setIsDialogOpen(false); }; - const handleInputChange = (e: React.ChangeEvent) => { - const parsed = parse(e.target.value, dateFormat, new Date()); - if (isValidDate(parsed)) { - setValue('simpleBinding', getSaveFormattedDateString(parsed, timeStamp)); - } else { - setValue('simpleBinding', e.target.value ?? ''); - } + const handleInputValueChange = (isoDateString: string) => { + setValue('simpleBinding', isoDateString); }; const renderModal = (trigger: ReactNode, content: ReactNode) => @@ -110,14 +112,15 @@ export function DatepickerComponent({ node }: IDatepickerProps) { value={value} isDialogOpen={isMobile ? modalRef.current?.open : isDialogOpen} formatString={dateFormat} - onBlur={handleInputChange} + timeStamp={timeStamp} + onValueChange={handleInputValueChange} onClick={() => (isMobile ? modalRef.current?.showModal() : setIsDialogOpen(!isDialogOpen))} readOnly={readOnly} />, { tests.forEach(({ props, expected }) => { it(`should return ${expected} when called with ${JSON.stringify(props)}`, () => { const result = getDateConstraint(...props); - expect(format(result, DatepickerSaveFormatTimestamp)).toEqual(expected); + expect(formatISO(result, { representation: 'complete' })).toEqual(expected); }); }); }); diff --git a/src/utils/dateHelpers.ts b/src/utils/dateHelpers.ts index 611165256..cb21dd549 100644 --- a/src/utils/dateHelpers.ts +++ b/src/utils/dateHelpers.ts @@ -1,4 +1,4 @@ -import { endOfDay, formatDate, formatISO, isValid, parseISO, startOfDay } from 'date-fns'; +import { endOfDay, format, formatDate, formatISO, isValid, parse, parseISO, startOfDay } from 'date-fns'; import type { Locale } from 'date-fns/locale'; import { DateFlags } from 'src/types'; @@ -7,9 +7,7 @@ import { locales } from 'src/utils/dateLocales'; export const DatepickerMinDateDefault = '1900-01-01T00:00:00Z'; export const DatepickerMaxDateDefault = '2100-01-01T23:59:59Z'; export const DatepickerFormatDefault = 'dd.MM.yyyy'; -export const DatepickerSaveFormatTimestamp = "yyyy-MM-dd'T'HH:mm:ssXXXXX"; export const PrettyDateAndTime = 'dd.MM.yyyy HH.mm.ss'; -export const DatepickerSaveFormatNoTimestamp = 'yyyy-MM-dd'; export type DateResult = | { @@ -108,3 +106,30 @@ export function parseISOString(isoString: string | undefined): DateResult { }; } } + +/** + * The date-fns parseISO function is a bit too lax for us, and will parse e.g. '01' as the date '0100-01-01', + * this function requires at least a full date to parse successfully. + * This prevents the value in the Datepicker input from changing while typing. + */ +export function strictParseISO(isoString: string | undefined): Date | null { + const minimumDate = 'yyyy-MM-dd'; + if (!isoString || isoString.length < minimumDate.length) { + return null; + } + return parseISO(isoString); +} + +/** + * The format function is a bit too lax, and will parse '01/01/1' (format: 'dd/MM/yyyy', which requires full year) as '01/01/0001', + * this function requires that the parsed date when formatted using the same format is equal to the input. + * This prevents the value in the Datepicker input from changing while typing. + */ +export function strictParseFormat(formattedDate: string | undefined, formatString: string): Date | null { + if (!formattedDate) { + return null; + } + const date = parse(formattedDate, formatString, new Date()); + const newFormattedDate = isValid(date) ? format(date, formatString) : undefined; + return newFormattedDate && newFormattedDate === formattedDate ? date : null; +}