diff --git a/app/components/DateInput.tsx b/app/components/DateInput.tsx index a34c407c9..c5288c288 100644 --- a/app/components/DateInput.tsx +++ b/app/components/DateInput.tsx @@ -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, + "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 ( - - ); - } - return ( <> - {date && } + {parsedDate && isMounted && ( + + )} { - //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" /> ); diff --git a/app/routes/calendar/new.tsx b/app/routes/calendar/new.tsx index c7505d4af..eb9bc6372 100644 --- a/app/routes/calendar/new.tsx +++ b/app/routes/calendar/new.tsx @@ -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(); - // 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 (
@@ -322,25 +346,21 @@ function DatesInput() { {t("calendar:forms.dates")}
- {datesInputState.map((inputState, i) => { + {datesInputState.map(({ date, key }, i) => { return ( -
+
{ + 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")} diff --git a/app/utils/dates.ts b/app/utils/dates.ts index 057eeef69..2946e1a18 100644 --- a/app/utils/dates.ts +++ b/app/utils/dates.ts @@ -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();