From 2b22ab6eaf01a1e8e9d825b1cc6a680e806fc424 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Fri, 9 Jul 2021 15:35:48 +0200 Subject: [PATCH] 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 --- .../DatePicker/DatePicker.story.tsx | 2 +- .../DatePickerWithInput.story.tsx | 2 +- .../DateTimePicker/DateTimePicker.mdx | 18 ++ .../DateTimePicker/DateTimePicker.story.tsx | 34 +++ .../DateTimePicker/DateTimePicker.test.tsx | 39 +++ .../DateTimePicker/DateTimePicker.tsx | 228 ++++++++++++++++++ .../DateTimePickers/TimeOfDayPicker.tsx | 8 +- .../TimeRangePicker/TimePickerCalendar.tsx | 1 + .../src/components/DateTimePickers/utils.ts | 14 ++ 9 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.mdx create mode 100644 packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.story.tsx create mode 100644 packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.test.tsx create mode 100644 packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx create mode 100644 packages/grafana-ui/src/components/DateTimePickers/utils.ts diff --git a/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.story.tsx b/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.story.tsx index 878e02f0905..be64460e183 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.story.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.story.tsx @@ -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: { diff --git a/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.story.tsx b/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.story.tsx index b7469850491..4fe461cc117 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.story.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/DatePickerWithInput/DatePickerWithInput.story.tsx @@ -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: { diff --git a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.mdx b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.mdx new file mode 100644 index 00000000000..3b2b2865faf --- /dev/null +++ b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.mdx @@ -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('2021-05-05 12:00:00')); +return ; +``` + +### Props + diff --git a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.story.tsx b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.story.tsx new file mode 100644 index 00000000000..0e005d42a6d --- /dev/null +++ b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.story.tsx @@ -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 = ({ label }) => { + const [date, setDate] = useState(dateTime('2021-05-05 12:00:00')); + return ; +}; + +Basic.args = { + label: 'Date', +}; diff --git a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.test.tsx new file mode 100644 index 00000000000..526abef54fb --- /dev/null +++ b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.test.tsx @@ -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(); +}; + +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'); + }); +}); diff --git a/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx new file mode 100644 index 00000000000..49fbb738328 --- /dev/null +++ b/packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx @@ -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) => event.stopPropagation(); + +export const DateTimePicker: FC = ({ 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) => { + event.preventDefault(); + setOpen(true); + }, + [setOpen] + ); + + return ( +
+ + {isOpen ? ( + isFullscreen ? ( + setOpen(false)}> + setOpen(false)} /> + + ) : ( + + setOpen(false)}> +
+ setOpen(false)} /> +
+
+ + + ) + ) : null} +
+ ); +}; + +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) => void; +} + +type InputState = { + value: string; + invalid: boolean; +}; + +const DateTimeInput: FC = ({ date, label, onChange, isFullscreen, onOpen }) => { + const [internalDate, setInternalDate] = useState(() => { + 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) => { + const isInvalid = !isValid(event.currentTarget.value); + setInternalDate({ + value: event.currentTarget.value, + invalid: isInvalid, + }); + }, []); + + const onFocus = useCallback( + (event: FormEvent) => { + if (!isFullscreen) { + return; + } + onOpen(event); + }, + [isFullscreen, onOpen] + ); + + const onBlur = useCallback(() => { + if (isDateTime(internalDate.value)) { + onChange(dateTime(internalDate.value)); + } + }, [internalDate.value, onChange]); + + const icon = + + +
+ ); +}; + +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; + `, +}); diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeOfDayPicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeOfDayPicker.tsx index 1d51c6c3dd3..673426766c6 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeOfDayPicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeOfDayPicker.tsx @@ -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 = ({ minuteStep = 1, showHour = true, + showSeconds = false, onChange, value, size = 'auto', @@ -34,7 +36,7 @@ export const TimeOfDayPicker: FC = ({ 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` diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerCalendar.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerCalendar.tsx index b8ee9d372a4..248c14c5b38 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerCalendar.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerCalendar.tsx @@ -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}; `, diff --git a/packages/grafana-ui/src/components/DateTimePickers/utils.ts b/packages/grafana-ui/src/components/DateTimePickers/utils.ts new file mode 100644 index 00000000000..83f95224ed4 --- /dev/null +++ b/packages/grafana-ui/src/components/DateTimePickers/utils.ts @@ -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(); +}