Skip to content
Snippets Groups Projects
Unverified Commit 1132cb3b authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Add 12-hour clock support to events (#20704)

parent b1629f5b
No related branches found
No related tags found
No related merge requests found
Showing
with 236 additions and 90 deletions
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}
......
......@@ -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 (
......
......@@ -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} />);
......
......@@ -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>
......
......@@ -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}
......
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({});
......@@ -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;
`;
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;
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);
});
});
......@@ -17,8 +17,6 @@ const createForm = () => {
title: t`Date`,
type: "date",
hasTime: true,
hasTimezone: true,
timezoneFieldName: "timezone",
validate: validate.required(),
},
{
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment