mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-01 16:47:47 -05:00
Make date input more reliable by handling invalid dates and different browser behaviours
This commit is contained in:
parent
27a427a81b
commit
b4f8e4402e
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user