fix(SW-649): let react-hook-form do date state handling

This commit is contained in:
Chuma McPhoy
2024-10-30 13:03:23 +01:00
parent 7ed2e1d5d0
commit 8763346f9d
4 changed files with 86 additions and 86 deletions

View File

@@ -23,7 +23,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({ z.object({
join: z.literal(true), join: z.literal(true),
zipCode: z.string().min(1, { message: "Zip code is required" }), 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, { termsAccepted: z.literal(true, {
errorMap: (err, ctx) => { errorMap: (err, ctx) => {
switch (err.code) { switch (err.code) {

View File

@@ -18,3 +18,12 @@
.year { .year {
grid-area: 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);
}

View File

@@ -2,6 +2,7 @@ import type { RegisterOptions } from "react-hook-form"
export const enum DateName { export const enum DateName {
date = "date", date = "date",
day = "day",
month = "month", month = "month",
year = "year", year = "year",
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { parseDate } from "@internationalized/date" import { parseDate } from "@internationalized/date"
import { useState } from "react" import { useEffect } from "react"
import { DateInput, DatePicker, Group } from "react-aria-components" import { DateInput, DatePicker, Group } from "react-aria-components"
import { useController, useFormContext, useWatch } from "react-hook-form" import { useController, useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -21,37 +21,24 @@ import type { DateProps } from "./date"
export default function DateSelect({ name, registerOptions = {} }: DateProps) { export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const intl = useIntl() const intl = useIntl()
const currentValue = useWatch({ name }) const { control, setValue, trigger, formState, watch } = useFormContext()
const { control, setValue, trigger, formState } = useFormContext() const { field, fieldState } = useController({
const { field } = useController({
control, control,
name, name,
rules: registerOptions, rules: registerOptions,
}) })
const dayLabel = intl.formatMessage({ id: "Day" }) const currentDateValue = useWatch({ name })
const monthLabel = intl.formatMessage({ id: "Month" }) const year = watch(DateName.year)
const yearLabel = intl.formatMessage({ id: "Year" }) const month = watch(DateName.month)
const day = watch(DateName.day)
const initialDate = dt(currentValue)
const [selectedYear, setSelectedYear] = useState<number | null>(
initialDate.isValid() ? initialDate.year() : null
)
const [selectedMonth, setSelectedMonth] = useState<number | null>(
initialDate.isValid() ? initialDate.month() : null
)
const [selectedDay, setSelectedDay] = useState<number | null>(
initialDate.isValid() ? initialDate.date() : null
)
const currentYear = new Date().getFullYear()
const months = rangeArray(1, 12).map((month) => ({ const months = rangeArray(1, 12).map((month) => ({
value: month, value: month,
label: `${month}`, label: `${month}`,
})) }))
const currentYear = new Date().getFullYear()
const years = rangeArray(1900, currentYear - 18) const years = rangeArray(1900, currentYear - 18)
.reverse() .reverse()
.map((year) => ({ value: year, label: year.toString() })) .map((year) => ({ value: year, label: year.toString() }))
@@ -60,58 +47,61 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
if (month === null) { if (month === null) {
return 31 return 31
} }
// If month is February and no year selected, return minimum.
if (month === 1 && !year) {
return 28
}
const yearToUse = year ?? new Date().getFullYear() const yearToUse = year ?? new Date().getFullYear()
return dt(`${yearToUse}-${month + 1}-01`).daysInMonth() return dt(`${yearToUse}-${month + 1}-01`).daysInMonth()
} }
const days = rangeArray(1, getDaysInMonth(selectedYear, selectedMonth)).map( // Calculate available days based on selected year and month
(day) => ({ const daysInMonth = getDaysInMonth(
value: day, year ? Number(year) : null,
label: `${day}`, month ? Number(month) - 1 : null
})
) )
function handleSegmentChange(selector: DateName, value: number) { const days = rangeArray(1, daysInMonth).map((day) => ({
let newYear = selectedYear value: day,
let newMonth = selectedMonth label: `${day}`,
let newDay = selectedDay }))
switch (selector) { const dayLabel = intl.formatMessage({ id: "Day" })
case DateName.year: const monthLabel = intl.formatMessage({ id: "Month" })
newYear = value const yearLabel = intl.formatMessage({ id: "Year" })
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
}
// Check if all segments are set and update form value. useEffect(() => {
if (newYear && newMonth !== null && newDay) { if (formState.isSubmitting) return
const newDate = dt().year(newYear).month(newMonth).date(newDay)
if (newDate.isValid()) { if (month && day) {
setValue(name, newDate.format("YYYY-MM-DD")) const maxDays = getDaysInMonth(
trigger(name) 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 let dateValue = null
try { try {
@@ -120,7 +110,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
* date, but we can't check isNan since * date, but we can't check isNan since
* we recieve the date as "1999-01-01" * we recieve the date as "1999-01-01"
*/ */
dateValue = dt(currentValue).isValid() ? parseDate(currentValue) : null dateValue = dt(currentDateValue).isValid()
? parseDate(currentDateValue)
: null
} catch (error) { } catch (error) {
console.warn("Known error for parse date in DateSelect: ", error) console.warn("Known error for parse date in DateSelect: ", error)
} }
@@ -129,6 +121,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
<DatePicker <DatePicker
aria-label={intl.formatMessage({ id: "Select date of birth" })} aria-label={intl.formatMessage({ id: "Select date of birth" })}
isRequired={!!registerOptions.required} isRequired={!!registerOptions.required}
isInvalid={!formState.isValid}
name={name} name={name}
ref={field.ref} ref={field.ref}
value={dateValue} value={dateValue}
@@ -140,63 +133,60 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
switch (segment.type) { switch (segment.type) {
case "day": case "day":
return ( return (
<div className={styles.day}> <div
className={`${styles.day} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select <Select
aria-label={dayLabel} aria-label={dayLabel}
items={days} items={days}
label={dayLabel} label={dayLabel}
name={DateName.date} name={DateName.day}
onSelect={(select: Key) => onSelect={(key: Key) =>
handleSegmentChange(DateName.date, Number(select)) setValue(DateName.day, Number(key))
} }
placeholder="DD" placeholder={dayLabel}
required required
tabIndex={3} tabIndex={3}
defaultSelectedKey={
segment.isPlaceholder ? undefined : segment.value
}
value={segment.isPlaceholder ? undefined : segment.value} value={segment.isPlaceholder ? undefined : segment.value}
/> />
</div> </div>
) )
case "month": case "month":
return ( return (
<div className={styles.month}> <div
className={`${styles.month} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select <Select
aria-label={monthLabel} aria-label={monthLabel}
items={months} items={months}
label={monthLabel} label={monthLabel}
name={DateName.month} name={DateName.month}
onSelect={(select: Key) => onSelect={(key: Key) =>
handleSegmentChange(DateName.month, Number(select)) setValue(DateName.month, Number(key))
} }
placeholder="MM" placeholder={monthLabel}
required required
tabIndex={2} tabIndex={2}
defaultSelectedKey={
segment.isPlaceholder ? undefined : segment.value
}
value={segment.isPlaceholder ? undefined : segment.value} value={segment.isPlaceholder ? undefined : segment.value}
/> />
</div> </div>
) )
case "year": case "year":
return ( return (
<div className={styles.year}> <div
className={`${styles.year} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select <Select
aria-label={yearLabel} aria-label={yearLabel}
items={years} items={years}
label={yearLabel} label={yearLabel}
name={DateName.year} name={DateName.year}
onSelect={(select: Key) => onSelect={(key: Key) =>
handleSegmentChange(DateName.year, Number(select)) setValue(DateName.year, Number(key))
} }
placeholder="YYYY" placeholder={yearLabel}
required required
tabIndex={1} tabIndex={1}
defaultSelectedKey={
segment.isPlaceholder ? undefined : segment.value
}
value={segment.isPlaceholder ? undefined : segment.value} value={segment.isPlaceholder ? undefined : segment.value}
/> />
</div> </div>