Skip to content

Commit

Permalink
different approach to parsing and saving from dateinput
Browse files Browse the repository at this point in the history
  • Loading branch information
bjosttveit committed Oct 23, 2024
1 parent e25af39 commit 12e07f3
Show file tree
Hide file tree
Showing 5 changed files with 81 additions and 47 deletions.
55 changes: 31 additions & 24 deletions src/layout/Datepicker/DatePickerInput.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,66 @@
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<HTMLInputElement>;
onValueChange?: (value: string) => void;
onClick?: () => void;
isDialogOpen?: boolean;
readOnly?: boolean;
}

export const DatePickerInput = forwardRef(
(
{ id, value, formatString, onBlur, isDialogOpen, readOnly, onClick }: DatePickerInputProps,
{ id, value, formatString, timeStamp, onValueChange, isDialogOpen, readOnly, onClick }: DatePickerInputProps,
ref: RefObject<HTMLButtonElement>,
) => {
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<HTMLInputElement>) => {
const stringValue = e.target.value;
const date = strictParseFormat(stringValue, formatString);
const valueToSave = getSaveFormattedDateString(date, timeStamp) ?? stringValue;
onValueChange && onValueChange(valueToSave);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={styles.calendarInputWrapper}>
<Textfield
className={styles.calendarInput}
type='text'
id={id}
value={input}
value={inputValue}
placeholder={formatString}
onChange={handleInputChange}
onBlur={onBlur}
onChange={handleChange}
onBlur={saveValue}
readOnly={readOnly}
aria-readonly={readOnly}
/>
Expand Down
27 changes: 15 additions & 12 deletions src/layout/Datepicker/DatepickerComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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)) {
Expand All @@ -47,13 +54,8 @@ export function DatepickerComponent({ node }: IDatepickerProps) {
setIsDialogOpen(false);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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) =>
Expand Down Expand Up @@ -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}
/>,
<DatePickerCalendar
id={id}
locale={languageLocale}
selectedDate={selectedDate}
selectedDate={dayPickerDate}
isOpen={isDialogOpen}
onSelect={handleDayPickerSelect}
minDate={calculatedMinDate}
Expand Down
10 changes: 5 additions & 5 deletions src/layout/Datepicker/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, { forwardRef } from 'react';
import type { JSX } from 'react';

import { isAfter, isBefore, isValid, parseISO } from 'date-fns';
import { isAfter, isBefore, isValid } from 'date-fns';

import { FrontendValidationSource, ValidationMask } from 'src/features/validation';
import { DatepickerDef } from 'src/layout/Datepicker/config.def.generated';
import { DatepickerComponent } from 'src/layout/Datepicker/DatepickerComponent';
import { DatepickerSummary } from 'src/layout/Datepicker/DatepickerSummary';
import { SummaryItemSimple } from 'src/layout/Summary/SummaryItemSimple';
import { formatISOString, getDateConstraint, getDateFormat } from 'src/utils/dateHelpers';
import { formatISOString, getDateConstraint, getDateFormat, strictParseISO } from 'src/utils/dateHelpers';
import type { LayoutValidationCtx } from 'src/features/devtools/layoutValidation/types';
import type { DisplayDataProps } from 'src/features/displayData';
import type { BaseValidation, ComponentValidation, ValidationDataSources } from 'src/features/validation';
Expand Down Expand Up @@ -89,7 +89,7 @@ export class Datepicker extends DatepickerDef implements ValidateComponent<'Date
);

const validations: ComponentValidation[] = [];
const date = parseISO(dataAsString);
const date = strictParseISO(dataAsString);
if (!isValid(date)) {
validations.push({
message: { key: 'date_picker.invalid_date_message', params: [format] },
Expand All @@ -99,14 +99,14 @@ export class Datepicker extends DatepickerDef implements ValidateComponent<'Date
});
}

if (isBefore(date, minDate)) {
if (date && isBefore(date, minDate)) {
validations.push({
message: { key: 'date_picker.min_date_exeeded' },
severity: 'error',
source: FrontendValidationSource.Component,
category: ValidationMask.Component,
});
} else if (isAfter(date, maxDate)) {
} else if (date && isAfter(date, maxDate)) {
validations.push({
message: { key: 'date_picker.max_date_exeeded' },
severity: 'error',
Expand Down
5 changes: 2 additions & 3 deletions src/utils/dateHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { jest } from '@jest/globals';
import { format, parseISO } from 'date-fns';
import { formatISO, parseISO } from 'date-fns';

import { DateFlags } from 'src/types';
import {
DatepickerMaxDateDefault,
DatepickerMinDateDefault,
DatepickerSaveFormatTimestamp,
formatISOString,
getDateConstraint,
getDateFormat,
Expand Down Expand Up @@ -81,7 +80,7 @@ describe('dateHelpers', () => {
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);
});
});
});
Expand Down
31 changes: 28 additions & 3 deletions src/utils/dateHelpers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 =
| {
Expand Down Expand Up @@ -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;
}

0 comments on commit 12e07f3

Please sign in to comment.