diff --git a/frontend/src/metabase/components/form/widgets/FormDateWidget/FormDateWidget.tsx b/frontend/src/metabase/components/form/widgets/FormDateWidget/FormDateWidget.tsx index 176f07d598e5d806525051cc2bc4c6fc2299c324..3575d404e5dd964e8acfbff2c4d233850aaa029c 100644 --- a/frontend/src/metabase/components/form/widgets/FormDateWidget/FormDateWidget.tsx +++ b/frontend/src/metabase/components/form/widgets/FormDateWidget/FormDateWidget.tsx @@ -1,6 +1,11 @@ import React, { forwardRef, Ref, useCallback, useMemo } from "react"; import { Moment } from "moment"; -import { parseTimestamp } from "metabase/lib/time"; +import { + getDateStyleFromSettings, + getTimeStyleFromSettings, + has24HourModeSetting, + parseTimestamp, +} from "metabase/lib/time"; import DateWidget from "metabase/core/components/DateWidget"; import { FormField } from "./types"; @@ -49,6 +54,9 @@ const FormDateWidget = forwardRef(function FormDateWidget( value={value} placeholder={placeholder} hasTime={hasTime} + dateFormat={getDateStyleFromSettings()} + timeFormat={getTimeStyleFromSettings()} + is24HourMode={has24HourModeSetting()} readOnly={readOnly} autoFocus={autoFocus} error={field.visited && !field.active && field.error != null} diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.tsx index 5f1fb794ddd410312ec8e6a4a6bf05826a8f5717..fde066a2f4d3ccfeb7a7ac2191b472dcf17bb521 100644 --- a/frontend/src/metabase/core/components/DateInput/DateInput.tsx +++ b/frontend/src/metabase/core/components/DateInput/DateInput.tsx @@ -11,15 +11,12 @@ import React, { } from "react"; import moment, { Moment } from "moment"; import { t } from "ttag"; -import { - getDateStyleFromSettings, - getTimeStyleFromSettings, - hasTimePart, -} from "metabase/lib/time"; +import { hasTimePart } from "metabase/lib/time"; import Input from "metabase/core/components/Input"; const DATE_FORMAT = "MM/DD/YYYY"; -const TIME_FORMAT = "HH:mm"; +const TIME_FORMAT_12 = "h:mm A"; +const TIME_FORMAT_24 = "HH:mm"; export type DateInputAttributes = Omit< InputHTMLAttributes<HTMLDivElement>, @@ -30,9 +27,11 @@ export interface DateInputProps extends DateInputAttributes { value?: Moment; inputRef?: Ref<HTMLInputElement>; hasTime?: boolean; + hasCalendar?: boolean; + dateFormat?: string; + timeFormat?: string; error?: boolean; fullWidth?: boolean; - hasCalendar?: boolean; onChange?: (value?: Moment) => void; onCalendarClick?: (event: MouseEvent<HTMLButtonElement>) => void; } @@ -43,9 +42,11 @@ const DateInput = forwardRef(function DateInput( inputRef, placeholder, hasTime, + hasCalendar, + dateFormat = DATE_FORMAT, + timeFormat = TIME_FORMAT_12, error, fullWidth, - hasCalendar, onFocus, onBlur, onChange, @@ -56,8 +57,6 @@ const DateInput = forwardRef(function DateInput( ) { const [inputText, setInputText] = useState(""); const [isFocused, setIsFocused] = useState(false); - const dateFormat = getDateStyleFromSettings() || DATE_FORMAT; - const timeFormat = getTimeStyleFromSettings() || TIME_FORMAT; const dateTimeFormat = `${dateFormat}, ${timeFormat}`; const now = useMemo(() => { @@ -78,6 +77,16 @@ const DateInput = forwardRef(function DateInput( } }, [value, hasTime, dateFormat, dateTimeFormat]); + const mixedTimeFormats = useMemo( + () => [ + dateFormat, + dateTimeFormat, + `${dateFormat}, ${TIME_FORMAT_12}`, + `${dateFormat}, ${TIME_FORMAT_24}`, + ], + [dateFormat, dateTimeFormat], + ); + const handleFocus = useCallback( (event: FocusEvent<HTMLInputElement>) => { setIsFocused(true); @@ -100,7 +109,7 @@ const DateInput = forwardRef(function DateInput( const newText = event.target.value; setInputText(newText); - const formats = hasTime ? [dateTimeFormat, dateFormat] : [dateFormat]; + const formats = hasTime ? mixedTimeFormats : [dateFormat]; const newValue = moment(newText, formats); if (newValue.isValid()) { @@ -109,7 +118,7 @@ const DateInput = forwardRef(function DateInput( onChange?.(undefined); } }, - [hasTime, dateFormat, dateTimeFormat, onChange], + [hasTime, dateFormat, mixedTimeFormats, onChange], ); return ( diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.unit.spec.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.unit.spec.tsx index 9b39125008a0c3265b66b4259785d264f20e6f5b..a5d0a94a118bd9bd999c2dcaf8c24aba7e225729 100644 --- a/frontend/src/metabase/core/components/DateInput/DateInput.unit.spec.tsx +++ b/frontend/src/metabase/core/components/DateInput/DateInput.unit.spec.tsx @@ -29,7 +29,17 @@ describe("DateInput", () => { expect(onChange).toHaveBeenLastCalledWith(expected); }); - it("should set date with time", () => { + it("should set date with time with 12-hour clock", () => { + const onChange = jest.fn(); + + render(<DateInputTest hasTime onChange={onChange} />); + userEvent.type(screen.getByRole("textbox"), "10/20/21 9:15 PM"); + + const expected = moment("10/20/21 9:15 PM", ["MM/DD/YYYY, h:mm A"]); + expect(onChange).toHaveBeenLastCalledWith(expected); + }); + + it("should set date with time with 24-hour clock", () => { const onChange = jest.fn(); render(<DateInputTest hasTime onChange={onChange} />); diff --git a/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx b/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx index 4ce65a6d7cfcd2efeb8e51734000aa848a9887f4..b91527f45b31e72c6f498c132e44031f86e58fad 100644 --- a/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx +++ b/frontend/src/metabase/core/components/DateSelector/DateSelector.tsx @@ -6,7 +6,7 @@ import React, { useMemo, useState, } from "react"; -import moment, { Duration, Moment } from "moment"; +import moment, { Moment } from "moment"; import { t } from "ttag"; import { hasTimePart } from "metabase/lib/time"; import TimeInput from "metabase/core/components/TimeInput"; @@ -23,49 +23,53 @@ export interface DateSelectorProps { style?: CSSProperties; value?: Moment; hasTime?: boolean; + is24HourMode?: boolean; onChange?: (date?: Moment) => void; onSubmit?: () => void; } const DateSelector = forwardRef(function DateSelector( - { className, style, value, hasTime, onChange, onSubmit }: DateSelectorProps, + { + className, + style, + value, + hasTime, + is24HourMode, + onChange, + onSubmit, + }: DateSelectorProps, ref: Ref<HTMLDivElement>, ): JSX.Element { + const today = useMemo(() => moment().startOf("date"), []); const [isTimeShown, setIsTimeShown] = useState(hasTime && hasTimePart(value)); - const time = useMemo(() => { - return moment.duration({ - hours: value?.hours(), - minutes: value?.minutes(), - }); - }, [value]); - const handleDateChange = useCallback( - (unused1: string, unused2: string, dateStart: Moment) => { - const newDate = dateStart.clone().local(); - newDate.hours(value ? value.hours() : 0); - newDate.minutes(value ? value.minutes() : 0); + (unused1: string, unused2: string, date: Moment) => { + const newDate = moment({ + year: date.year(), + month: date.month(), + day: date.day(), + hours: value?.hours(), + minutes: value?.minutes(), + }); onChange?.(newDate); }, [value, onChange], ); - const handleTimeChange = useCallback( - (newTime?: Duration) => { - const newDate = value ? value.clone() : moment().startOf("date"); - newDate.hours(newTime ? newTime.hours() : 0); - newDate.minutes(newTime ? newTime.minutes() : 0); - onChange?.(newDate); - setIsTimeShown(newTime != null); - }, - [value, onChange], - ); - const handleTimeClick = useCallback(() => { - const newDate = value ? value.clone() : moment().startOf("date"); - onChange?.(newDate); + const newValue = value ?? today; + onChange?.(newValue); setIsTimeShown(true); - }, [value, onChange]); + }, [value, today, onChange]); + + const handleTimeClear = useCallback( + (newValue: Moment) => { + onChange?.(newValue); + setIsTimeShown(false); + }, + [onChange], + ); return ( <div ref={ref} className={className} style={style}> @@ -75,9 +79,14 @@ const DateSelector = forwardRef(function DateSelector( isRangePicker={false} onChange={handleDateChange} /> - {isTimeShown && ( + {value && isTimeShown && ( <SelectorTimeContainer> - <TimeInput value={time} onChange={handleTimeChange} /> + <TimeInput + value={value} + is24HourMode={is24HourMode} + onChange={onChange} + onClear={handleTimeClear} + /> </SelectorTimeContainer> )} <SelectorFooter> diff --git a/frontend/src/metabase/core/components/DateWidget/DateWidget.tsx b/frontend/src/metabase/core/components/DateWidget/DateWidget.tsx index 2104ba7554da55b7d0488c695874619b94a9a851..1445c66ed4e447eecb942e9f6e8fbb9a8057c30c 100644 --- a/frontend/src/metabase/core/components/DateWidget/DateWidget.tsx +++ b/frontend/src/metabase/core/components/DateWidget/DateWidget.tsx @@ -18,13 +18,26 @@ export type DateWidgetAttributes = Omit< export interface DateWidgetProps extends DateWidgetAttributes { value?: Moment; hasTime?: boolean; + dateFormat?: string; + timeFormat?: string; + is24HourMode?: boolean; error?: boolean; fullWidth?: boolean; onChange?: (date?: Moment) => void; } const DateWidget = forwardRef(function DateWidget( - { value, hasTime, error, fullWidth, onChange, ...props }: DateWidgetProps, + { + value, + hasTime, + dateFormat, + timeFormat, + is24HourMode, + error, + fullWidth, + onChange, + ...props + }: DateWidgetProps, ref: Ref<HTMLDivElement>, ): JSX.Element { const [isOpened, setIsOpened] = useState(false); @@ -39,14 +52,14 @@ const DateWidget = forwardRef(function DateWidget( return ( <TippyPopover - trigger="manual" - placement="bottom-start" visible={isOpened} + placement="bottom-start" interactive content={ <DateSelector value={value} hasTime={hasTime} + is24HourMode={is24HourMode} onChange={onChange} onSubmit={handleClose} /> @@ -59,6 +72,8 @@ const DateWidget = forwardRef(function DateWidget( value={value} hasTime={hasTime} hasCalendar={true} + dateFormat={dateFormat} + timeFormat={timeFormat} error={error} fullWidth={fullWidth} onChange={onChange} diff --git a/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx b/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx index 33bacb11f39bab0d1df87a85a5ce9f3b6852a1ee..d4bdf7e786ab33fb951a49f179287595d06e75eb 100644 --- a/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx +++ b/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Duration } from "moment"; +import moment from "moment"; import { ComponentStory } from "@storybook/react"; import TimeInput from "./TimeInput"; @@ -9,8 +9,11 @@ export default { }; const Template: ComponentStory<typeof TimeInput> = args => { - const [value, setValue] = useState<Duration>(); - return <TimeInput {...args} value={value} onChange={setValue} />; + const [value, setValue] = useState(moment("2020-01-01T10:20")); + + return ( + <TimeInput {...args} value={value} onChange={setValue} onClear={setValue} /> + ); }; export const Default = Template.bind({}); diff --git a/frontend/src/metabase/core/components/TimeInput/TimeInput.styled.tsx b/frontend/src/metabase/core/components/TimeInput/TimeInput.styled.tsx index d6edfe07082888fb3958da9720807999da36d2ed..173821590d163d68eb87468c7a4038668526765d 100644 --- a/frontend/src/metabase/core/components/TimeInput/TimeInput.styled.tsx +++ b/frontend/src/metabase/core/components/TimeInput/TimeInput.styled.tsx @@ -23,6 +23,22 @@ export const InputClearIcon = styled(Icon)` color: ${color("text-light")}; `; +interface InputPeriodButtonProps { + isSelected?: boolean; +} + +export const InputMeridiemContainer = styled.div` + display: flex; + gap: 0.5rem; + margin-left: 0.5rem; +`; + +export const InputMeridiemButton = styled.button<InputPeriodButtonProps>` + color: ${props => (props.isSelected ? color("brand") : color("text-light"))}; + cursor: ${props => (props.isSelected ? "" : "pointer")}; + font-weight: ${props => (props.isSelected ? "bold" : "")}; +`; + export const InputClearButton = styled(IconButtonWrapper)` margin-left: auto; `; diff --git a/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx b/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx index 7161a8279cfed81fccaac4f8b69e7c3d31b60819..e745a41f170580245741b093238c0c2f0ec6989d 100644 --- a/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx +++ b/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx @@ -1,62 +1,87 @@ import React, { forwardRef, Ref, useCallback } from "react"; import { t } from "ttag"; -import moment, { Duration } from "moment"; +import moment, { Moment } from "moment"; import Tooltip from "metabase/components/Tooltip"; import { InputClearButton, InputClearIcon, InputDivider, InputField, + InputMeridiemButton, + InputMeridiemContainer, InputRoot, } from "./TimeInput.styled"; -const HOURS_MAX = 24; -const MINUTES_MAX = 60; - export interface TimeInputProps { - value?: Duration; + value: Moment; + is24HourMode?: boolean; autoFocus?: boolean; - onChange?: (value?: Duration) => void; + onChange?: (value: Moment) => void; + onClear?: (value: Moment) => void; } const TimeInput = forwardRef(function TimeInput( - { value, onChange }: TimeInputProps, + { value, is24HourMode, autoFocus, onChange, onClear }: TimeInputProps, ref: Ref<HTMLDivElement>, ): JSX.Element { - const hoursText = formatTime(value?.hours()); - const minutesText = formatTime(value?.minutes()); + const hoursText = value.format(is24HourMode ? "HH" : "hh"); + const minutesText = value.format("mm"); + const isAm = value.hours() < 12; + const isPm = !isAm; + const amText = moment.localeData().meridiem(0, 0, false); + const pmText = moment.localeData().meridiem(12, 0, false); const handleHoursChange = useCallback( - (hours?: number) => { - const newValue = moment.duration({ - hours: hours ? hours % HOURS_MAX : 0, - minutes: value ? value.minutes() : 0, - }); + (hours = 0) => { + const newValue = value.clone(); + if (is24HourMode) { + newValue.hours(hours % 24); + } else { + newValue.hours((hours % 12) + (isAm ? 0 : 12)); + } onChange?.(newValue); }, - [value, onChange], + [value, isAm, is24HourMode, onChange], ); const handleMinutesChange = useCallback( - (minutes?: number) => { - const newValue = moment.duration({ - hours: value ? value.hours() : 0, - minutes: minutes ? minutes % MINUTES_MAX : 0, - }); + (minutes = 0) => { + const newValue = value.clone(); + newValue.minutes(minutes % 60); onChange?.(newValue); }, [value, onChange], ); + const handleAmClick = useCallback(() => { + if (isPm) { + const newValue = value.clone(); + newValue.hours(newValue.hours() - 12); + onChange?.(newValue); + } + }, [value, isPm, onChange]); + + const handlePmClick = useCallback(() => { + if (isAm) { + const newValue = value.clone(); + newValue.hours(newValue.hours() + 12); + onChange?.(newValue); + } + }, [value, isAm, onChange]); + const handleClearClick = useCallback(() => { - onChange?.(undefined); - }, [onChange]); + const newValue = value.clone(); + newValue.hours(0); + newValue.minutes(0); + onClear?.(newValue); + }, [value, onClear]); return ( <InputRoot ref={ref}> <InputField value={hoursText} placeholder="00" + autoFocus={autoFocus} fullWidth aria-label={t`Hours`} onChange={handleHoursChange} @@ -69,6 +94,16 @@ const TimeInput = forwardRef(function TimeInput( aria-label={t`Minutes`} onChange={handleMinutesChange} /> + {!is24HourMode && ( + <InputMeridiemContainer> + <InputMeridiemButton isSelected={isAm} onClick={handleAmClick}> + {amText} + </InputMeridiemButton> + <InputMeridiemButton isSelected={isPm} onClick={handlePmClick}> + {pmText} + </InputMeridiemButton> + </InputMeridiemContainer> + )} <Tooltip tooltip={t`Remove time`}> <InputClearButton aria-label={t`Remove time`} @@ -81,12 +116,4 @@ const TimeInput = forwardRef(function TimeInput( ); }); -const formatTime = (value?: number) => { - if (value != null) { - return value < 10 ? `0${value}` : `${value}`; - } else { - return ""; - } -}; - export default TimeInput; diff --git a/frontend/src/metabase/core/components/TimeInput/TimeInput.unit.spec.tsx b/frontend/src/metabase/core/components/TimeInput/TimeInput.unit.spec.tsx index 77f9ed2c7522b91736f447565ca30cfdbe94f74d..f4b12bb4c4227e1cf4a6e756fdbea1f509857c59 100644 --- a/frontend/src/metabase/core/components/TimeInput/TimeInput.unit.spec.tsx +++ b/frontend/src/metabase/core/components/TimeInput/TimeInput.unit.spec.tsx @@ -1,14 +1,14 @@ import React, { useCallback, useState } from "react"; -import { duration, Duration } from "moment"; +import moment, { Moment } from "moment"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import TimeInput, { TimeInputProps } from "./TimeInput"; const TestTimeInput = ({ onChange, ...props }: TimeInputProps) => { - const [value, setValue] = useState<Duration>(); + const [value, setValue] = useState(props.value); const handleChange = useCallback( - (value?: Duration) => { + (value: Moment) => { setValue(value); onChange?.(value); }, @@ -19,25 +19,76 @@ const TestTimeInput = ({ onChange, ...props }: TimeInputProps) => { }; describe("TimeInput", () => { - it("should set time", () => { + it("should set time in 12-hour clock", () => { + const value = moment({ hours: 0, minutes: 0 }); const onChange = jest.fn(); - render(<TestTimeInput onChange={onChange} />); + render(<TestTimeInput value={value} onChange={onChange} />); + userEvent.clear(screen.getByLabelText("Hours")); userEvent.type(screen.getByLabelText("Hours"), "5"); + userEvent.clear(screen.getByLabelText("Minutes")); userEvent.type(screen.getByLabelText("Minutes"), "20"); - const expected = duration({ hours: 5, minutes: 20 }); + const expected = value.clone(); + expected.hours(5); + expected.minutes(20); expect(onChange).toHaveBeenLastCalledWith(expected); }); - it("should clear time", () => { + it("should set time in 24-hour clock", () => { + const value = moment({ hours: 0, minutes: 0 }); const onChange = jest.fn(); - render(<TestTimeInput onChange={onChange} />); + render(<TestTimeInput value={value} is24HourMode onChange={onChange} />); + userEvent.clear(screen.getByLabelText("Hours")); + userEvent.type(screen.getByLabelText("Hours"), "15"); + userEvent.clear(screen.getByLabelText("Minutes")); + userEvent.type(screen.getByLabelText("Minutes"), "10"); + + const expected = value.clone(); + expected.hours(15); + expected.minutes(10); + expect(onChange).toHaveBeenLastCalledWith(expected); + }); + + it("should change meridiem to am", () => { + const value = moment({ hours: 12, minutes: 20 }); + const onChange = jest.fn(); + + render(<TestTimeInput value={value} onChange={onChange} />); + userEvent.click(screen.getByText("AM")); + + const expected = value.clone(); + expected.hours(0); + expect(onChange).toHaveBeenCalledWith(expected); + }); + + it("should change meridiem to pm", () => { + const value = moment({ hours: 10, minutes: 20 }); + const onChange = jest.fn(); + + render(<TestTimeInput value={value} onChange={onChange} />); + userEvent.click(screen.getByText("PM")); + + const expected = value.clone(); + expected.hours(22); + expect(onChange).toHaveBeenCalledWith(expected); + }); + + it("should clear time", () => { + const value = moment({ hours: 2, minutes: 10 }); + const onClear = jest.fn(); + + render(<TestTimeInput value={value} onClear={onClear} />); + userEvent.clear(screen.getByLabelText("Hours")); userEvent.type(screen.getByLabelText("Hours"), "5"); + userEvent.clear(screen.getByLabelText("Minutes")); userEvent.type(screen.getByLabelText("Minutes"), "20"); userEvent.click(screen.getByLabelText("Remove time")); - expect(onChange).toHaveBeenLastCalledWith(undefined); + const expected = value.clone(); + expected.hours(0); + expected.minutes(0); + expect(onClear).toHaveBeenCalledWith(expected); }); }); diff --git a/frontend/src/metabase/entities/timeline-events/forms.js b/frontend/src/metabase/entities/timeline-events/forms.js index 63ad67c70c3705095cc1ec022662e4a0adb562c8..97cefda51f0f9859342619f746e677a14d1b1f26 100644 --- a/frontend/src/metabase/entities/timeline-events/forms.js +++ b/frontend/src/metabase/entities/timeline-events/forms.js @@ -17,8 +17,6 @@ const createForm = () => { title: t`Date`, type: "date", hasTime: true, - hasTimezone: true, - timezoneFieldName: "timezone", validate: validate.required(), }, {