diff --git a/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/PersistedModelAnchorTimeWidget.styled.tsx b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/PersistedModelAnchorTimeWidget.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4031e3a7cfde3f4fea0322550887b5946a4b161a --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/PersistedModelAnchorTimeWidget.styled.tsx @@ -0,0 +1,12 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; + +export const RelativeTimeLabel = styled.span` + display: block; + + color: ${color("text-medium")}; + font-size: 0.875rem; + font-weight: 400; + + margin-top: 1.5rem; +`; diff --git a/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/PersistedModelAnchorTimeWidget.tsx b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/PersistedModelAnchorTimeWidget.tsx new file mode 100644 index 0000000000000000000000000000000000000000..82cfafd5678d758f2b4c5f5656f6def3f710f8a7 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/PersistedModelAnchorTimeWidget.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useMemo } from "react"; +import _ from "underscore"; +import moment, { Moment } from "moment"; +import { t } from "ttag"; + +import TimeInput from "metabase/core/components/TimeInput"; + +import { has24HourModeSetting } from "metabase/lib/time"; + +import { formatTime, getNextExpectedRefreshTime } from "./utils"; +import { RelativeTimeLabel } from "./PersistedModelAnchorTimeWidget.styled"; + +const DEFAULT_REFRESH_INTERVAL = 6; + +type Props = { + setting: { + key: string; + value: string | null; + default: string; + }; + settingValues: { + "persisted-model-refresh-interval-hours": number | null; + }; + onChange: (anchor: string) => void; +}; + +function PersistedModelAnchorTimeWidget({ + setting, + onChange, + settingValues, +}: Props) { + const anchor = setting.value || setting.default; + const [hours, minutes] = anchor.split(":").map(value => parseInt(value, 10)); + const value = moment({ hours, minutes }); + + const onChangeDebounced = useMemo(() => _.debounce(onChange, 300), [ + onChange, + ]); + + const handleChange = useCallback( + (value: Moment) => { + const hours = value.hours(); + const minutes = value.minutes(); + const nextAnchor = `${formatTime(hours)}:${formatTime(minutes)}`; + onChangeDebounced(nextAnchor); + }, + [onChangeDebounced], + ); + + const nextRefreshDates = useMemo(() => { + const refreshInterval = + settingValues["persisted-model-refresh-interval-hours"] || + DEFAULT_REFRESH_INTERVAL; + const firstRefresh = getNextExpectedRefreshTime( + moment(), + refreshInterval, + anchor, + ); + const secondRefresh = getNextExpectedRefreshTime( + firstRefresh, + refreshInterval, + anchor, + ); + const thirdRefresh = getNextExpectedRefreshTime( + secondRefresh, + refreshInterval, + anchor, + ); + return [firstRefresh, secondRefresh, thirdRefresh]; + }, [anchor, settingValues]); + + const renderRefreshTimeHintText = useCallback(() => { + const [first, second, third] = nextRefreshDates.map(time => + time.fromNow(true), + ); + return ( + <RelativeTimeLabel>{t`The next three refresh jobs will run in ${first}, ${second}, and ${third}.`}</RelativeTimeLabel> + ); + }, [nextRefreshDates]); + + return ( + <div> + <TimeInput.Compact + value={value} + is24HourMode={has24HourModeSetting()} + onChange={handleChange} + /> + {renderRefreshTimeHintText()} + </div> + ); +} + +export default PersistedModelAnchorTimeWidget; diff --git a/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/index.ts b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fd1efa58d05d79f406d18a535040445f6be86da --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/index.ts @@ -0,0 +1 @@ +export { default } from "./PersistedModelAnchorTimeWidget"; diff --git a/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/utils.ts b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..b40ca88b143259275cef6f17e050323e43c26aca --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/utils.ts @@ -0,0 +1,25 @@ +import { Moment } from "moment"; + +export function formatTime(value: number) { + return value < 10 ? `0${value}` : value; +} + +export function getNextExpectedRefreshTime( + fromTime: Moment, + refreshInterval: number, + anchorTime: string, +) { + const nextRefresh = fromTime.clone(); + nextRefresh.add(refreshInterval, "hours"); + + const isNextDay = nextRefresh.date() !== fromTime.date(); + if (isNextDay) { + const [hours, minutes] = anchorTime + .split(":") + .map(number => parseInt(number, 10)); + nextRefresh.hours(hours); + nextRefresh.minutes(minutes); + } + + return nextRefresh; +} diff --git a/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/utils.unit.spec.ts b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/utils.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f63701b0241c30cdd3543dfc210ebcb7cc2cb813 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PersistedModelAnchorTimeWidget/utils.unit.spec.ts @@ -0,0 +1,25 @@ +import moment, { Moment } from "moment"; +import { getNextExpectedRefreshTime } from "./utils"; + +describe("getNextExpectedRefreshTime", () => { + it("should jump forward by refresh interval (hours)", () => { + const fromTime = moment({ hours: 12, minutes: 0 }); + const result = getNextExpectedRefreshTime(fromTime, 8, "10:00"); + expect(result.hours()).toBe(20); + expect(result.minutes()).toBe(0); + }); + + it("should respect minutes", () => { + const fromTime = moment({ hours: 12, minutes: 15 }); + const result = getNextExpectedRefreshTime(fromTime, 6, "10:00"); + expect(result.hours()).toBe(18); + expect(result.minutes()).toBe(15); + }); + + it("should stick to anchor time for refreshes tomorrow", () => { + const fromTime = moment({ hours: 19, minutes: 0 }); + const result = getNextExpectedRefreshTime(fromTime, 6, "10:00"); + expect(result.hours()).toBe(10); + expect(result.minutes()).toBe(0); + }); +}); diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index 741ae4ab36d79a0e2e755f735d8c86e023b8ac02..6bfbd1d854c4851cf6cf0420d5cd660b1814ce38 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -20,6 +20,7 @@ import SecretKeyWidget from "./components/widgets/SecretKeyWidget"; import EmbeddingLegalese from "./components/widgets/EmbeddingLegalese"; import FormattingWidget from "./components/widgets/FormattingWidget"; import { PremiumEmbeddingLinkWidget } from "./components/widgets/PremiumEmbeddingLinkWidget"; +import PersistedModelAnchorTimeWidget from "./components/widgets/PersistedModelAnchorTimeWidget"; import PersistedModelRefreshIntervalWidget from "./components/widgets/PersistedModelRefreshIntervalWidget"; import SectionDivider from "./components/widgets/SectionDivider"; import SettingsUpdatesForm from "./components/SettingsUpdatesForm/SettingsUpdatesForm"; @@ -54,6 +55,8 @@ function updateSectionsWithPlugins(sections) { } } +const CACHING_MIN_REFRESH_HOURS_FOR_ANCHOR_TIME_SETTING = 6; + const SECTIONS = updateSectionsWithPlugins({ setup: { name: t`Setup`, @@ -449,8 +452,28 @@ const SECTIONS = updateSectionsWithPlugins({ disableDefaultUpdate: true, widget: PersistedModelRefreshIntervalWidget, getHidden: settings => !settings["persisted-models-enabled"], - onChanged: async (oldValue, newValue) => - PersistedModelsApi.setRefreshInterval({ hours: newValue }), + onChanged: (oldHours, hours) => + PersistedModelsApi.setRefreshInterval({ hours }), + }, + { + key: "persisted-model-refresh-anchor-time", + display_name: t`Anchoring time`, + disableDefaultUpdate: true, + widget: PersistedModelAnchorTimeWidget, + getHidden: settings => { + if (!settings["persisted-models-enabled"]) { + return true; + } + const DEFAULT_REFRESH_INTERVAL = 6; + const refreshInterval = + settings["persisted-model-refresh-interval-hours"] || + DEFAULT_REFRESH_INTERVAL; + return ( + refreshInterval < CACHING_MIN_REFRESH_HOURS_FOR_ANCHOR_TIME_SETTING + ); + }, + onChanged: (oldAnchor, anchor) => + PersistedModelsApi.setRefreshInterval({ anchor }), }, ], }, diff --git a/frontend/src/metabase/components/SegmentedControl.info.js b/frontend/src/metabase/components/SegmentedControl.info.js index b4e59cc97d732e93e25d35693da01c96550063e9..a23dec4be10a988ae58a46be2c48cfbc5727d0a1 100644 --- a/frontend/src/metabase/components/SegmentedControl.info.js +++ b/frontend/src/metabase/components/SegmentedControl.info.js @@ -57,6 +57,7 @@ export const examples = { options={SIMPLE_OPTIONS} variant="fill-background" /> + <SegmentedControlDemo options={SIMPLE_OPTIONS} variant="fill-all" /> </React.Fragment> ), icons: <SegmentedControlDemo options={OPTIONS_WITH_ICONS} />, diff --git a/frontend/src/metabase/components/SegmentedControl.jsx b/frontend/src/metabase/components/SegmentedControl.jsx index 0a9370e5148972e06dd65895249d0b36e4c1a2c7..cca132cb22af0b4afda815fc7ac8cd617e2bd54c 100644 --- a/frontend/src/metabase/components/SegmentedControl.jsx +++ b/frontend/src/metabase/components/SegmentedControl.jsx @@ -25,7 +25,7 @@ const propTypes = { name: PropTypes.string, value: PropTypes.any, options: PropTypes.arrayOf(optionShape).isRequired, - variant: PropTypes.oneOf(["fill-text", "fill-background"]), + variant: PropTypes.oneOf(["fill-text", "fill-background", "fill-all"]), inactiveColor: PropTypes.string, onChange: PropTypes.func, fullWidth: PropTypes.bool, @@ -45,12 +45,13 @@ export function SegmentedControl({ }) { const id = useMemo(() => _.uniqueId("radio-"), []); const name = nameFromProps || id; + const selectedOptionIndex = options.findIndex( + option => option.value === value, + ); return ( <SegmentedList {...props} fullWidth={fullWidth}> {options.map((option, index) => { - const isSelected = option.value === value; - const isFirst = index === 0; - const isLast = index === options.length - 1; + const isSelected = index === selectedOptionIndex; const id = `${name}-${option.value}`; const labelId = `${name}-${option.value}`; const iconOnly = !option.name; @@ -59,8 +60,9 @@ export function SegmentedControl({ <SegmentedItem key={option.value} isSelected={isSelected} - isFirst={isFirst} - isLast={isLast} + index={index} + total={options.length} + selectedOptionIndex={selectedOptionIndex} fullWidth={fullWidth} variant={variant} selectedColor={selectedColor} diff --git a/frontend/src/metabase/components/SegmentedControl.styled.jsx b/frontend/src/metabase/components/SegmentedControl.styled.jsx index f9ac65ce4e9caad98e17a8e2c82e157c7fb9dffd..2afb5f3420307771b96de29bdc13b72666b7a5d2 100644 --- a/frontend/src/metabase/components/SegmentedControl.styled.jsx +++ b/frontend/src/metabase/components/SegmentedControl.styled.jsx @@ -1,37 +1,90 @@ import React from "react"; import styled from "@emotion/styled"; +import { css } from "@emotion/react"; import _ from "underscore"; import Icon from "metabase/components/Icon"; -import { color, darken } from "metabase/lib/colors"; +import { color, darken, alpha } from "metabase/lib/colors"; -const BORDER_RADIUS = "8px"; +function getDefaultBorderColor() { + return darken(color("border"), 0.1); +} -export const SegmentedList = styled.ul` - display: flex; - width: ${props => (props.fullWidth ? 1 : 0)}; -`; +const COLORS = { + "fill-text": { + background: () => "transparent", + border: () => getDefaultBorderColor(), + text: ({ isSelected, selectedColor, inactiveColor }) => + color(isSelected ? selectedColor : inactiveColor), + }, + "fill-background": { + background: ({ isSelected, selectedColor }) => + isSelected ? color(selectedColor) : "transparent", + border: ({ isSelected, selectedColor }) => + isSelected ? color(selectedColor) : getDefaultBorderColor(), + text: ({ isSelected, inactiveColor }) => + color(isSelected ? "text-white" : inactiveColor), + }, + "fill-all": { + background: ({ isSelected, selectedColor }) => + isSelected ? alpha(color(selectedColor), 0.2) : "transparent", + border: ({ isSelected, selectedColor }) => + isSelected ? color(selectedColor) : getDefaultBorderColor(), + text: ({ isSelected, selectedColor, inactiveColor }) => + color(isSelected ? selectedColor : inactiveColor), + }, +}; + +function getSpecialBorderStyles({ + index, + isSelected, + total, + selectedOptionIndex, +}) { + if (isSelected) { + return css` + border-right-width: 1px; + border-left-width: 1px; + `; + } + + const isBeforeSelected = index === selectedOptionIndex - 1; + if (isBeforeSelected) { + return css` + border-right-width: 0; + `; + } + + const isAfterSelected = index === selectedOptionIndex + 1; + if (isAfterSelected) { + return css` + border-left-width: 0; + `; + } -function getSegmentedItemColor(props, fallbackColor) { - if (props.variant === "fill-text") { - return fallbackColor; + const isFirst = index === 0; + if (isFirst) { + return css` + border-left-width: 1px; + border-right-width: 0; + `; + } + const isLast = index === total - 1; + if (isLast) { + return css` + border-right-width: 1px; + border-left-width: 0; + `; } - return props.isSelected ? color(props.selectedColor) : fallbackColor; } export const SegmentedItem = styled.li` display: flex; flex-grow: ${props => (props.fullWidth ? 1 : 0)}; - background-color: ${props => getSegmentedItemColor(props, "transparent")}; - - border: 1px solid - ${props => getSegmentedItemColor(props, darken(color("border"), 0.1))}; + background-color: ${props => COLORS[props.variant].background(props)}; + border: 1px solid ${props => COLORS[props.variant].border(props)}; - border-right-width: ${props => (props.isLast ? "1px" : 0)}; - border-top-left-radius: ${props => (props.isFirst ? BORDER_RADIUS : 0)}; - border-bottom-left-radius: ${props => (props.isFirst ? BORDER_RADIUS : 0)}; - border-top-right-radius: ${props => (props.isLast ? BORDER_RADIUS : 0)}; - border-bottom-right-radius: ${props => (props.isLast ? BORDER_RADIUS : 0)}; + ${props => getSpecialBorderStyles(props)}; `; export const SegmentedItemLabel = styled.label` @@ -41,12 +94,7 @@ export const SegmentedItemLabel = styled.label` justify-content: center; position: relative; font-weight: bold; - color: ${props => { - const selectedColor = color( - props.variant === "fill-text" ? props.selectedColor : "white", - ); - return props.isSelected ? selectedColor : color(props.inactiveColor); - }}; + color: ${props => COLORS[props.variant].text(props)}; padding: ${props => (props.compact ? "8px" : "8px 12px")}; cursor: pointer; @@ -77,3 +125,21 @@ function IconWrapper(props) { export const ItemIcon = styled(IconWrapper)` margin-right: ${props => (props.iconOnly ? 0 : "4px")}; `; + +const BORDER_RADIUS = "8px"; + +export const SegmentedList = styled.ul` + display: flex; + + ${SegmentedItem} { + &:first-of-type { + border-top-left-radius: ${BORDER_RADIUS}; + border-bottom-left-radius: ${BORDER_RADIUS}; + } + + &:last-of-type { + border-top-right-radius: ${BORDER_RADIUS}; + border-bottom-right-radius: ${BORDER_RADIUS}; + } + } +`; diff --git a/frontend/src/metabase/core/components/Input/Input.tsx b/frontend/src/metabase/core/components/Input/Input.tsx index 86eb0595ed3540fa212c08edcb0f1e771e2aea7d..989d614dde72a5456d1068eefe2cf3190ef027c0 100644 --- a/frontend/src/metabase/core/components/Input/Input.tsx +++ b/frontend/src/metabase/core/components/Input/Input.tsx @@ -84,4 +84,7 @@ const Input = forwardRef(function Input( ); }); -export default Input; +export default Object.assign(Input, { + Root: InputRoot, + Field: InputField, +}); diff --git a/frontend/src/metabase/core/components/TimeInput/CompactTimeInput.styled.tsx b/frontend/src/metabase/core/components/TimeInput/CompactTimeInput.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c4ac3c91c4c910fe9ab50d501f3fc9f05b03d9b --- /dev/null +++ b/frontend/src/metabase/core/components/TimeInput/CompactTimeInput.styled.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import NumericInput from "metabase/core/components/NumericInput"; +import Input from "metabase/core/components/Input"; + +export const CompactInputContainer = styled.div` + display: flex; + align-items: center; + padding: 0.55rem 1rem; + + border-radius: 8px; + border: 1px solid ${color("border")}; + background-color: ${color("white")}; +`; + +export const CompactInput = styled(NumericInput)` + width: 1rem; + text-align: center; + + ${Input.Root}, ${Input.Field} { + border: none; + padding: 0; + margin: 0; + + font-size: 0.875rem; + } +`; diff --git a/frontend/src/metabase/core/components/TimeInput/CompactTimeInput.tsx b/frontend/src/metabase/core/components/TimeInput/CompactTimeInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..74f398322cca7f1dfa794a96fd73373ace2b092c --- /dev/null +++ b/frontend/src/metabase/core/components/TimeInput/CompactTimeInput.tsx @@ -0,0 +1,81 @@ +import React, { forwardRef, Ref, useCallback } from "react"; +import { t } from "ttag"; + +import { SegmentedControl } from "metabase/components/SegmentedControl"; + +import useTimeInput, { BaseTimeInputProps } from "./useTimeInput"; +import { + InputDivider, + InputRoot, + InputMeridiemContainer, +} from "./TimeInput.styled"; +import { CompactInputContainer, CompactInput } from "./CompactTimeInput.styled"; + +export type CompactTimeInputProps = BaseTimeInputProps; + +const CompactTimeInput = forwardRef(function TimeInput( + { value, is24HourMode, autoFocus, onChange }: CompactTimeInputProps, + ref: Ref<HTMLDivElement>, +): JSX.Element { + const { + isAm, + hoursText, + minutesText, + amText, + pmText, + handleHoursChange, + handleMinutesChange, + handleAM, + handlePM, + } = useTimeInput({ value, is24HourMode, onChange }); + + const onAmPmChange = useCallback( + value => { + if (value === "am") { + handleAM(); + } else { + handlePM(); + } + }, + [handleAM, handlePM], + ); + + return ( + <InputRoot ref={ref}> + <CompactInputContainer> + <CompactInput + value={hoursText} + placeholder="00" + autoFocus={autoFocus} + fullWidth + aria-label={t`Hours`} + onChange={handleHoursChange} + /> + <InputDivider>:</InputDivider> + <CompactInput + value={minutesText} + placeholder="00" + fullWidth + aria-label={t`Minutes`} + onChange={handleMinutesChange} + /> + </CompactInputContainer> + {!is24HourMode && ( + <InputMeridiemContainer> + <SegmentedControl + name="am-pm" + value={isAm ? "am" : "pm"} + options={[ + { name: amText, value: "am" }, + { name: pmText, value: "pm" }, + ]} + variant="fill-all" + onChange={onAmPmChange} + /> + </InputMeridiemContainer> + )} + </InputRoot> + ); +}); + +export default CompactTimeInput; diff --git a/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx b/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx index d4bdf7e786ab33fb951a49f179287595d06e75eb..d778457563528e00326645541ea7669b4325e1a4 100644 --- a/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx +++ b/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx @@ -17,3 +17,11 @@ const Template: ComponentStory<typeof TimeInput> = args => { }; export const Default = Template.bind({}); + +const CompactTemplate: ComponentStory<typeof TimeInput> = args => { + const [value, setValue] = useState(moment("2020-01-01T10:20")); + + return <TimeInput.Compact {...args} value={value} onChange={setValue} />; +}; + +export const Compact = CompactTemplate.bind({}); diff --git a/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx b/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx index e745a41f170580245741b093238c0c2f0ec6989d..9bc9dbfb1cf81d8728cfd37ff7a3e276d9a29fe6 100644 --- a/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx +++ b/frontend/src/metabase/core/components/TimeInput/TimeInput.tsx @@ -1,7 +1,11 @@ -import React, { forwardRef, Ref, useCallback } from "react"; +import React, { forwardRef, Ref } from "react"; import { t } from "ttag"; -import moment, { Moment } from "moment"; +import { Moment } from "moment"; import Tooltip from "metabase/components/Tooltip"; + +import useTimeInput, { BaseTimeInputProps } from "./useTimeInput"; +import CompactTimeInput from "./CompactTimeInput"; + import { InputClearButton, InputClearIcon, @@ -12,69 +16,35 @@ import { InputRoot, } from "./TimeInput.styled"; -export interface TimeInputProps { - value: Moment; - is24HourMode?: boolean; - autoFocus?: boolean; - onChange?: (value: Moment) => void; +export interface TimeInputProps extends BaseTimeInputProps { + hasClearButton?: boolean; onClear?: (value: Moment) => void; } const TimeInput = forwardRef(function TimeInput( - { value, is24HourMode, autoFocus, onChange, onClear }: TimeInputProps, + { + value, + is24HourMode, + autoFocus, + hasClearButton = true, + onChange, + onClear, + }: TimeInputProps, ref: Ref<HTMLDivElement>, ): JSX.Element { - 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 = 0) => { - const newValue = value.clone(); - if (is24HourMode) { - newValue.hours(hours % 24); - } else { - newValue.hours((hours % 12) + (isAm ? 0 : 12)); - } - onChange?.(newValue); - }, - [value, isAm, is24HourMode, onChange], - ); - - const handleMinutesChange = useCallback( - (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(() => { - const newValue = value.clone(); - newValue.hours(0); - newValue.minutes(0); - onClear?.(newValue); - }, [value, onClear]); + const { + isAm, + isPm, + hoursText, + minutesText, + amText, + pmText, + handleHoursChange, + handleMinutesChange, + handleAM: handleAmClick, + handlePM: handlePmClick, + handleClear: handleClearClick, + } = useTimeInput({ value, is24HourMode, onChange, onClear }); return ( <InputRoot ref={ref}> @@ -104,16 +74,20 @@ const TimeInput = forwardRef(function TimeInput( </InputMeridiemButton> </InputMeridiemContainer> )} - <Tooltip tooltip={t`Remove time`}> - <InputClearButton - aria-label={t`Remove time`} - onClick={handleClearClick} - > - <InputClearIcon name="close" /> - </InputClearButton> - </Tooltip> + {hasClearButton && ( + <Tooltip tooltip={t`Remove time`}> + <InputClearButton + aria-label={t`Remove time`} + onClick={handleClearClick} + > + <InputClearIcon name="close" /> + </InputClearButton> + </Tooltip> + )} </InputRoot> ); }); -export default TimeInput; +export default Object.assign(TimeInput, { + Compact: CompactTimeInput, +}); diff --git a/frontend/src/metabase/core/components/TimeInput/useTimeInput.ts b/frontend/src/metabase/core/components/TimeInput/useTimeInput.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e0f01b5ca85a2220e6904d4d7e3415246621bf8 --- /dev/null +++ b/frontend/src/metabase/core/components/TimeInput/useTimeInput.ts @@ -0,0 +1,85 @@ +import { useCallback } from "react"; +import moment, { Moment } from "moment"; + +export interface BaseTimeInputProps { + value: Moment; + is24HourMode?: boolean; + autoFocus?: boolean; + onChange?: (value: Moment) => void; + onClear?: (value: Moment) => void; +} + +function useTimeInput({ + value, + is24HourMode, + onChange, + onClear, +}: BaseTimeInputProps) { + 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 = 0) => { + const newValue = value.clone(); + if (is24HourMode) { + newValue.hours(hours % 24); + } else { + newValue.hours((hours % 12) + (isAm ? 0 : 12)); + } + onChange?.(newValue); + }, + [value, isAm, is24HourMode, onChange], + ); + + const handleMinutesChange = useCallback( + (minutes = 0) => { + const newValue = value.clone(); + newValue.minutes(minutes % 60); + onChange?.(newValue); + }, + [value, onChange], + ); + + const handleAM = useCallback(() => { + if (isPm) { + const newValue = value.clone(); + newValue.hours(newValue.hours() - 12); + onChange?.(newValue); + } + }, [value, isPm, onChange]); + + const handlePM = useCallback(() => { + if (isAm) { + const newValue = value.clone(); + newValue.hours(newValue.hours() + 12); + onChange?.(newValue); + } + }, [value, isAm, onChange]); + + const handleClear = useCallback(() => { + const newValue = value.clone(); + newValue.hours(0); + newValue.minutes(0); + onClear?.(newValue); + }, [value, onClear]); + + return { + isAm, + isPm, + hoursText, + minutesText, + amText, + pmText, + handleHoursChange, + handleMinutesChange, + handleAM, + handlePM, + handleClear, + }; +} + +export default useTimeInput;