Skip to content

Commit

Permalink
🐛 [release-0.2] Fix state management and validation of date pickers i…
Browse files Browse the repository at this point in the history
…n migration wave modal (#1110)

Currently, the date pickers in the create/edit migration wave modal
misbehave when the user attempts to enter a date string manually.
* The required MM/DD/YYYY format is not enforced.
* Invalid date formats are coerced to the nearest matching date, which
leads to inaccurate validation running before the user is done typing.
* Entering a start date that is out of the allowable range (before the
current day) disables the field.

The problem seemed to mostly stem from the fact that these dates were
stored in form state as `Date` objects, which made it impossible to
reference the user's entered string in validation if it was formatted
incorrectly. This PR changes the form state to use strings instead of
Dates, updates the validation accordingly, and transforms the strings
into the correct format on submit.

Note also that the interdependent validation has been changed: before,
there was a validation error on the start date if it was after the end
date AND a validation error on the end date if it was before the start
date. Since these validations have been moved to use `.when` and `.test`
(because `.min` with `yup.ref` is unreliable when the value could be in
an invalid format), yup gives an error if there is a circular dependency
in validation. To address this, only the end date field is validated to
enforce the requirement that it is after the start date, and if the
start date is changed, validation is re-triggered on the end date.

Signed-off-by: Mike Turley <[email protected]>
  • Loading branch information
mturley authored Jul 11, 2023
1 parent 27b0f53 commit e0b0db3
Showing 1 changed file with 87 additions and 73 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "@app/queries/migration-waves";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import customParseFormat from "dayjs/plugin/customParseFormat";
import {
Stakeholder,
StakeholderGroup,
Expand All @@ -39,6 +40,7 @@ import { OptionWithValue, SimpleSelect } from "@app/shared/components";
import { NotificationsContext } from "@app/shared/notifications-context";
import { DEFAULT_SELECT_MAX_HEIGHT } from "@app/Constants";
dayjs.extend(utc);
dayjs.extend(customParseFormat);

const stakeholderGroupToOption = (
value: StakeholderGroup
Expand All @@ -59,8 +61,8 @@ const stakeholderToOption = (

interface WaveFormValues {
name?: string;
startDate: Date | null;
endDate: Date | null;
startDateStr: string;
endDateStr: string;
stakeholders: Stakeholder[];
stakeholderGroups: StakeholderGroup[];
}
Expand Down Expand Up @@ -131,67 +133,86 @@ export const WaveForm: React.FC<WaveFormProps> = ({
onUpdateMigrationWaveError
);

const dateStrFormatValidator = (dateStr: string) =>
dayjs(dateStr, "MM/DD/YYYY", true).isValid();

const validationSchema: yup.SchemaOf<WaveFormValues> = yup.object().shape({
name: yup
.string()
.trim()
.max(120, t("validation.maxLength", { length: 120 })),
startDate: yup
.date()
.when([], {
is: () => !!!migrationWave?.startDate,
then: yup
.date()
.min(dayjs().toDate(), "Start date can be no sooner than today"),
otherwise: yup.date(),
})
.when([], {
is: () => !!migrationWave?.endDate,
then: yup
.date()
.max(yup.ref("endDate"), "Start date must be before end date"),
otherwise: yup.date(),
})
.required(t("validation.required")),
endDate: yup
.date()
.min(yup.ref("startDate"), "End date must be after start date")
.required(t("validation.required")),
startDateStr: yup
.string()
.required(t("validation.required"))
.test(
"isValidFormat",
"Date must be formatted as MM/DD/YYYY",
(value) => !!value && dateStrFormatValidator(value)
)
.test(
"noSoonerThanToday",
"Start date can be no sooner than today",
(value) => !dayjs(value).isBefore(dayjs(), "day")
),
endDateStr: yup
.string()
.required(t("validation.required"))
.test(
"isValidFormat",
"Date must be formatted as MM/DD/YYYY",
(value) => !!value && dateStrFormatValidator(value)
)
.when("startDateStr", (startDateStr, schema: yup.StringSchema) =>
schema.test(
"afterStartDate",
"End date must be after start date",
(value) =>
!startDateStr || dayjs(value).isAfter(dayjs(startDateStr), "day")
)
),
stakeholders: yup.array(),
stakeholderGroups: yup.array(),
});

const {
handleSubmit,
formState: { isSubmitting, isValidating, isValid, isDirty },
getValues,
formState: {
isSubmitting,
isValidating,
isValid,
isDirty,
errors: formErrors,
},
control,
watch,
trigger,
} = useForm<WaveFormValues>({
mode: "onChange",
defaultValues: {
name: migrationWave?.name || "",
startDate: migrationWave?.startDate
? dayjs(migrationWave.startDate).toDate()
: null,
endDate: migrationWave?.endDate
? dayjs(migrationWave.endDate).toDate()
: null,
startDateStr: migrationWave?.startDate
? dayjs(migrationWave.startDate).format("MM/DD/YYYY")
: "",
endDateStr: migrationWave?.endDate
? dayjs(migrationWave.endDate).format("MM/DD/YYYY")
: "",
stakeholders: migrationWave?.stakeholders || [],
stakeholderGroups: migrationWave?.stakeholderGroups || [],
},
resolver: yupResolver(validationSchema),
});

const startDate = watch("startDate");
const endDate = getValues("endDate");
const startDateStr = watch("startDateStr");
const startDate = dateStrFormatValidator(startDateStr)
? dayjs(startDateStr).toDate()
: null;

const onSubmit = (formValues: WaveFormValues) => {
const payload: New<MigrationWave> = {
applications: migrationWave?.applications || [],
name: formValues.name?.trim() || "",
startDate: dayjs.utc(formValues.startDate).format(),
endDate: dayjs.utc(formValues.endDate).format(),
startDate: dayjs(formValues.startDateStr).format(),
endDate: dayjs(formValues.endDateStr).format(),
stakeholders: formValues.stakeholders,
stakeholderGroups: formValues.stakeholderGroups,
};
Expand All @@ -205,15 +226,15 @@ export const WaveForm: React.FC<WaveFormProps> = ({
onClose();
};

const startDateValidator = (date: Date) => {
const startDateRangeValidator = (date: Date) => {
if (date < dayjs().toDate()) {
return "Date is before allowable range.";
}
return "";
};

const endDateValidator = (date: Date) => {
const sDate = getValues("startDate") || new Date();
const endDateRangeValidator = (date: Date) => {
const sDate = startDate || new Date();
if (sDate >= date) {
return "Date is before allowable range.";
}
Expand All @@ -236,34 +257,29 @@ export const WaveForm: React.FC<WaveFormProps> = ({
<GridItem span={5}>
<HookFormPFGroupController
control={control}
name="startDate"
name="startDateStr"
label="Potential Start Date"
fieldId="startDate"
fieldId="startDateStr"
isRequired
renderInput={({ field: { value, name, onChange } }) => {
const startDateValue = value
? dayjs(value).format("MM/DD/YYYY")
: "";
return (
<DatePicker
aria-label={name}
onChange={(e, val, date) => {
onChange(date);
}}
placeholder="MM/DD/YYYY"
value={startDateValue}
dateFormat={(val) => dayjs(val).format("MM/DD/YYYY")}
dateParse={(val) => dayjs(val).toDate()}
validators={[startDateValidator]}
appendTo={() =>
document.getElementById(
"create-edit-migration-wave-modal"
) as HTMLElement
}
isDisabled={dayjs(value).isBefore(dayjs())}
/>
);
}}
renderInput={({ field: { value, name, onChange } }) => (
<DatePicker
aria-label={name}
onChange={(e, val) => {
onChange(val);
trigger("endDateStr"); // Validation of endDateStr depends on startDateStr
}}
placeholder="MM/DD/YYYY"
value={value}
dateFormat={(val) => dayjs(val).format("MM/DD/YYYY")}
dateParse={(val) => dayjs(val).toDate()}
validators={[startDateRangeValidator]}
appendTo={() =>
document.getElementById(
"create-edit-migration-wave-modal"
) as HTMLElement
}
/>
)}
/>
</GridItem>
</LevelItem>
Expand All @@ -274,29 +290,27 @@ export const WaveForm: React.FC<WaveFormProps> = ({
<GridItem span={5}>
<HookFormPFGroupController
control={control}
name="endDate"
name="endDateStr"
label="Potential End Date"
fieldId="endDate"
fieldId="endDateStr"
isRequired
renderInput={({ field: { value, name, onChange } }) => (
<DatePicker
aria-label={name}
onChange={(e, val, date) => {
onChange(date);
onChange={(e, val) => {
onChange(val);
}}
placeholder="MM/DD/YYYY"
value={endDate ? dayjs(endDate).format("MM/DD/YYYY") : ""}
value={value}
dateFormat={(val) => dayjs(val).format("MM/DD/YYYY")}
dateParse={(val) => dayjs(val).toDate()}
validators={[endDateValidator]}
validators={[endDateRangeValidator]}
appendTo={() =>
document.getElementById(
"create-edit-migration-wave-modal"
) as HTMLElement
}
isDisabled={
!startDate || dayjs(startDate).isBefore(dayjs())
}
isDisabled={!!formErrors.startDateStr}
/>
)}
/>
Expand Down

0 comments on commit e0b0db3

Please sign in to comment.