diff --git a/frontend/src/metabase/components/form/FormWidget.jsx b/frontend/src/metabase/components/form/FormWidget.jsx index de916c0accb7cf8f40c97ab8304ff3f78e5306f6..9ce32a2f4b3a382c014ad5591c32347abd0ca87c 100644 --- a/frontend/src/metabase/components/form/FormWidget.jsx +++ b/frontend/src/metabase/components/form/FormWidget.jsx @@ -5,6 +5,7 @@ import { PLUGIN_FORM_WIDGETS } from "metabase/plugins"; import FormInfoWidget from "./widgets/FormInfoWidget"; import FormInputWidget from "./widgets/FormInputWidget"; +import FormDateWidget from "./widgets/FormDateWidget"; import FormEmailWidget from "./widgets/FormEmailWidget"; import FormTextAreaWidget from "./widgets/FormTextAreaWidget"; import FormPasswordWidget from "./widgets/FormPasswordWidget"; @@ -23,6 +24,7 @@ import FormTextFileWidget from "./widgets/FormTextFileWidget"; const WIDGETS = { info: FormInfoWidget, input: FormInputWidget, + date: FormDateWidget, email: FormEmailWidget, text: FormTextAreaWidget, checkbox: FormCheckBoxWidget, diff --git a/frontend/src/metabase/components/form/widgets/FormDateWidget/FormDateWidget.tsx b/frontend/src/metabase/components/form/widgets/FormDateWidget/FormDateWidget.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f821e7991d1ce3865bb99dd8f28cff0a8d3c6a41 --- /dev/null +++ b/frontend/src/metabase/components/form/widgets/FormDateWidget/FormDateWidget.tsx @@ -0,0 +1,57 @@ +import React, { forwardRef, Ref, useCallback, useMemo } from "react"; +import moment, { Moment } from "moment"; +import DateInput from "metabase/core/components/DateInput"; +import { FormField } from "./types"; + +const DATE_FORMAT = "YYYY-MM-DD"; + +export interface FormDateWidgetProps { + field: FormField; + placeholder?: string; + readOnly?: boolean; + autoFocus?: boolean; + tabIndex?: number; +} + +const FormDateWidget = forwardRef(function FormDateWidget( + { field, placeholder, readOnly, autoFocus, tabIndex }: FormDateWidgetProps, + ref: Ref<HTMLDivElement>, +) { + const value = useMemo(() => { + return field.value ? moment(field.value, DATE_FORMAT) : undefined; + }, [field]); + + const handleChange = useCallback( + (newValue?: Moment) => { + field.onChange?.(newValue?.format(DATE_FORMAT)); + }, + [field], + ); + + const handleFocus = useCallback(() => { + field.onFocus?.(field.value); + }, [field]); + + const handleBlur = useCallback(() => { + field.onBlur?.(field.value); + }, [field]); + + return ( + <DateInput + ref={ref} + value={value} + placeholder={placeholder} + readOnly={readOnly} + autoFocus={autoFocus} + error={field.visited && !field.active && field.error != null} + fullWidth + tabIndex={tabIndex} + aria-labelledby={`${field.name}-label`} + onChange={handleChange} + onFocus={handleFocus} + onBlur={handleBlur} + /> + ); +}); + +export default FormDateWidget; diff --git a/frontend/src/metabase/components/form/widgets/FormDateWidget/index.ts b/frontend/src/metabase/components/form/widgets/FormDateWidget/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..df005321703a871099a81157f815c47ca9ab6a82 --- /dev/null +++ b/frontend/src/metabase/components/form/widgets/FormDateWidget/index.ts @@ -0,0 +1 @@ +export { default } from "./FormDateWidget"; diff --git a/frontend/src/metabase/components/form/widgets/FormDateWidget/types.ts b/frontend/src/metabase/components/form/widgets/FormDateWidget/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..26db3ee8eae383ad843ecea3beaae1560b28d4e3 --- /dev/null +++ b/frontend/src/metabase/components/form/widgets/FormDateWidget/types.ts @@ -0,0 +1,10 @@ +export interface FormField { + name: string; + value?: string; + visited?: boolean; + active?: boolean; + error?: string; + onChange?: (value?: string) => void; + onFocus?: (value?: string) => void; + onBlur?: (value?: string) => void; +} diff --git a/frontend/src/metabase/components/form/widgets/FormInputWidget.jsx b/frontend/src/metabase/components/form/widgets/FormInputWidget.jsx index 5371b9b2e594e15fa6a92cc99e76fd96554cfed4..f2a44a3ee4f22a215cc1cf80f47a8595ce9d51dd 100644 --- a/frontend/src/metabase/components/form/widgets/FormInputWidget.jsx +++ b/frontend/src/metabase/components/form/widgets/FormInputWidget.jsx @@ -1,7 +1,7 @@ import React, { forwardRef } from "react"; import PropTypes from "prop-types"; import { formDomOnlyProps } from "metabase/lib/redux"; -import Input from "metabase/components/Input/Input"; +import Input from "metabase/core/components/Input"; // Important: do NOT use this as an input of type="file" // For file inputs, See component FormTextFileWidget.tsx diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.stories.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8e16fac32214229a308b185dc9d071d3a4c76d8 --- /dev/null +++ b/frontend/src/metabase/core/components/DateInput/DateInput.stories.tsx @@ -0,0 +1,21 @@ +import React, { useState } from "react"; +import { Moment } from "moment"; +import { ComponentStory } from "@storybook/react"; +import DateInput from "./DateInput"; + +export default { + title: "Core/DateInput", + component: DateInput, +}; + +const Template: ComponentStory<typeof DateInput> = args => { + const [value, setValue] = useState<Moment>(); + return <DateInput {...args} value={value} onChange={setValue} />; +}; + +export const Default = Template.bind({}); + +export const WithError = Template.bind({}); +WithError.args = { + error: true, +}; diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.styled.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fd8e5c533bdd76bcc06fffdd013af3a3c015699b --- /dev/null +++ b/frontend/src/metabase/core/components/DateInput/DateInput.styled.tsx @@ -0,0 +1,35 @@ +import styled from "@emotion/styled"; +import { color, darken } from "metabase/lib/colors"; +import IconButtonWrapper from "metabase/components/IconButtonWrapper"; + +export interface InputRootProps { + readOnly?: boolean; + error?: boolean; + fullWidth?: boolean; +} + +export const InputRoot = styled.div<InputRootProps>` + display: inline-flex; + align-items: center; + width: ${props => (props.fullWidth ? "100%" : "")}; + border: 1px solid + ${props => (props.error ? color("error") : darken("border", 0.1))}; + border-radius: 4px; + background-color: ${props => + props.readOnly ? color("bg-light") : color("bg-white")}; + + &:focus-within { + border-color: ${color("brand")}; + transition: border 300ms ease-in-out; + } +`; + +export const InputIconButton = styled(IconButtonWrapper)` + margin: 0 0.75rem; +`; + +export const CalendarFooter = styled.div` + display: flex; + justify-content: flex-end; + padding: 0.75rem; +`; diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e49af36256e09a45f66b56441fffa140efb8796c --- /dev/null +++ b/frontend/src/metabase/core/components/DateInput/DateInput.tsx @@ -0,0 +1,165 @@ +import React, { + ChangeEvent, + FocusEvent, + forwardRef, + InputHTMLAttributes, + Ref, + useCallback, + useMemo, + useState, +} from "react"; +import moment, { Moment } from "moment"; +import { t } from "ttag"; +import Button from "metabase/core/components/Button"; +import Input from "metabase/core/components/Input"; +import Icon from "metabase/components/Icon"; +import Calendar from "metabase/components/Calendar"; +import Tooltip from "metabase/components/Tooltip"; +import TippyPopover from "metabase/components/Popover/TippyPopover"; +import { CalendarFooter, InputIconButton, InputRoot } from "./DateInput.styled"; + +const INPUT_FORMAT = "MM/DD/YYYY"; +const CALENDAR_FORMAT = "YYYY-MM-DD"; + +export type DateInputAttributes = Omit< + InputHTMLAttributes<HTMLDivElement>, + "value" | "onChange" +>; + +export interface DateInputProps extends DateInputAttributes { + inputRef?: Ref<HTMLInputElement>; + value?: Moment; + error?: boolean; + fullWidth?: boolean; + onChange?: (value: Moment | undefined) => void; +} + +const DateInput = forwardRef(function DateInput( + { + className, + style, + inputRef, + value, + placeholder, + readOnly, + disabled, + error, + fullWidth, + onFocus, + onBlur, + onChange, + ...props + }: DateInputProps, + ref: Ref<HTMLDivElement>, +) { + const now = useMemo(() => moment(), []); + const nowText = useMemo(() => now.format(INPUT_FORMAT), [now]); + const valueText = useMemo(() => value?.format(INPUT_FORMAT) ?? "", [value]); + const [inputText, setInputText] = useState(valueText); + const [isOpened, setIsOpened] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + const handleInputFocus = useCallback( + (event: FocusEvent<HTMLInputElement>) => { + setIsFocused(true); + setInputText(valueText); + onFocus?.(event); + }, + [valueText, onFocus], + ); + + const handleInputBlur = useCallback( + (event: FocusEvent<HTMLInputElement>) => { + setIsFocused(false); + onBlur?.(event); + }, + [onBlur], + ); + + const handleInputChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => { + const newText = event.target.value; + const newValue = moment(newText, INPUT_FORMAT); + setInputText(newText); + + if (newValue.isValid()) { + onChange?.(newValue); + } else { + onChange?.(undefined); + } + }, + [onChange], + ); + + const handlePopoverOpen = useCallback(() => { + setIsOpened(true); + }, []); + + const handlePopoverClose = useCallback(() => { + setIsOpened(false); + }, []); + + const handleCalendarChange = useCallback( + (valueText: string) => { + const value = moment(valueText, CALENDAR_FORMAT); + onChange?.(value); + }, + [onChange], + ); + + return ( + <TippyPopover + trigger="manual" + placement="bottom-start" + visible={isOpened} + interactive + content={ + <div> + <Calendar + selected={value} + initial={value ?? now} + onChange={handleCalendarChange} + isRangePicker={false} + /> + <CalendarFooter> + <Button primary onClick={handlePopoverClose}>{t`Save`}</Button> + </CalendarFooter> + </div> + } + onHide={handlePopoverClose} + > + <InputRoot + ref={ref} + className={className} + style={style} + readOnly={readOnly} + error={error} + fullWidth={fullWidth} + > + <Input + {...props} + ref={inputRef} + value={isFocused ? inputText : valueText} + placeholder={nowText} + readOnly={readOnly} + disabled={disabled} + error={error} + fullWidth={fullWidth} + borderless + onFocus={handleInputFocus} + onBlur={handleInputBlur} + onChange={handleInputChange} + /> + {!readOnly && !disabled && ( + <Tooltip tooltip={t`Show calendar`}> + <InputIconButton tabIndex={-1} onClick={handlePopoverOpen}> + <Icon name="calendar" /> + </InputIconButton> + </Tooltip> + )} + </InputRoot> + </TippyPopover> + ); +}); + +export default DateInput; diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.unit.spec.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14570740a3e1179a468169c1e21f11b04b791520 --- /dev/null +++ b/frontend/src/metabase/core/components/DateInput/DateInput.unit.spec.tsx @@ -0,0 +1,45 @@ +import React, { useState } from "react"; +import { Moment } from "moment"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DateInput, { DateInputProps } from "./DateInput"; + +const DateInputTest = (props: DateInputProps) => { + const [value, setValue] = useState<Moment>(); + return <DateInput {...props} value={value} onChange={setValue} />; +}; + +describe("DateInput", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2015, 0, 10)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should set a label", () => { + render(<DateInputTest aria-label="Date" />); + + expect(screen.getByLabelText("Date")).toBeInTheDocument(); + }); + + it("should set a placeholder", () => { + render(<DateInputTest />); + + expect(screen.getByPlaceholderText("01/10/2015")).toBeInTheDocument(); + }); + + it("should accept text input", () => { + const onChange = jest.fn(); + + render(<DateInputTest onChange={onChange} />); + + userEvent.type(screen.getByRole("textbox"), "10/20/21"); + expect(screen.getByDisplayValue("10/20/21")).toBeInTheDocument(); + + userEvent.tab(); + expect(screen.getByDisplayValue("10/20/2021")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/core/components/DateInput/index.ts b/frontend/src/metabase/core/components/DateInput/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..362d5a3898338e320011d95c2be7c515f58f4ef9 --- /dev/null +++ b/frontend/src/metabase/core/components/DateInput/index.ts @@ -0,0 +1 @@ +export { default } from "./DateInput"; diff --git a/frontend/src/metabase/components/Input/Input.styled.tsx b/frontend/src/metabase/core/components/Input/Input.styled.tsx similarity index 89% rename from frontend/src/metabase/components/Input/Input.styled.tsx rename to frontend/src/metabase/core/components/Input/Input.styled.tsx index 9babb46bbf0a7b932fe4fc97223f1be8bcf0098d..693ec9a725bee27fe29dbf1145f800b9ebade4c2 100644 --- a/frontend/src/metabase/components/Input/Input.styled.tsx +++ b/frontend/src/metabase/core/components/Input/Input.styled.tsx @@ -6,6 +6,7 @@ export interface InputProps { hasError?: boolean; hasTooltip?: boolean; fullWidth?: boolean; + borderless?: boolean; } export const InputRoot = styled.div<InputProps>` @@ -20,10 +21,10 @@ export const InputField = styled.input<InputProps>` font-weight: 700; font-size: 1rem; color: ${color("text-dark")}; - background-color: ${props => color(props.readOnly ? "bg-light" : "bg-white")}; padding: 0.75rem; border: 1px solid ${darken("border", 0.1)}; border-radius: 4px; + background-color: ${props => color(props.readOnly ? "bg-light" : "bg-white")}; outline: none; &:focus { @@ -48,6 +49,14 @@ export const InputField = styled.input<InputProps>` css` width: 100%; `} + + ${props => + props.borderless && + css` + border: none; + border-radius: 0; + background-color: transparent; + `}; `; export const InputIconContainer = styled.div` diff --git a/frontend/src/metabase/components/Input/Input.tsx b/frontend/src/metabase/core/components/Input/Input.tsx similarity index 51% rename from frontend/src/metabase/components/Input/Input.tsx rename to frontend/src/metabase/core/components/Input/Input.tsx index 9b2717a6810d8ae877ad9b66dd7871f367741926..63b53e198c0e6ccd92736e6a2fa1eb4a175dc75b 100644 --- a/frontend/src/metabase/components/Input/Input.tsx +++ b/frontend/src/metabase/core/components/Input/Input.tsx @@ -1,43 +1,53 @@ -import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react"; +import React, { forwardRef, InputHTMLAttributes, ReactNode, Ref } from "react"; import Icon from "metabase/components/Icon"; import Tooltip from "metabase/components/Tooltip"; import { InputField, InputIconContainer, InputRoot } from "./Input.styled"; export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { - className?: string; + inputRef?: Ref<HTMLInputElement>; error?: boolean; fullWidth?: boolean; + borderless?: boolean; helperText?: ReactNode; } -const Input = forwardRef<HTMLInputElement, InputProps>(function Input( - { className, error, fullWidth, helperText, ...rest }: InputProps, - ref, +const Input = forwardRef(function Input( + { + className, + style, + inputRef, + error, + fullWidth, + borderless, + helperText, + ...rest + }: InputProps, + ref: Ref<HTMLDivElement>, ) { return ( - <InputRoot className={className} fullWidth={fullWidth}> + <InputRoot + ref={ref} + className={className} + style={style} + fullWidth={fullWidth} + > <InputField {...rest} + ref={inputRef} hasError={error} hasTooltip={Boolean(helperText)} fullWidth={fullWidth} - ref={ref} + borderless={borderless} /> {helperText && ( <Tooltip tooltip={helperText} placement="right" offset={[0, 24]}> - <InputHelpContent /> + <InputIconContainer> + <Icon name="info" /> + </InputIconContainer> </Tooltip> )} </InputRoot> ); }); -const InputHelpContent = forwardRef(function InputHelpContent(props, ref: any) { - return ( - <InputIconContainer ref={ref}> - <Icon name="info" /> - </InputIconContainer> - ); -}); - export default Input; diff --git a/frontend/src/metabase/components/Input/index.ts b/frontend/src/metabase/core/components/Input/index.ts similarity index 100% rename from frontend/src/metabase/components/Input/index.ts rename to frontend/src/metabase/core/components/Input/index.ts diff --git a/frontend/src/metabase/entities/timeline-events/forms.js b/frontend/src/metabase/entities/timeline-events/forms.js index bc902b6bb31d3d21ba13f0f51fa71997a33292e7..9712039f268528bb307dc71565b38ade9d7ad0da 100644 --- a/frontend/src/metabase/entities/timeline-events/forms.js +++ b/frontend/src/metabase/entities/timeline-events/forms.js @@ -13,7 +13,7 @@ const FORM_FIELDS = [ { name: "timestamp", title: t`Date`, - placeholder: "2022-01-02", + type: "date", validate: validate.required(), }, {