diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonBirthday/PersonBirthday.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonBirthday/PersonBirthday.tsx index 3f0c1ef8f..d40479fa0 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonBirthday/PersonBirthday.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonBirthday/PersonBirthday.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import CakeIcon from '@mui/icons-material/Cake'; import { FormikProps } from 'formik'; import { DateTime } from 'luxon'; @@ -8,9 +8,12 @@ import { PersonCreateInput, PersonUpdateInput, } from 'src/graphql/types.generated'; +import { useLocale } from 'src/hooks/useLocale'; +import { validateAndFormatInvalidDate } from 'src/lib/intlFormat'; import { ModalSectionContainer } from '../ModalSectionContainer/ModalSectionContainer'; import { ModalSectionIcon } from '../ModalSectionIcon/ModalSectionIcon'; import { NewSocial } from '../PersonModal'; +import { buildDate } from '../personModalHelper'; interface PersonBirthdayProps { formikProps: FormikProps<(PersonUpdateInput | PersonCreateInput) & NewSocial>; @@ -20,28 +23,54 @@ export const PersonBirthday: React.FC = ({ formikProps, }) => { const { t } = useTranslation(); + const locale = useLocale(); + const [birthdayDateIsInvalid, setBirthdayDateIsInvalid] = useState(false); + const [backupBirthdayDate, setBackupBirthdayDate] = + useState | null>(null); const { values: { birthdayDay, birthdayMonth, birthdayYear }, setFieldValue, } = formikProps; + useEffect(() => { + if (typeof birthdayMonth !== 'number' || typeof birthdayDay !== 'number') { + return; + } + + const date = validateAndFormatInvalidDate( + birthdayYear, + birthdayMonth, + birthdayDay, + locale, + ); + + setBackupBirthdayDate( + date.formattedInvalidDate as unknown as DateTime, + ); + if (date.dateTime.invalidExplanation) { + setBirthdayDateIsInvalid(true); + } + }, [birthdayMonth, birthdayDay, birthdayYear]); + const handleDateChange = (date: DateTime | null) => { - setFieldValue('birthdayDay', date?.day || null); - setFieldValue('birthdayMonth', date?.month || null); - setFieldValue('birthdayYear', date?.year || null); + setFieldValue('birthdayDay', date?.day ?? null); + setFieldValue('birthdayMonth', date?.month ?? null); + setFieldValue('birthdayYear', date?.year ?? null); }; + const birthdayDate = useMemo( + () => buildDate(birthdayMonth, birthdayDay, birthdayYear), + [birthdayMonth, birthdayDay, birthdayYear], + ); + return ( } /> handleDateChange(date)} /> diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.test.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.test.tsx index 274475c25..1e49ff614 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.test.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.test.tsx @@ -295,6 +295,47 @@ describe('PersonModal', () => { expect(queryByText('Show Less')).not.toBeInTheDocument(), ); }); + + it('should show invalid dates and highlight them as errors', async () => { + const { getByText, getByRole, queryAllByText } = render( + + + + + + + + + + + , + ); + + const birthdayInput = getByRole('textbox', { name: 'Birthday' }); + expect(birthdayInput).toHaveValue('0/0/2000'); + expect(birthdayInput.parentElement).toHaveClass('Mui-error'); + + userEvent.click(queryAllByText('Show More')[0]); + await waitFor(() => expect(getByText('Show Less')).toBeInTheDocument()); + + const anniversaryInput = getByRole('textbox', { name: 'Anniversary' }); + expect(anniversaryInput).toHaveValue('0/0/2000'); + expect(anniversaryInput.parentElement).toHaveClass('Mui-error'); + }); + describe('Updating', () => { const createObjectURL = jest .fn() diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx index db627d099..e1ed438e4 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import BusinessIcon from '@mui/icons-material/Business'; import SchoolIcon from '@mui/icons-material/School'; import { @@ -20,11 +20,14 @@ import { PersonCreateInput, PersonUpdateInput, } from 'src/graphql/types.generated'; +import { useLocale } from 'src/hooks/useLocale'; +import { validateAndFormatInvalidDate } from 'src/lib/intlFormat'; import { RingIcon } from '../../../../../../RingIcon'; import { ModalSectionContainer } from '../ModalSectionContainer/ModalSectionContainer'; import { ModalSectionIcon } from '../ModalSectionIcon/ModalSectionIcon'; import { NewSocial } from '../PersonModal'; import { PersonSocial } from '../PersonSocials/PersonSocials'; +import { buildDate } from '../personModalHelper'; const DeceasedLabel = styled(FormControlLabel)(() => ({ margin: 'none', @@ -40,6 +43,11 @@ export const PersonShowMore: React.FC = ({ showDeceased = true, }) => { const { t } = useTranslation(); + const locale = useLocale(); + const [anniversaryDateIsInvalid, setAnniversaryDateIsInvalid] = + useState(false); + const [backupAnniversaryDate, setBackupAnniversaryDate] = + useState | null>(null); const { values: { @@ -58,11 +66,40 @@ export const PersonShowMore: React.FC = ({ setFieldValue, } = formikProps; + useEffect(() => { + if ( + typeof anniversaryMonth !== 'number' || + typeof anniversaryDay !== 'number' + ) { + return; + } + + const date = validateAndFormatInvalidDate( + anniversaryYear, + anniversaryMonth, + anniversaryDay, + locale, + ); + + setBackupAnniversaryDate( + date.formattedInvalidDate as unknown as DateTime, + ); + if (date.dateTime.invalidExplanation) { + setAnniversaryDateIsInvalid(true); + } + }, [anniversaryMonth, anniversaryDay, anniversaryYear]); + const handleDateChange = (date: DateTime | null) => { - setFieldValue('anniversaryDay', date?.day || null); - setFieldValue('anniversaryMonth', date?.month || null); - setFieldValue('anniversaryYear', date?.year || null); + setFieldValue('anniversaryDay', date?.day ?? null); + setFieldValue('anniversaryMonth', date?.month ?? null); + setFieldValue('anniversaryYear', date?.year ?? null); }; + + const anniversaryDate = useMemo( + () => buildDate(anniversaryMonth, anniversaryDay, anniversaryYear), + [anniversaryMonth, anniversaryDay, anniversaryYear], + ); + return ( <> {/* Legal First Name and Gender Section */} @@ -142,14 +179,11 @@ export const PersonShowMore: React.FC = ({ date && handleDateChange(date)} /> diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/personModalHelper.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/personModalHelper.tsx index ca9acbfb9..7f567b3c6 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/personModalHelper.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/personModalHelper.tsx @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon'; import { TFunction } from 'react-i18next'; import * as yup from 'yup'; import { @@ -263,3 +264,7 @@ export const formatSubmittedFields = ( ), }; }; + +export const buildDate = (month, day, year) => { + return month && day ? DateTime.local(year ?? 1900, month, day) : null; +}; diff --git a/src/components/common/DateTimePickers/CustomDateField.tsx b/src/components/common/DateTimePickers/CustomDateField.tsx index 8989b30d0..2bc625147 100644 --- a/src/components/common/DateTimePickers/CustomDateField.tsx +++ b/src/components/common/DateTimePickers/CustomDateField.tsx @@ -11,12 +11,13 @@ export const CustomDateField: React.FC = (props) => { const isDesktop = useMediaQuery(DEFAULT_DESKTOP_MODE_MEDIA_QUERY, { defaultMatches: true, }); + const { label, value, invalidDate, onChange, ...textFieldProps } = props; - if (isDesktop) { + // If value is not valid render desktop input as it can render invalid value + if (isDesktop || invalidDate) { return ; } - const { label, value, onChange, ...textFieldProps } = props; return ( label={label} diff --git a/src/components/common/DateTimePickers/DesktopDateField.test.tsx b/src/components/common/DateTimePickers/DesktopDateField.test.tsx index a8a8a544e..ba9dbcd55 100644 --- a/src/components/common/DateTimePickers/DesktopDateField.test.tsx +++ b/src/components/common/DateTimePickers/DesktopDateField.test.tsx @@ -9,6 +9,7 @@ import { DesktopDateField } from './DesktopDateField'; interface TestComponentProps { value?: DateTime | null; locale?: string; + invalidDate?: boolean; } const onChange = jest.fn(); @@ -16,10 +17,16 @@ const onChange = jest.fn(); const TestComponent: React.FC = ({ value = DateTime.local(2024, 1, 2, 3, 4, 5), locale = 'en-US', + invalidDate = false, }) => ( - + ); @@ -40,6 +47,30 @@ describe('DesktopDateField', () => { expect(getByRole('textbox')).toHaveValue(''); }); + it('shows invalid date when invalidDate prop is TRUE', () => { + const { getByRole, rerender } = render(); + rerender( + , + ); + + expect(getByRole('textbox')).toHaveValue('0/0/2000'); + }); + + it('shows default date with a invalid date when invalidDate prop is FALSE', () => { + const { getByRole, rerender } = render(); + rerender( + , + ); + + expect(getByRole('textbox')).toHaveValue('1/2/2024'); + }); + it('the formatted value', () => { const { getByRole } = render(); diff --git a/src/components/common/DateTimePickers/DesktopDateField.tsx b/src/components/common/DateTimePickers/DesktopDateField.tsx index 4353e5ba5..c829c3bdd 100644 --- a/src/components/common/DateTimePickers/DesktopDateField.tsx +++ b/src/components/common/DateTimePickers/DesktopDateField.tsx @@ -12,11 +12,13 @@ export interface DesktopDateFieldProps extends Omit { value: DateTime | null; onChange: (date: DateTime | null) => void; + invalidDate?: boolean; } export const DesktopDateField: React.FC = ({ value, onChange, + invalidDate, ...props }) => { const locale = useLocale(); @@ -30,6 +32,8 @@ export const DesktopDateField: React.FC = ({ setRawDate(''); } else if (value.isValid) { setRawDate(value.toFormat('D', options)); + } else if (invalidDate) { + setRawDate(value as unknown as string); } }, [locale, value]); diff --git a/src/lib/intlFormat.test.ts b/src/lib/intlFormat.test.ts index a94e4b0f1..f38bd8668 100644 --- a/src/lib/intlFormat.test.ts +++ b/src/lib/intlFormat.test.ts @@ -9,6 +9,7 @@ import { monthYearFormat, numberFormat, percentageFormat, + validateAndFormatInvalidDate, } from './intlFormat'; describe('intlFormat', () => { @@ -186,7 +187,19 @@ describe('intlFormat', () => { expect(date).toBeNull(); }); - it('returns if month is null', () => { + it('handle an invalid date', () => { + const date = dateFromParts(2000, 0, 0, locale); + + expect(date).toBe('0/0/2000 - Invalid Date, please fix.'); + }); + + it('handle an invalid date without a year', () => { + const date = dateFromParts(null, 0, 0, locale); + + expect(date).toBe('0/0/2020 - Invalid Date, please fix.'); + }); + + it('handle an invalid date where we can not format the invalid date', () => { const date = dateFromParts(0, 0, 2000, locale); expect(date).toBe( @@ -194,6 +207,24 @@ describe('intlFormat', () => { ); }); }); + + describe('validateAndFormatInvalidDate', () => { + const locale = 'en-US'; + it('returns invalid date en-US formatted', () => { + const date = validateAndFormatInvalidDate(2000, 0, 0, locale); + expect(date.formattedInvalidDate).toBe('0/0/2000'); + }); + + it('returns invalid date en-UK formatted', () => { + const date = validateAndFormatInvalidDate(2000, 0, 0, 'en-UK'); + expect(date.formattedInvalidDate).toBe('0/00/2000'); + }); + + it('returns invalid date de formatted', () => { + const date = validateAndFormatInvalidDate(2000, 0, 0, 'de'); + expect(date.formattedInvalidDate).toBe('0.0.2000'); + }); + }); //this test often fails locally. It passes on github. describe('dateTimeFormat', () => { const locale = 'en-US'; diff --git a/src/lib/intlFormat.ts b/src/lib/intlFormat.ts index 8b19e27b2..79632cfad 100644 --- a/src/lib/intlFormat.ts +++ b/src/lib/intlFormat.ts @@ -88,17 +88,17 @@ export const dateFromParts = ( return null; } - if (typeof year === 'number') { - const date = DateTime.local(year, month, day); - if (date.invalidReason || date.invalidExplanation) { - return `Invalid Date - ${date.invalidExplanation}`; + const date = validateAndFormatInvalidDate(year, month, day, locale); + if (date.dateTime.invalidExplanation) { + if (date.formattedInvalidDate) { + return `${date.formattedInvalidDate} - Invalid Date, please fix.`; + } else { + return `Invalid Date - ${date.dateTime.invalidExplanation}`; } - return dateFormat(date, locale); + } + if (typeof year === 'number') { + return dateFormat(date.dateTime, locale); } else { - const date = DateTime.local().set({ month, day }); - if (date.invalidReason || date.invalidExplanation) { - return `Invalid Date - ${date.invalidExplanation}`; - } return dayMonthFormat(day, month, locale); } }; @@ -120,6 +120,41 @@ export const dateTimeFormat = ( }).format(date.toJSDate()); }; +export const validateAndFormatInvalidDate = ( + year: number | null | undefined, + month: number, + day: number, + locale: string, +) => { + const yyyy = year ?? DateTime.local().year; + const date = DateTime.local(yyyy, month, day); + let formattedInvalidDate = ''; + if (date.invalidExplanation && month === 0 && day === 0) { + const placeholderYear = 2024; + const placeholderMonth = 8; + const placeholderDay = 15; + const placeholderDate = new Date( + placeholderYear, + placeholderMonth - 1, + placeholderDay, + ); + const formattedPlaceholderDate = dateFormatShort( + DateTime.fromISO(placeholderDate.toISOString()), + locale, + ); + + formattedInvalidDate = formattedPlaceholderDate + .replace(`${placeholderYear}`, `${yyyy}`) + .replace(`${placeholderMonth}`, `${month}`) + .replace(`${placeholderDay}`, `${day}`); + } + + return { + formattedInvalidDate, + dateTime: date, + }; +}; + const intlFormat = { numberFormat, percentageFormat,