From b3401b2df492bc1045f1c91a89643821c9e3794f Mon Sep 17 00:00:00 2001 From: Matt Warman Date: Wed, 18 Sep 2024 07:33:36 -0400 Subject: [PATCH] 46 Date and time picker inputs (#77) * input error text * initial DatetimeInput component * user form field attrs * calendar icon * datetimeinput internal event logic * profile dateOfBirth * datetime time format * date format * datetime disabled * clean up * format datetime * DateInput component * tests * DatetimeInput refactored * format IonDatetime value * tests * tests * docs --- src/__fixtures__/profiles.ts | 1 + src/common/components/Icon/Icon.tsx | 3 + src/common/components/Input/DateInput.scss | 12 ++ src/common/components/Input/DateInput.tsx | 163 ++++++++++++++++ .../components/Input/DatetimeInput.scss | 12 ++ src/common/components/Input/DatetimeInput.tsx | 177 ++++++++++++++++++ src/common/components/Input/Input.tsx | 3 +- .../Input/__tests__/DateInput.test.tsx | 45 +++++ .../Input/__tests__/DatetimeInput.test.tsx | 46 +++++ src/common/models/profile.ts | 1 + .../components/Profile/ProfileForm.scss | 7 +- .../components/Profile/ProfileForm.tsx | 20 +- .../Users/components/UserForm/UserForm.tsx | 4 - 13 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 src/common/components/Input/DateInput.scss create mode 100644 src/common/components/Input/DateInput.tsx create mode 100644 src/common/components/Input/DatetimeInput.scss create mode 100644 src/common/components/Input/DatetimeInput.tsx create mode 100644 src/common/components/Input/__tests__/DateInput.test.tsx create mode 100644 src/common/components/Input/__tests__/DatetimeInput.test.tsx diff --git a/src/__fixtures__/profiles.ts b/src/__fixtures__/profiles.ts index 83d347b..043049a 100644 --- a/src/__fixtures__/profiles.ts +++ b/src/__fixtures__/profiles.ts @@ -4,4 +4,5 @@ export const profileFixture1: Profile = { name: 'Test User', email: 'test1@example.com', bio: 'My name is Test User.', + dateOfBirth: '2002-05-07', }; diff --git a/src/common/components/Icon/Icon.tsx b/src/common/components/Icon/Icon.tsx index 123b35b..bd2af6f 100644 --- a/src/common/components/Icon/Icon.tsx +++ b/src/common/components/Icon/Icon.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { faBuilding, + faCalendar, faCircleInfo, faEnvelope, faHouse, @@ -42,6 +43,7 @@ export interface IconProps */ export enum IconName { Building = 'building', + Calendar = 'calendar', CircleInfo = 'circle_info', Envelope = 'envelope', House = 'house', @@ -65,6 +67,7 @@ export enum IconName { */ const icons: Record = { building: faBuilding, + calendar: faCalendar, circle_info: faCircleInfo, envelope: faEnvelope, house: faHouse, diff --git a/src/common/components/Input/DateInput.scss b/src/common/components/Input/DateInput.scss new file mode 100644 index 0000000..87cebee --- /dev/null +++ b/src/common/components/Input/DateInput.scss @@ -0,0 +1,12 @@ +ion-input.ls-date-input { + input { + cursor: pointer; + } +} + +ion-modal.ls-date-modal { + --height: fit-content; + --width: fit-content; + + --border-radius: 0.5rem; +} diff --git a/src/common/components/Input/DateInput.tsx b/src/common/components/Input/DateInput.tsx new file mode 100644 index 0000000..13d5160 --- /dev/null +++ b/src/common/components/Input/DateInput.tsx @@ -0,0 +1,163 @@ +import { ComponentPropsWithoutRef, useMemo, useState } from 'react'; +import { ModalCustomEvent } from '@ionic/core'; +import { DatetimeCustomEvent, IonButton, IonDatetime, IonInput, IonModal } from '@ionic/react'; +import { useField } from 'formik'; +import classNames from 'classnames'; +import dayjs from 'dayjs'; + +import './DateInput.scss'; +import { PropsWithTestId } from '../types'; +import Icon, { IconName } from '../Icon/Icon'; + +/** + * Default `IonDatetime` `formatOptions` for the date. May be overridden by + * supplying a `formatOptions` property. Controls how the date is displayed + * in the form input. + * @see {@link IonDatetime} + */ +const DEFAULT_FORMAT_DATE: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', +}; + +/** + * `DateValue` describes the possible types of an `IonDatetime` whose `presentation` + * is 'date'. + */ +type DateValue = string | null; + +/** + * Properties for the `DateInput` component. + * @see {@link PropsWithTestId} + * @see {@link IonDatetime} + * @see {@link IonInput} + * @see {@link IonModal} + */ +interface DateInputProps + extends PropsWithTestId, + Pick, 'label' | 'labelPlacement'>, + Pick, 'onIonModalDidDismiss'>, + Omit, 'multiple' | 'name' | 'presentation'>, + Required, 'name'>> {} + +/** + * The `DateInput` component renders an `IonDatetime` which is integrated with + * Formik. The form field value is displayed in an `IonInput`. When that input + * is clicked, an `IonDatetime` is presented within an `IonModal`. + * + * Use this component when you need to collect a date value within a form. The + * date value will be set as an ISO8601 date, e.g. YYYY-MM-DD + * + * @param {DateInputProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const DateInput = ({ + className, + label, + labelPlacement, + onIonModalDidDismiss, + testid = 'input-date', + ...datetimeProps +}: DateInputProps): JSX.Element => { + const [field, meta, helpers] = useField(datetimeProps.name); + const [isOpen, setIsOpen] = useState(false); + + // populate error text only if the field has been touched and has an error + const errorText: string | undefined = meta.touched ? meta.error : undefined; + + /** + * Handle change events emitted by `IonDatetime`. + * @param {DatetimeCustomEvent} e - The event. + */ + const onChange = async (e: DatetimeCustomEvent): Promise => { + const value = e.detail.value as DateValue; + if (value) { + const isoDate = dayjs(value).format('YYYY-MM-DD'); + await helpers.setValue(isoDate, true); + } else { + await helpers.setValue(null, true); + } + datetimeProps.onIonChange?.(e); + }; + + /** + * Handle 'did dismiss' events emitted by `IonModal`. + */ + const onDidDismiss = async (e: ModalCustomEvent): Promise => { + await helpers.setTouched(true, true); + setIsOpen(false); + onIonModalDidDismiss?.(e); + }; + + // format the value to display in the IonInput + // use UTC so that local timezone offset does not change the date + const inputValue = useMemo(() => { + if (field.value) { + const dateOptions: Intl.DateTimeFormatOptions = { + ...(datetimeProps.formatOptions?.date ?? DEFAULT_FORMAT_DATE), + timeZone: 'UTC', + }; + + return Intl.DateTimeFormat(undefined, dateOptions).format(new Date(field.value)); + } else { + return ''; + } + }, [datetimeProps.formatOptions, field.value]); + + // format the value for the IonDatetime. it must be a local ISO date or null/undefined + const datetimeValue = useMemo(() => { + return field.value ? dayjs(field.value).format('YYYY-MM-DD[T]HH:mm') : null; + }, [field.value]); + + return ( + <> + setIsOpen(true)} + readonly + value={inputValue} + > + + + + + + + + ); +}; + +export default DateInput; diff --git a/src/common/components/Input/DatetimeInput.scss b/src/common/components/Input/DatetimeInput.scss new file mode 100644 index 0000000..b1f73df --- /dev/null +++ b/src/common/components/Input/DatetimeInput.scss @@ -0,0 +1,12 @@ +ion-input.ls-datetime-input { + input { + cursor: pointer; + } +} + +ion-modal.ls-datetime-modal { + --height: fit-content; + --width: fit-content; + + --border-radius: 0.5rem; +} diff --git a/src/common/components/Input/DatetimeInput.tsx b/src/common/components/Input/DatetimeInput.tsx new file mode 100644 index 0000000..83cfb6b --- /dev/null +++ b/src/common/components/Input/DatetimeInput.tsx @@ -0,0 +1,177 @@ +import { ComponentPropsWithoutRef, useMemo, useState } from 'react'; +import { ModalCustomEvent } from '@ionic/core'; +import { DatetimeCustomEvent, IonButton, IonDatetime, IonInput, IonModal } from '@ionic/react'; +import { useField } from 'formik'; +import classNames from 'classnames'; +import dayjs from 'dayjs'; + +import './DatetimeInput.scss'; +import { PropsWithTestId } from '../types'; +import Icon, { IconName } from '../Icon/Icon'; + +/** + * Default `IonDatetime` `formatOptions` for the date. May be overridden by + * supplying a `formatOptions` property. Controls how the date is displayed + * in the form input. + * @see {@link IonDatetime} + */ +const DEFAULT_FORMAT_DATE: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', +}; + +/** + * Default `IonDatetime` `formatOptions` for the time. May be overridden by + * supplying a `formatOptions` property. Controls how the time is displayed + * in the form input. + * @see {@link IonDatetime} + */ +const DEFAULT_FORMAT_TIME: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', +}; + +/** + * `DatetimeValue` describes the possible types of an `IonDatetime` whose `presentation` + * is 'date-time'. + */ +type DatetimeValue = string | null; + +/** + * Properties for the `DatetimeInput` component. + * @see {@link PropsWithTestId} + * @see {@link IonDatetime} + * @see {@link IonInput} + * @see {@link IonModal} + */ +interface DatetimeInputProps + extends PropsWithTestId, + Pick, 'label' | 'labelPlacement'>, + Pick, 'onIonModalDidDismiss'>, + Omit, 'multiple' | 'name' | 'presentation'>, + Required, 'name'>> {} + +/** + * The `DatetimeInput` component renders an `IonDatetime` which is integrated with + * Formik. The form field value is displayed in an `IonInput`. When that input + * is clicked, an `IonDatetime` is presented within an `IonModal`. + * + * Use this component when you need to collect a date and time, a timestamp, + * value within a form. The value will be set as an ISO8601 timestamp. + * + * @param {DateInputProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const DatetimeInput = ({ + className, + label, + labelPlacement, + onIonModalDidDismiss, + testid = 'input-datetime', + ...datetimeProps +}: DatetimeInputProps): JSX.Element => { + const [field, meta, helpers] = useField(datetimeProps.name); + const [isOpen, setIsOpen] = useState(false); + + // populate error text only if the field has been touched and has an error + const errorText: string | undefined = meta.touched ? meta.error : undefined; + + /** + * Handle change events emitted by `IonDatetime`. + * @param {DatetimeCustomEvent} e - The event. + */ + const onChange = async (e: DatetimeCustomEvent): Promise => { + const value = e.detail.value as DatetimeValue; + if (value) { + const isoDate = dayjs(value).toISOString(); + await helpers.setValue(isoDate, true); + } else { + await helpers.setValue(null, true); + } + datetimeProps.onIonChange?.(e); + }; + + /** + * Handle 'did dismiss' events emitted by `IonModal`. + */ + const onDidDismiss = async (e: ModalCustomEvent): Promise => { + await helpers.setTouched(true, true); + setIsOpen(false); + onIonModalDidDismiss?.(e); + }; + + // format the value to display in the IonInput + const inputValue = useMemo(() => { + if (field.value) { + const date = new Intl.DateTimeFormat( + undefined, + datetimeProps.formatOptions?.date ?? DEFAULT_FORMAT_DATE, + ).format(new Date(field.value)); + const time = new Intl.DateTimeFormat( + undefined, + datetimeProps.formatOptions?.time ?? DEFAULT_FORMAT_TIME, + ).format(new Date(field.value)); + + return `${date} ${time}`; + } else { + return ''; + } + }, [datetimeProps.formatOptions, field.value]); + + // format the value for the IonDatetime. it must be a local ISO date or null/undefined + const datetimeValue = useMemo(() => { + return field.value ? dayjs(field.value).format('YYYY-MM-DD[T]HH:mm') : null; + }, [field.value]); + + return ( + <> + setIsOpen(true)} + readonly + value={inputValue} + > + + + + + + + + ); +}; + +export default DatetimeInput; diff --git a/src/common/components/Input/Input.tsx b/src/common/components/Input/Input.tsx index 026dd02..1c21626 100644 --- a/src/common/components/Input/Input.tsx +++ b/src/common/components/Input/Input.tsx @@ -29,6 +29,7 @@ interface InputProps const Input = forwardRef( ({ className, testid = 'input', ...props }: InputProps, ref): JSX.Element => { const [field, meta, helpers] = useField(props.name); + const errorText: string | undefined = meta.touched ? meta.error : undefined; return ( ( data-testid={testid} {...field} {...props} - errorText={meta.error} + errorText={errorText} ref={ref} > ); diff --git a/src/common/components/Input/__tests__/DateInput.test.tsx b/src/common/components/Input/__tests__/DateInput.test.tsx new file mode 100644 index 0000000..58cba42 --- /dev/null +++ b/src/common/components/Input/__tests__/DateInput.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { Form, Formik } from 'formik'; + +import { render, screen } from 'test/test-utils'; + +import DateInput from '../DateInput'; + +describe('DateInput', () => { + it('should render successfully', async () => { + // ARRANGE + render( + {}}> +
+ + +
, + ); + await screen.findByTestId('input'); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + }); + + it('should display initial value', async () => { + // ARRANGE + render( + {}}> +
+ + +
, + ); + await screen.findByTestId('input-button-calendar'); + + // ACT + await userEvent.click(screen.getByTestId('input-button-calendar')); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + expect(screen.getByTestId('input')).toHaveValue('Jan 1, 2024'); + expect(screen.getByTestId('input-datetime')).toBeDefined(); + expect(screen.getByTestId('input-datetime')).toHaveValue('2024-01-01T00:00'); + }); +}); diff --git a/src/common/components/Input/__tests__/DatetimeInput.test.tsx b/src/common/components/Input/__tests__/DatetimeInput.test.tsx new file mode 100644 index 0000000..01680d7 --- /dev/null +++ b/src/common/components/Input/__tests__/DatetimeInput.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { Form, Formik } from 'formik'; + +import { render, screen } from 'test/test-utils'; + +import DatetimeInput from '../DatetimeInput'; + +describe('DatetimeInput', () => { + it('should render successfully', async () => { + // ARRANGE + render( + {}}> +
+ + +
, + ); + await screen.findByTestId('input'); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + expect(screen.getByTestId('input')).toHaveValue(''); + }); + + it('should display initial value', async () => { + // ARRANGE + render( + {}}> +
+ + +
, + ); + await screen.findByTestId('input-button-calendar'); + + // ACT + await userEvent.click(screen.getByTestId('input-button-calendar')); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + expect(screen.getByTestId('input')).toHaveValue('Jan 1, 2024 5:00 AM'); + expect(screen.getByTestId('input-datetime')).toBeDefined(); + expect(screen.getByTestId('input-datetime')).toHaveValue('2024-01-01T05:00'); + }); +}); diff --git a/src/common/models/profile.ts b/src/common/models/profile.ts index 11fd134..261484c 100644 --- a/src/common/models/profile.ts +++ b/src/common/models/profile.ts @@ -5,4 +5,5 @@ import { User } from './user'; */ export type Profile = Pick & { bio?: string; + dateOfBirth?: string; }; diff --git a/src/pages/Account/components/Profile/ProfileForm.scss b/src/pages/Account/components/Profile/ProfileForm.scss index 728c291..6a491cd 100644 --- a/src/pages/Account/components/Profile/ProfileForm.scss +++ b/src/pages/Account/components/Profile/ProfileForm.scss @@ -1,5 +1,10 @@ .form-profile { - ion-input { + ion-input, + ion-textarea { margin-bottom: 0.5rem; } + + .button-row { + margin-top: 2rem; + } } diff --git a/src/pages/Account/components/Profile/ProfileForm.tsx b/src/pages/Account/components/Profile/ProfileForm.tsx index c4de8b4..750608e 100644 --- a/src/pages/Account/components/Profile/ProfileForm.tsx +++ b/src/pages/Account/components/Profile/ProfileForm.tsx @@ -1,7 +1,7 @@ import { IonButton, useIonRouter, useIonViewDidEnter } from '@ionic/react'; import { useRef, useState } from 'react'; import { Form, Formik } from 'formik'; -import { object, string } from 'yup'; +import { date, object, string } from 'yup'; import classNames from 'classnames'; import './ProfileForm.scss'; @@ -15,6 +15,7 @@ import ErrorCard from 'common/components/Card/ErrorCard'; import Input from 'common/components/Input/Input'; import ButtonRow from 'common/components/Button/ButtonRow'; import Textarea from 'common/components/Input/Textarea'; +import DateInput from 'common/components/Input/DateInput'; /** * Profile form values. @@ -38,6 +39,7 @@ const validationSchema = object({ name: string().required('Required. '), email: string().required('Required. ').email('Must be an email address. '), bio: string().max(500, 'Must be 500 characters or less. '), + dateOfBirth: date().required('Required. '), }); /** @@ -81,6 +83,7 @@ const ProfileForm = ({ email: profile.email, name: profile.name, bio: profile.bio, + dateOfBirth: profile.dateOfBirth, }} onSubmit={(values, { setSubmitting }) => { setProgress(true); @@ -119,6 +122,7 @@ const ProfileForm = ({ ref={focusInput} data-testid={`${testid}-field-name`} /> + +