grafana/ui: Date time picker (#36477)

* styling

* useCallback for onAction

* add flex to accomodate seconds

* fix positioning of the calender

* move input to its own component with state

* wrap callbacks in usecallback

* fix states and add mdx

* add docs

* add tests

* styling fixes for smaller screens

* make date optional

* add test for the changing the input

* more position fixes

* fix an issue with removing the date

* do not show invalid date in input

* more pr feedback
This commit is contained in:
Peter Holmberg 2021-07-09 15:35:48 +02:00 committed by GitHub
parent 19fbfc8d24
commit 2b22ab6eaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 343 additions and 3 deletions

View File

@ -5,7 +5,7 @@ import mdx from './DatePicker.mdx';
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
export default {
title: 'Pickers And Editors/DatePicker',
title: 'Pickers and Editors/TimePickers/Pickers And Editors/DatePicker',
component: DatePicker,
decorators: [withCenteredStory],
parameters: {

View File

@ -4,7 +4,7 @@ import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
import mdx from './DatePickerWithInput.mdx';
export default {
title: 'Pickers And Editors/DatePickerWithInput',
title: 'Pickers and Editors/TimePickers/DatePickerWithInput',
component: DatePickerWithInput,
decorators: [withCenteredStory],
parameters: {

View File

@ -0,0 +1,18 @@
import { ArgsTable} from '@storybook/addon-docs/blocks';
import { DateTimePicker } from "./DateTimePicker";
# DateTimePicker
A component for selecting a date _and_ time.
### Usage
```tsx
import React, { useState} from 'react';
import {DateTime, dateTime} from '@grafana/data';
import { DateTimePicker } from '@grafana/ui';
const [date, setDate] = useState<DateTime>(dateTime('2021-05-05 12:00:00'));
return <DateTimePicker label="Date" date={date} onChange={setDate} />;
```
### Props
<ArgsTable of={DateTimePicker} />

View File

@ -0,0 +1,34 @@
import React, { useState } from 'react';
import { Story, Meta } from '@storybook/react';
import { dateTime, DateTime } from '@grafana/data';
import { DateTimePicker, Props } from './DateTimePicker';
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
import mdx from './DateTimePicker.mdx';
export default {
title: 'Pickers and Editors/TimePickers/DateTimePicker',
decorators: [withCenteredStory],
component: DateTimePicker,
argTypes: {
date: {
table: { disable: true },
},
onChange: {
table: { disable: true },
},
},
parameters: {
docs: {
page: mdx,
},
},
} as Meta;
export const Basic: Story<Props> = ({ label }) => {
const [date, setDate] = useState<DateTime>(dateTime('2021-05-05 12:00:00'));
return <DateTimePicker label={label} date={date} onChange={setDate} />;
};
Basic.args = {
label: 'Date',
};

View File

@ -0,0 +1,39 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { dateTime } from '@grafana/data';
import { DateTimePicker, Props } from './DateTimePicker';
const renderDatetimePicker = (props?: Props) => {
const combinedProps = Object.assign(
{
date: dateTime('2021-05-05 12:00:00'),
onChange: () => {},
},
props
);
return render(<DateTimePicker {...combinedProps} />);
};
describe('Date time picker', () => {
it('should render component', () => {
renderDatetimePicker();
expect(screen.queryByTestId('date-time-picker')).toBeInTheDocument();
});
it('input should have a value', () => {
renderDatetimePicker();
expect(screen.queryByDisplayValue('2021-05-05 12:00:00')).toBeInTheDocument();
});
it('should update date onblur', () => {
renderDatetimePicker();
const dateTimeInput = screen.getByTestId('date-time-input');
fireEvent.change(dateTimeInput, { target: { value: '2021-07-31 12:30:30' } });
fireEvent.blur(dateTimeInput);
expect(dateTimeInput).toHaveDisplayValue('2021-07-31 12:30:30');
});
});

View File

@ -0,0 +1,228 @@
import React, { FC, FormEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import { useMedia } from 'react-use';
import Calendar from 'react-calendar/dist/entry.nostyle';
import { css, cx } from '@emotion/css';
import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data';
import { Button, ClickOutsideWrapper, Field, HorizontalGroup, Icon, Input, Portal } from '../..';
import { TimeOfDayPicker } from '../TimeOfDayPicker';
import { getBodyStyles, getStyles as getCalendarStyles } from '../TimeRangePicker/TimePickerCalendar';
import { useStyles2, useTheme2 } from '../../../themes';
import { isValid } from '../utils';
export interface Props {
/** Input date for the component */
date?: DateTime;
/** Callback for returning the selected date */
onChange: (date: DateTime) => void;
/** label for the input field */
label?: ReactNode;
}
const stopPropagation = (event: React.MouseEvent<HTMLDivElement>) => event.stopPropagation();
export const DateTimePicker: FC<Props> = ({ date, label, onChange }) => {
const [isOpen, setOpen] = useState(false);
const theme = useTheme2();
const isFullscreen = useMedia(`(min-width: ${theme.breakpoints.values.lg}px)`);
const containerStyles = useStyles2(getCalendarStyles);
const styles = useStyles2(getStyles);
const onApply = useCallback(
(date: DateTime) => {
setOpen(false);
onChange(date);
},
[onChange]
);
const onOpen = useCallback(
(event: FormEvent<HTMLElement>) => {
event.preventDefault();
setOpen(true);
},
[setOpen]
);
return (
<div data-testid="date-time-picker" style={{ position: 'relative' }}>
<DateTimeInput date={date} onChange={onChange} isFullscreen={isFullscreen} onOpen={onOpen} label={label} />
{isOpen ? (
isFullscreen ? (
<ClickOutsideWrapper onClick={() => setOpen(false)}>
<DateTimeCalendar date={date} onChange={onApply} isFullscreen={true} onClose={() => setOpen(false)} />
</ClickOutsideWrapper>
) : (
<Portal>
<ClickOutsideWrapper onClick={() => setOpen(false)}>
<div className={styles.modal} onClick={stopPropagation}>
<DateTimeCalendar date={date} onChange={onApply} isFullscreen={false} onClose={() => setOpen(false)} />
</div>
<div className={containerStyles.backdrop} onClick={stopPropagation} />
</ClickOutsideWrapper>
</Portal>
)
) : null}
</div>
);
};
interface DateTimeCalendarProps {
date?: DateTime;
onChange: (date: DateTime) => void;
onClose: () => void;
isFullscreen: boolean;
}
interface InputProps {
label?: ReactNode;
date?: DateTime;
isFullscreen: boolean;
onChange: (date: DateTime) => void;
onOpen: (event: FormEvent<HTMLElement>) => void;
}
type InputState = {
value: string;
invalid: boolean;
};
const DateTimeInput: FC<InputProps> = ({ date, label, onChange, isFullscreen, onOpen }) => {
const [internalDate, setInternalDate] = useState<InputState>(() => {
return { value: date ? dateTimeFormat(date) : dateTimeFormat(dateTime()), invalid: false };
});
useEffect(() => {
if (date) {
setInternalDate({
invalid: !isValid(dateTimeFormat(date)),
value: isDateTime(date) ? dateTimeFormat(date) : date,
});
}
}, [date]);
const onChangeDate = useCallback((event: FormEvent<HTMLInputElement>) => {
const isInvalid = !isValid(event.currentTarget.value);
setInternalDate({
value: event.currentTarget.value,
invalid: isInvalid,
});
}, []);
const onFocus = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!isFullscreen) {
return;
}
onOpen(event);
},
[isFullscreen, onOpen]
);
const onBlur = useCallback(() => {
if (isDateTime(internalDate.value)) {
onChange(dateTime(internalDate.value));
}
}, [internalDate.value, onChange]);
const icon = <Button icon="calendar-alt" variant="secondary" onClick={onOpen} />;
return (
<Field
label={label}
onClick={stopPropagation}
invalid={!!(internalDate.value && internalDate.invalid)}
error="Incorrect date format"
>
<Input
onClick={stopPropagation}
onChange={onChangeDate}
addonAfter={icon}
value={internalDate.value}
onFocus={onFocus}
onBlur={onBlur}
data-testid="date-time-input"
placeholder="Select date/time"
/>
</Field>
);
};
const DateTimeCalendar: FC<DateTimeCalendarProps> = ({ date, onClose, onChange, isFullscreen }) => {
const calendarStyles = useStyles2(getBodyStyles);
const styles = useStyles2(getStyles);
const [internalDate, setInternalDate] = useState<Date>(() => {
if (date && date.isValid()) {
return date.toDate();
}
return new Date();
});
const onChangeDate = useCallback((date: Date | Date[]) => {
if (!Array.isArray(date)) {
setInternalDate((prevState) => {
// If we don't use time from prevState
// the time will be reset to 00:00:00
date.setHours(prevState.getHours());
date.setMinutes(prevState.getMinutes());
date.setSeconds(prevState.getSeconds());
return date;
});
}
}, []);
const onChangeTime = useCallback((date: DateTime) => {
setInternalDate(date.toDate());
}, []);
return (
<div className={cx(styles.container, { [styles.fullScreen]: isFullscreen })} onClick={stopPropagation}>
<Calendar
next2Label={null}
prev2Label={null}
value={internalDate}
nextLabel={<Icon name="angle-right" />}
prevLabel={<Icon name="angle-left" />}
onChange={onChangeDate}
locale="en"
className={calendarStyles.body}
tileClassName={calendarStyles.title}
/>
<div className={styles.time}>
<TimeOfDayPicker showSeconds={true} onChange={onChangeTime} value={dateTime(internalDate)} />
</div>
<HorizontalGroup>
<Button type="button" onClick={() => onChange(dateTime(internalDate))}>
Apply
</Button>
<Button variant="secondary" type="button" onClick={onClose}>
Cancel
</Button>
</HorizontalGroup>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
padding: ${theme.spacing(1)};
border: 1px ${theme.colors.border.weak} solid;
border-radius: ${theme.shape.borderRadius(1)};
background-color: ${theme.colors.background.primary};
`,
fullScreen: css`
position: absolute;
`,
time: css`
margin-bottom: ${theme.spacing(2)};
`,
modal: css`
position: fixed;
top: 25%;
left: 25%;
width: 100%;
z-index: ${theme.zIndex.modal};
max-width: 280px;
`,
});

View File

@ -12,6 +12,7 @@ export interface Props {
onChange: (value: DateTime) => void;
value?: DateTime;
showHour?: boolean;
showSeconds?: boolean;
minuteStep?: number;
size?: FormInputSize;
disabled?: boolean;
@ -20,6 +21,7 @@ export interface Props {
export const TimeOfDayPicker: FC<Props> = ({
minuteStep = 1,
showHour = true,
showSeconds = false,
onChange,
value,
size = 'auto',
@ -34,7 +36,7 @@ export const TimeOfDayPicker: FC<Props> = ({
defaultValue={dateTimeAsMoment()}
onChange={(value: any) => onChange(dateTime(value))}
allowEmpty={false}
showSecond={false}
showSecond={showSeconds}
value={dateTimeAsMoment(value)}
showHour={showHour}
minuteStep={minuteStep}
@ -107,6 +109,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
padding-top: 2px;
}
}
.rc-time-picker-panel-combobox {
display: flex;
}
}
`,
input: css`

View File

@ -37,6 +37,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed = false
modal: css`
position: fixed;
top: 20%;
left: 25%;
width: 100%;
z-index: ${theme.zIndex.modal};
`,

View File

@ -0,0 +1,14 @@
import { dateMath, dateTimeParse, isDateTime, TimeZone } from '@grafana/data';
export function isValid(value: string, roundUp?: boolean, timeZone?: TimeZone): boolean {
if (isDateTime(value)) {
return value.isValid();
}
if (dateMath.isMathString(value)) {
return dateMath.isValid(value);
}
const parsed = dateTimeParse(value, { roundUp, timeZone });
return parsed.isValid();
}