diff --git a/backend/services/implementations/testSessionService.ts b/backend/services/implementations/testSessionService.ts index e438458bb..6761cbccf 100644 --- a/backend/services/implementations/testSessionService.ts +++ b/backend/services/implementations/testSessionService.ts @@ -364,7 +364,7 @@ class TestSessionService implements ITestSessionService { return ( !updatableKeys.has(key) && (currentValue instanceof Date - ? currentValue.getTime() !== newValue.getTime() + ? currentValue.getTime() !== new Date(newValue).getTime() : currentValue?.toString() !== newValue) ); }, diff --git a/frontend/src/components/common/form/DatePicker.tsx b/frontend/src/components/common/form/DatePicker.tsx index fbed39cde..99648f92a 100644 --- a/frontend/src/components/common/form/DatePicker.tsx +++ b/frontend/src/components/common/form/DatePicker.tsx @@ -1,8 +1,27 @@ import type { ReactElement } from "react"; -import React from "react"; +import React, { useMemo } from "react"; import { SingleDatepicker } from "chakra-dayzed-datepicker"; +import type { PropsConfigs } from "chakra-dayzed-datepicker/dist/utils/commonTypes"; import { format } from "date-fns"; +const DATEPICKER_CONFIGS = { + dayNames: "SMTWTFS".split(""), + monthNames: [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "Auguest", + "September", + "October", + "November", + "December", + ], +}; + type DatePickerProps = { name?: string; onChange: (date: Date) => void; @@ -10,82 +29,76 @@ type DatePickerProps = { isDisabled?: boolean; }; +const getDatePickerStyles = ( + value?: Date | null, + isDisabled?: boolean, +): PropsConfigs => ({ + dateNavBtnProps: { + fontWeight: 400, + }, + dayOfMonthBtnProps: { + defaultBtnProps: { + width: "2rem", + color: "grey.400", + fontWeight: 400, + borderRadius: 20, + _hover: { + width: "2rem", + background: "blue.300", + color: "white", + bg: "blue.300", + }, + }, + selectedBtnProps: { + width: "2rem", + background: "blue.300", + borderRadius: 20, + color: "white", + }, + todayBtnProps: { + width: "2rem", + borderColor: "grey.300", + borderRadius: 20, + borderWidth: "1px", + borderStyle: "solid", + }, + }, + inputProps: { + isDisabled, + type: "button", + cursor: "pointer", + "aria-label": "This is a date input, activate to open date picker", + textAlign: "left", + value: value ? format(value, "yyyy-MM-dd") : "Please choose a date", + color: value ? "grey.300" : "placeholder.300", + transition: "color 0s", + }, + popoverCompProps: { + popoverContentProps: { + fontWeight: "400", + color: "grey.300", + }, + }, +}); + const DatePicker = ({ name, onChange, value, isDisabled = false, }: DatePickerProps): ReactElement => { + const styles = useMemo( + () => getDatePickerStyles(value, isDisabled), + [value, isDisabled], + ); + return ( onChange(date)} - propsConfigs={{ - dateNavBtnProps: { - fontWeight: 400, - }, - dayOfMonthBtnProps: { - defaultBtnProps: { - width: "2rem", - color: "grey.400", - fontWeight: 400, - borderRadius: 20, - _hover: { - width: "2rem", - background: "blue.300", - color: "white", - bg: "blue.300", - }, - }, - selectedBtnProps: { - width: "2rem", - background: "blue.300", - borderRadius: 20, - color: "white", - }, - todayBtnProps: { - width: "2rem", - borderColor: "grey.300", - borderRadius: 20, - borderWidth: "1px", - borderStyle: "solid", - }, - }, - inputProps: { - isDisabled, - type: "button", - cursor: "pointer", - "aria-label": "This is a date input, activate to open date picker", - textAlign: "left", - value: value ? format(value, "yyyy-MM-dd") : "Please choose a date", - color: value ? "grey.300" : "placeholder.300", - transition: "color 0s", - }, - popoverCompProps: { - popoverContentProps: { - fontWeight: "400", - color: "grey.300", - }, - }, - }} + propsConfigs={styles} /> ); }; diff --git a/frontend/src/components/common/form/DateTimePicker.tsx b/frontend/src/components/common/form/DateTimePicker.tsx new file mode 100644 index 000000000..a035a3d96 --- /dev/null +++ b/frontend/src/components/common/form/DateTimePicker.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from "react"; +import React, { useRef, useState } from "react"; +import { HStack } from "@chakra-ui/react"; + +import { combineDateAndTime } from "../../../utils/DateUtils"; + +import DatePicker from "./DatePicker"; +import TimePicker from "./TimePicker"; + +type DateTimePickerProps = { + name?: string; + onChange: (date: Date | null) => void; + value: Date | null | undefined; + isDisabled?: boolean; +}; + +const DateTimePicker = ({ + isDisabled, + onChange, + value, + name, +}: DateTimePickerProps): ReactElement => { + const timePickerRef = useRef(null); + + const [internalDateValue, setInternalDateValue] = useState(null); + const [internalTimeValue, setInternalTimeValue] = useState(null); + const dateValue = value ?? internalDateValue; + const timeValue = value ?? internalTimeValue; + + const handleDateChange = (newDate: Date | null) => { + setInternalDateValue(newDate); + if (timeValue && newDate) { + onChange(combineDateAndTime(newDate, timeValue)); + } + + // For some reason, the time picker doesn't focus properly if we try to + // focus it while the date picker is open. This is a workaround to make + // sure the focus happens after the date picker has started to close. + setTimeout(() => { + timePickerRef.current?.click(); + }); + }; + + const handleTimeChange = (newTime: Date | null) => { + setInternalTimeValue(newTime); + if (dateValue && newTime) { + onChange(combineDateAndTime(dateValue, newTime)); + } + }; + + return ( + + + + + ); +}; + +export default DateTimePicker; diff --git a/frontend/src/components/common/form/TimePicker.tsx b/frontend/src/components/common/form/TimePicker.tsx new file mode 100644 index 000000000..a475dc1b1 --- /dev/null +++ b/frontend/src/components/common/form/TimePicker.tsx @@ -0,0 +1,210 @@ +import type { ForwardedRef, ReactElement } from "react"; +import React, { forwardRef, useRef } from "react"; +import { Box, Input, useMergeRefs } from "@chakra-ui/react"; +import type { ChakraStylesConfig, SelectInstance } from "chakra-react-select"; + +import type { StringOption } from "../../../types/SelectInputTypes"; + +import Select from "./Select"; + +const MINUTE_OPTIONS: StringOption[] = [...Array(60).fill(null)].map( + (_, num) => ({ + label: num.toString().padStart(2, "0"), + value: num.toString(), + }), +); + +const HOUR_OPTIONS: StringOption[] = [...Array(24).fill(null)].map( + (_, num) => ({ + label: num.toString().padStart(2, "0"), + value: num.toString(), + }), +); + +const chakraStyles = ( + align: "left" | "right", +): ChakraStylesConfig => ({ + control: (provided) => ({ + ...provided, + background: "unset", + _hover: { + background: "unset", + }, + _focusVisible: { + outline: "none", + }, + border: "none", + }), + valueContainer: (provided) => ({ + ...provided, + p: 0, + justifyContent: align === "left" ? "flex-start" : "flex-end", + _focusWithin: { + color: "rgba(255, 255, 255, 0.7)", + }, + minW: "calc(2em)", + }), + inputContainer: (provided) => ({ + ...provided, + p: "0.125rem", + m: 0, + borderRadius: 3, + _focusWithin: { + bg: "blue.500", + }, + justifyContent: align === "left" ? "flex-start" : "flex-end", + }), + input: (provided) => ({ + ...provided, + caretColor: "white", + color: "white", + textAlign: align, + cursor: "inherit", + }), + placeholder: (provided) => ({ + ...provided, + }), + indicatorsContainer: (provided) => ({ + ...provided, + display: "none", + }), + downChevron: (provided) => ({ + ...provided, + display: "none", + }), + container: (provided) => ({ + ...provided, + flex: 1, + }), + menu: (provided) => ({ + ...provided, + w: 105, + }), +}); + +type TimePickerProps = { + name?: string; + value: Date | null | undefined; + defaultDay?: Date; + onChange: (date: Date) => void; + isDisabled?: boolean; +}; + +const TimePicker = forwardRef(function TimePicker( + { name, value, defaultDay, onChange, isDisabled }: TimePickerProps, + ref: ForwardedRef, +): ReactElement { + const wrapperRef = useRef(null); + const mergedWrapperRef = useMergeRefs(wrapperRef, ref); + const hourInputRef = useRef>(null); + const minuteInputRef = useRef>(null); + const elementRef = useRef(null); + const valueHours = value instanceof Date ? value.getHours() : null; + const valueMinutes = value instanceof Date ? value.getMinutes() : null; + + return ( + + { + if (e.target === wrapperRef.current) { + hourInputRef.current?.focus(); + } + }} + role="group" + > + + + ref={hourInputRef} + chakraStyles={chakraStyles("right")} + isDisabled={isDisabled} + name={`${name}-hour`} + onChange={(option) => { + const hours = parseInt(option ?? "0", 10); + if (hours == valueHours) { + return; + } + + const newValue = new Date(value ?? defaultDay ?? new Date()); + newValue.setHours(hours); + newValue.setMinutes(valueMinutes ?? 0); + newValue.setSeconds(0); + newValue.setMilliseconds(0); + onChange(newValue); + minuteInputRef.current?.focus(); + }} + onFocus={() => { + setTimeout(() => { + // For use with a date picker, our date picker will focus itself + // when it starts to close. This means that we might need to re- + // focus the hour input afterwards. + hourInputRef.current?.focus(); + hourInputRef.current?.openMenu("first"); + }); + }} + options={HOUR_OPTIONS} + placeholder="00" + value={valueHours?.toString()} + /> + + : + + + ref={minuteInputRef} + chakraStyles={chakraStyles("left")} + isDisabled={isDisabled} + name={`${name}-minute`} + onChange={(option) => { + const minutes = parseInt(option ?? "0", 10); + if (minutes == valueMinutes) { + return; + } + + const newValue = new Date(value ?? defaultDay ?? new Date()); + newValue.setHours(valueHours ?? 0); + newValue.setMinutes(minutes); + newValue.setSeconds(0); + newValue.setMilliseconds(0); + onChange(newValue); + elementRef.current?.focus(); + }} + onFocus={() => { + minuteInputRef.current?.openMenu("first"); + }} + options={MINUTE_OPTIONS} + placeholder="00" + value={valueMinutes?.toString()} + /> +