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

Add date input for timeline event dates (#20562)

parent 8f0c6a62
No related merge requests found
Showing
with 375 additions and 19 deletions
......@@ -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,
......
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;
export { default } from "./FormDateWidget";
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;
}
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
......
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,
};
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;
`;
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;
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();
});
});
export { default } from "./DateInput";
......@@ -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`
......
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;
......@@ -13,7 +13,7 @@ const FORM_FIELDS = [
{
name: "timestamp",
title: t`Date`,
placeholder: "2022-01-02",
type: "date",
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