From 33fd052c041d882410ffc7c393b7943070aafe76 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Tue, 29 Oct 2024 09:02:34 +0100 Subject: [PATCH 01/24] refactor(SW-649): DateSelect component state refactoring --- .../TempDesignSystem/Form/Date/index.tsx | 90 ++++++++++++++----- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index 7441ff471..8f5e7347a 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -28,6 +28,22 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { rules: registerOptions, }) + const dayLabel = intl.formatMessage({ id: "Day" }) + const monthLabel = intl.formatMessage({ id: "Month" }) + const yearLabel = intl.formatMessage({ id: "Year" }) + + const initialDate = dt(currentValue) + + const [selectedYear, setSelectedYear] = useState( + initialDate.isValid() ? initialDate.year() : null + ) + const [selectedMonth, setSelectedMonth] = useState( + initialDate.isValid() ? initialDate.month() : null + ) + const [selectedDay, setSelectedDay] = useState( + initialDate.isValid() ? initialDate.date() : null + ) + const currentYear = new Date().getFullYear() const months = rangeArray(1, 12).map((month) => ({ @@ -39,32 +55,60 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { .reverse() .map((year) => ({ value: year, label: year.toString() })) - // Ensure the user can't select a date that doesn't exist. - const daysInMonth = dt(currentValue).daysInMonth() + // Get max days based on selected month/year + const daysInMonth = + selectedMonth !== null && selectedYear !== null + ? dt(`${selectedYear}-${selectedMonth + 1}-01`).daysInMonth() + : 31 + const days = rangeArray(1, daysInMonth).map((day) => ({ value: day, label: `${day}`, })) - function createOnSelect(selector: DateName) { - /** - * Months are 0 index based and therefore we - * must subtract by 1 to get the selected month - */ - return (select: Key) => { - if (selector === DateName.month) { - select = Number(select) - 1 + function handleSegmentChange(selector: DateName, value: number) { + let newYear = selectedYear + let newMonth = selectedMonth + let newDay = selectedDay + + switch (selector) { + case DateName.year: + newYear = value + setSelectedYear(newYear) + break + /** + * Months are 0 index based and therefore we + * must subtract by 1 to get the selected month + */ + case DateName.month: + const monthZeroBased = value - 1 + newMonth = monthZeroBased + setSelectedMonth(newMonth) + if (selectedDay) { + const maxDays = dt(`${newYear}-${value}-01`).daysInMonth() + if (selectedDay > maxDays) { + newDay = maxDays + setSelectedDay(newDay) + } + } + break + case DateName.date: + newDay = value + setSelectedDay(newDay) + break + } + + // Check if all segments are set and update form value. + if (newYear && newMonth !== null && newDay) { + const newDate = dt().year(newYear).month(newMonth).date(newDay) + + if (newDate.isValid()) { + setValue(name, newDate.format("YYYY-MM-DD")) + trigger(name) } - const newDate = dt(currentValue).set(selector, Number(select)) - setValue(name, newDate.format("YYYY-MM-DD")) - trigger(name) } } - const dayLabel = intl.formatMessage({ id: "Day" }) - const monthLabel = intl.formatMessage({ id: "Month" }) - const yearLabel = intl.formatMessage({ id: "Year" }) - let dateValue = null try { /** @@ -98,7 +142,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { items={days} label={dayLabel} name={DateName.date} - onSelect={createOnSelect(DateName.date)} + onSelect={(select: Key) => + handleSegmentChange(DateName.date, Number(select)) + } placeholder="DD" required tabIndex={3} @@ -117,7 +163,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { items={months} label={monthLabel} name={DateName.month} - onSelect={createOnSelect(DateName.month)} + onSelect={(select: Key) => + handleSegmentChange(DateName.month, Number(select)) + } placeholder="MM" required tabIndex={2} @@ -136,7 +184,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { items={years} label={yearLabel} name={DateName.year} - onSelect={createOnSelect(DateName.year)} + onSelect={(select: Key) => + handleSegmentChange(DateName.year, Number(select)) + } placeholder="YYYY" required tabIndex={1} From aa20c3569bdcb46a0fce5825b2d2e9128e029f19 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Tue, 29 Oct 2024 10:32:47 +0100 Subject: [PATCH 02/24] fix(SW-667): add ErrorMessage --- components/TempDesignSystem/Form/Date/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index 8f5e7347a..d697cff34 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -10,6 +10,7 @@ import { dt } from "@/lib/dt" import Select from "@/components/TempDesignSystem/Select" import { rangeArray } from "@/utils/rangeArray" +import ErrorMessage from "../ErrorMessage" import { DateName } from "./date" import styles from "./date.module.css" @@ -21,7 +22,7 @@ import type { DateProps } from "./date" export default function DateSelect({ name, registerOptions = {} }: DateProps) { const intl = useIntl() const currentValue = useWatch({ name }) - const { control, setValue, trigger } = useFormContext() + const { control, setValue, trigger, formState } = useFormContext() const { field } = useController({ control, name, @@ -204,6 +205,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { }} + ) } From 6a31aca0b1d9b9a74ef1f0d20b4af970af5e4319 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Tue, 29 Oct 2024 12:50:45 +0100 Subject: [PATCH 03/24] fix(SW-649): remove superfluous variable --- components/TempDesignSystem/Form/Date/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index d697cff34..3f56edcfb 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -56,7 +56,6 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { .reverse() .map((year) => ({ value: year, label: year.toString() })) - // Get max days based on selected month/year const daysInMonth = selectedMonth !== null && selectedYear !== null ? dt(`${selectedYear}-${selectedMonth + 1}-01`).daysInMonth() @@ -82,8 +81,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { * must subtract by 1 to get the selected month */ case DateName.month: - const monthZeroBased = value - 1 - newMonth = monthZeroBased + newMonth = value - 1 setSelectedMonth(newMonth) if (selectedDay) { const maxDays = dt(`${newYear}-${value}-01`).daysInMonth() From 7ed2e1d5d0a13c070c5bdaa42359654ec857e115 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Tue, 29 Oct 2024 15:23:36 +0100 Subject: [PATCH 04/24] refactor(SW-649): reusable getDaysInMonth function --- components/Forms/Signup/schema.ts | 4 +++- .../TempDesignSystem/Form/Date/index.tsx | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/components/Forms/Signup/schema.ts b/components/Forms/Signup/schema.ts index 6a8eecc22..2962d9b90 100644 --- a/components/Forms/Signup/schema.ts +++ b/components/Forms/Signup/schema.ts @@ -16,7 +16,9 @@ export const signUpSchema = z.object({ "Phone is required", "Please enter a valid phone number" ), - dateOfBirth: z.string().min(1), + dateOfBirth: z.string().min(1, { + message: "Date of birth is required", + }), address: z.object({ countryCode: z .string({ diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index 3f56edcfb..c2754bb13 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -56,15 +56,20 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { .reverse() .map((year) => ({ value: year, label: year.toString() })) - const daysInMonth = - selectedMonth !== null && selectedYear !== null - ? dt(`${selectedYear}-${selectedMonth + 1}-01`).daysInMonth() - : 31 + function getDaysInMonth(year: number | null, month: number | null): number { + if (month === null) { + return 31 + } + const yearToUse = year ?? new Date().getFullYear() + return dt(`${yearToUse}-${month + 1}-01`).daysInMonth() + } - const days = rangeArray(1, daysInMonth).map((day) => ({ - value: day, - label: `${day}`, - })) + const days = rangeArray(1, getDaysInMonth(selectedYear, selectedMonth)).map( + (day) => ({ + value: day, + label: `${day}`, + }) + ) function handleSegmentChange(selector: DateName, value: number) { let newYear = selectedYear @@ -84,7 +89,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { newMonth = value - 1 setSelectedMonth(newMonth) if (selectedDay) { - const maxDays = dt(`${newYear}-${value}-01`).daysInMonth() + const maxDays = getDaysInMonth(newYear, newMonth) if (selectedDay > maxDays) { newDay = maxDays setSelectedDay(newDay) From 8763346f9d3520b7d74bf435a9ba2724771217be Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Wed, 30 Oct 2024 13:03:23 +0100 Subject: [PATCH 05/24] fix(SW-649): let react-hook-form do date state handling --- .../EnterDetails/Details/schema.ts | 2 +- .../Form/Date/date.module.css | 9 + components/TempDesignSystem/Form/Date/date.ts | 1 + .../TempDesignSystem/Form/Date/index.tsx | 160 ++++++++---------- 4 files changed, 86 insertions(+), 86 deletions(-) diff --git a/components/HotelReservation/EnterDetails/Details/schema.ts b/components/HotelReservation/EnterDetails/Details/schema.ts index 2e01eefe3..e6bd0dd41 100644 --- a/components/HotelReservation/EnterDetails/Details/schema.ts +++ b/components/HotelReservation/EnterDetails/Details/schema.ts @@ -23,7 +23,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge( z.object({ join: z.literal(true), zipCode: z.string().min(1, { message: "Zip code is required" }), - dateOfBirth: z.string(), + dateOfBirth: z.string().min(1, { message: "Date of birth is required" }), termsAccepted: z.literal(true, { errorMap: (err, ctx) => { switch (err.code) { diff --git a/components/TempDesignSystem/Form/Date/date.module.css b/components/TempDesignSystem/Form/Date/date.module.css index 6c0549313..fde0b7e03 100644 --- a/components/TempDesignSystem/Form/Date/date.module.css +++ b/components/TempDesignSystem/Form/Date/date.module.css @@ -18,3 +18,12 @@ .year { grid-area: year; } + +/* TODO: Handle this in Select component. + - out of scope for now. +*/ +.day.invalid > div > div, +.month.invalid > div > div, +.year.invalid > div > div { + border-color: var(--Scandic-Red-60); +} diff --git a/components/TempDesignSystem/Form/Date/date.ts b/components/TempDesignSystem/Form/Date/date.ts index 25eff31c7..8f17d4607 100644 --- a/components/TempDesignSystem/Form/Date/date.ts +++ b/components/TempDesignSystem/Form/Date/date.ts @@ -2,6 +2,7 @@ import type { RegisterOptions } from "react-hook-form" export const enum DateName { date = "date", + day = "day", month = "month", year = "year", } diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index c2754bb13..36a0ff44a 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -1,6 +1,6 @@ "use client" import { parseDate } from "@internationalized/date" -import { useState } from "react" +import { useEffect } from "react" import { DateInput, DatePicker, Group } from "react-aria-components" import { useController, useFormContext, useWatch } from "react-hook-form" import { useIntl } from "react-intl" @@ -21,37 +21,24 @@ import type { DateProps } from "./date" export default function DateSelect({ name, registerOptions = {} }: DateProps) { const intl = useIntl() - const currentValue = useWatch({ name }) - const { control, setValue, trigger, formState } = useFormContext() - const { field } = useController({ + const { control, setValue, trigger, formState, watch } = useFormContext() + const { field, fieldState } = useController({ control, name, rules: registerOptions, }) - const dayLabel = intl.formatMessage({ id: "Day" }) - const monthLabel = intl.formatMessage({ id: "Month" }) - const yearLabel = intl.formatMessage({ id: "Year" }) - - const initialDate = dt(currentValue) - - const [selectedYear, setSelectedYear] = useState( - initialDate.isValid() ? initialDate.year() : null - ) - const [selectedMonth, setSelectedMonth] = useState( - initialDate.isValid() ? initialDate.month() : null - ) - const [selectedDay, setSelectedDay] = useState( - initialDate.isValid() ? initialDate.date() : null - ) - - const currentYear = new Date().getFullYear() + const currentDateValue = useWatch({ name }) + const year = watch(DateName.year) + const month = watch(DateName.month) + const day = watch(DateName.day) const months = rangeArray(1, 12).map((month) => ({ value: month, label: `${month}`, })) + const currentYear = new Date().getFullYear() const years = rangeArray(1900, currentYear - 18) .reverse() .map((year) => ({ value: year, label: year.toString() })) @@ -60,58 +47,61 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { if (month === null) { return 31 } + + // If month is February and no year selected, return minimum. + if (month === 1 && !year) { + return 28 + } + const yearToUse = year ?? new Date().getFullYear() return dt(`${yearToUse}-${month + 1}-01`).daysInMonth() } - const days = rangeArray(1, getDaysInMonth(selectedYear, selectedMonth)).map( - (day) => ({ - value: day, - label: `${day}`, - }) + // Calculate available days based on selected year and month + const daysInMonth = getDaysInMonth( + year ? Number(year) : null, + month ? Number(month) - 1 : null ) - function handleSegmentChange(selector: DateName, value: number) { - let newYear = selectedYear - let newMonth = selectedMonth - let newDay = selectedDay + const days = rangeArray(1, daysInMonth).map((day) => ({ + value: day, + label: `${day}`, + })) - switch (selector) { - case DateName.year: - newYear = value - setSelectedYear(newYear) - break - /** - * Months are 0 index based and therefore we - * must subtract by 1 to get the selected month - */ - case DateName.month: - newMonth = value - 1 - setSelectedMonth(newMonth) - if (selectedDay) { - const maxDays = getDaysInMonth(newYear, newMonth) - if (selectedDay > maxDays) { - newDay = maxDays - setSelectedDay(newDay) - } - } - break - case DateName.date: - newDay = value - setSelectedDay(newDay) - break - } + const dayLabel = intl.formatMessage({ id: "Day" }) + const monthLabel = intl.formatMessage({ id: "Month" }) + const yearLabel = intl.formatMessage({ id: "Year" }) - // Check if all segments are set and update form value. - if (newYear && newMonth !== null && newDay) { - const newDate = dt().year(newYear).month(newMonth).date(newDay) + useEffect(() => { + if (formState.isSubmitting) return - if (newDate.isValid()) { - setValue(name, newDate.format("YYYY-MM-DD")) - trigger(name) + if (month && day) { + const maxDays = getDaysInMonth( + year ? Number(year) : null, + Number(month) - 1 + ) + const adjustedDay = Number(day) > maxDays ? maxDays : Number(day) + + if (adjustedDay !== Number(day)) { + setValue(DateName.day, adjustedDay) } } - } + + if (year && month && day) { + const newDate = dt() + .year(Number(year)) + .month(Number(month) - 1) + .date(Number(day)) + + if (newDate.isValid()) { + setValue(name, newDate.format("YYYY-MM-DD"), { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } + } + }, [year, month, day, setValue, name, formState.isSubmitting]) let dateValue = null try { @@ -120,7 +110,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { * date, but we can't check isNan since * we recieve the date as "1999-01-01" */ - dateValue = dt(currentValue).isValid() ? parseDate(currentValue) : null + dateValue = dt(currentDateValue).isValid() + ? parseDate(currentDateValue) + : null } catch (error) { console.warn("Known error for parse date in DateSelect: ", error) } @@ -129,6 +121,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { +
- handleSegmentChange(DateName.month, Number(select)) + onSelect={(key: Key) => + setValue(DateName.month, Number(key)) } - placeholder="MM" + placeholder={monthLabel} required tabIndex={2} - defaultSelectedKey={ - segment.isPlaceholder ? undefined : segment.value - } value={segment.isPlaceholder ? undefined : segment.value} />
) case "year": return ( -
+