Make date input more reliable by handling invalid dates and different browser behaviours

This commit is contained in:
Remmy Cat Stock 2022-11-01 00:53:41 +01:00 committed by Remmy Cat Stock
parent 27a427a81b
commit b4f8e4402e
3 changed files with 94 additions and 80 deletions

View File

@ -2,62 +2,68 @@ import { useIsMounted } from "~/hooks/useIsMounted";
import { dateToYearMonthDayHourMinuteString, isValidDate } from "~/utils/dates";
import * as React from "react";
export interface DateInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"defaultValue" | "min" | "max" | "onChange" | "value"
> {
defaultValue?: Date;
min?: Date;
max?: Date;
onChange?: (newDate: Date | null) => void;
}
export function DateInput({
id,
name,
defaultValue,
min,
max,
required,
onChange,
}: {
id?: string;
name?: string;
defaultValue?: Date;
min?: Date;
max?: Date;
required?: boolean;
onChange?: (newDate: Date) => void;
}) {
const [date, setDate] = React.useState(defaultValue ?? new Date());
...inputProps
}: DateInputProps) {
// Keeping track of the value as a string is a nice fallback for browsers that
// don't show a date picker but actually expect the user to type in the date
// as a text. This was Safari Desktop until recently, but nowadays all current
// versions of the main browsers set the input to either a valid date string
// or "". (The browser will handle transitional invalid states internally).
const [[parsedDate, valueString], setDate] = React.useState<
[Date | null, string]
>(() => {
if (defaultValue) {
if (isValidDate(defaultValue)) {
return [defaultValue, dateToYearMonthDayHourMinuteString(defaultValue)];
}
console.warn("DateInput got invalid date as defaultValue");
}
return [null, ""];
});
const isMounted = useIsMounted();
if (!isMounted) {
return (
<input
id={id}
type="datetime-local"
name={name}
required={required}
disabled
/>
);
}
return (
<>
{date && <input name={name} type="hidden" value={date.getTime()} />}
{parsedDate && isMounted && (
<input name={name} type="hidden" value={parsedDate.getTime() ?? ""} />
)}
<input
id={id}
{...inputProps}
type="datetime-local"
value={dateToYearMonthDayHourMinuteString(date)}
disabled={!isMounted || inputProps.disabled}
// This is important, because SSR will likely have a date in the wrong
// timezone. We can only fill in a value once hydration is over.
value={isMounted ? valueString : ""}
min={min ? dateToYearMonthDayHourMinuteString(min) : undefined}
max={max ? dateToYearMonthDayHourMinuteString(max) : undefined}
onChange={(e) => {
//TODO: fix invalid Date Input handling: https://github.com/Sendouc/sendou.ink/issues/1082
const updatedDate = new Date(e.target.value);
if (!isValidDate(updatedDate)) {
console.warn("Invalid date");
// throw new RangeError("Invalid Date");
}
setDate(updatedDate);
const newValueString = e.target.value;
const parsedValue = new Date(newValueString);
const newDate = isValidDate(parsedValue) ? parsedValue : null;
// Update the correct entry in the React hook from the parent via the passed on callback function
if (typeof onChange !== "undefined") {
onChange(updatedDate);
}
setDate([newDate, newValueString]);
onChange?.(newDate);
}}
required={required}
// Firefox fix for hydration error "prop `disabled` did not match" */
// https://github.com/facebook/react/issues/21459
autoComplete="off"
/>
</>
);

View File

@ -55,6 +55,7 @@ import {
} from "~/utils/zod";
import { MapPoolSelector } from "~/components/MapPoolSelector";
import { Tags } from "./components/Tags";
import { isDefined } from "~/utils/arrays";
const MIN_DATE = new Date(Date.UTC(2015, 4, 28));
@ -290,22 +291,29 @@ function DatesInput() {
const { t } = useTranslation(["common", "calendar"]);
const { eventToEdit } = useLoaderData<typeof loader>();
// Initialize datesInputState by retrieving pre-existing events if they exist
let eventDatesInputState = null;
if (typeof eventToEdit?.startTimes !== "undefined") {
eventDatesInputState = eventToEdit.startTimes.map((t) => {
return { finalDateInputDate: databaseTimestampToDate(t) };
});
}
// Using array index as a key can mess up internal state, especially when
// removing elements from the middle. So we just count up for every date we
// create.
const keyCounter = React.useRef(0);
const getKey = () => ++keyCounter.current;
// React hook that keeps contains an array of parameters that corresponds to each DateInput child object generated
const [datesInputState, setDatesInputState] = React.useState(
eventDatesInputState ?? [
{
finalDateInputDate: new Date(),
},
]
);
// React hook that keeps track of child DateInput's dates
// (necessary for determining additional Date's defaultValues)
const [datesInputState, setDatesInputState] = React.useState<
Array<{
key: number;
date: Date | null;
}>
>(() => {
// Initialize datesInputState by retrieving pre-existing events if they exist
if (eventToEdit?.startTimes) {
return eventToEdit.startTimes.map((t) => ({
key: getKey(),
date: databaseTimestampToDate(t),
}));
}
return [{ key: getKey(), date: new Date() }];
});
const datesCount = datesInputState.length;
@ -315,6 +323,22 @@ function DatesInput() {
: "";
const NEW_CALENDAR_EVENT_HOURS_OFFSET = 24;
const addDate = () =>
setDatesInputState((current) => {
// .reverse() is mutating, but map/filter returns a new array anyway.
const lastValidDate = current
.map((e) => e.date)
.filter(isDefined)
.reverse()
.at(0);
const addedDate = lastValidDate
? getDateWithHoursOffset(lastValidDate, NEW_CALENDAR_EVENT_HOURS_OFFSET)
: new Date();
return [...current, { key: getKey(), date: addedDate }];
});
return (
<div className="stack md items-start">
<div>
@ -322,25 +346,21 @@ function DatesInput() {
{t("calendar:forms.dates")}
</Label>
<div className="stack sm">
{datesInputState.map((inputState, i) => {
{datesInputState.map(({ date, key }, i) => {
return (
<div key={i} className="stack horizontal sm items-center">
<div key={key} className="stack horizontal sm items-center">
<DateInput
id="date"
id={`date-input-${key}`}
name="date"
defaultValue={inputState.finalDateInputDate ?? new Date()}
defaultValue={date ?? undefined}
min={MIN_DATE}
max={MAX_DATE}
required
onChange={(newDate: Date) => {
onChange={(newDate: Date | null) => {
setDatesInputState((current) =>
current.map((obj, objIndex) => {
if (objIndex === i) {
return { ...obj, finalDateInputDate: newDate };
}
return obj;
})
current.map((entry) =>
entry.key === key ? { ...entry, date: newDate } : entry
)
);
}}
/>
@ -352,17 +372,7 @@ function DatesInput() {
disabled={
datesCount === CALENDAR_EVENT.MAX_AMOUNT_OF_DATES
}
onClick={() => {
setDatesInputState((current) => [
...current,
{
finalDateInputDate: getDateWithHoursOffset(
inputState.finalDateInputDate,
NEW_CALENDAR_EVENT_HOURS_OFFSET
),
},
]);
}}
onClick={addDate}
>
{t("common:actions.add")}
</Button>

View File

@ -49,10 +49,8 @@ export function isValidDate(date: Date) {
export function dateToYearMonthDayHourMinuteString(date: Date) {
const copiedDate = new Date(date.getTime());
//TODO: fix invalid Date Input handling: https://github.com/Sendouc/sendou.ink/issues/1082
if (!isValidDate(copiedDate)) {
console.warn("Invalid date");
// throw new RangeError("Invalid Date");
throw new Error("tried to format string from invalid date");
}
const year = copiedDate.getFullYear();