mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
19fbfc8d24
commit
2b22ab6eaf
@ -5,7 +5,7 @@ import mdx from './DatePicker.mdx';
|
|||||||
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
|
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Pickers And Editors/DatePicker',
|
title: 'Pickers and Editors/TimePickers/Pickers And Editors/DatePicker',
|
||||||
component: DatePicker,
|
component: DatePicker,
|
||||||
decorators: [withCenteredStory],
|
decorators: [withCenteredStory],
|
||||||
parameters: {
|
parameters: {
|
||||||
|
@ -4,7 +4,7 @@ import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
|
|||||||
import mdx from './DatePickerWithInput.mdx';
|
import mdx from './DatePickerWithInput.mdx';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Pickers And Editors/DatePickerWithInput',
|
title: 'Pickers and Editors/TimePickers/DatePickerWithInput',
|
||||||
component: DatePickerWithInput,
|
component: DatePickerWithInput,
|
||||||
decorators: [withCenteredStory],
|
decorators: [withCenteredStory],
|
||||||
parameters: {
|
parameters: {
|
||||||
|
@ -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} />
|
@ -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',
|
||||||
|
};
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
`,
|
||||||
|
});
|
@ -12,6 +12,7 @@ export interface Props {
|
|||||||
onChange: (value: DateTime) => void;
|
onChange: (value: DateTime) => void;
|
||||||
value?: DateTime;
|
value?: DateTime;
|
||||||
showHour?: boolean;
|
showHour?: boolean;
|
||||||
|
showSeconds?: boolean;
|
||||||
minuteStep?: number;
|
minuteStep?: number;
|
||||||
size?: FormInputSize;
|
size?: FormInputSize;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -20,6 +21,7 @@ export interface Props {
|
|||||||
export const TimeOfDayPicker: FC<Props> = ({
|
export const TimeOfDayPicker: FC<Props> = ({
|
||||||
minuteStep = 1,
|
minuteStep = 1,
|
||||||
showHour = true,
|
showHour = true,
|
||||||
|
showSeconds = false,
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
size = 'auto',
|
size = 'auto',
|
||||||
@ -34,7 +36,7 @@ export const TimeOfDayPicker: FC<Props> = ({
|
|||||||
defaultValue={dateTimeAsMoment()}
|
defaultValue={dateTimeAsMoment()}
|
||||||
onChange={(value: any) => onChange(dateTime(value))}
|
onChange={(value: any) => onChange(dateTime(value))}
|
||||||
allowEmpty={false}
|
allowEmpty={false}
|
||||||
showSecond={false}
|
showSecond={showSeconds}
|
||||||
value={dateTimeAsMoment(value)}
|
value={dateTimeAsMoment(value)}
|
||||||
showHour={showHour}
|
showHour={showHour}
|
||||||
minuteStep={minuteStep}
|
minuteStep={minuteStep}
|
||||||
@ -107,6 +109,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rc-time-picker-panel-combobox {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
input: css`
|
input: css`
|
||||||
|
@ -37,6 +37,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed = false
|
|||||||
modal: css`
|
modal: css`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20%;
|
top: 20%;
|
||||||
|
left: 25%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: ${theme.zIndex.modal};
|
z-index: ${theme.zIndex.modal};
|
||||||
`,
|
`,
|
||||||
|
14
packages/grafana-ui/src/components/DateTimePickers/utils.ts
Normal file
14
packages/grafana-ui/src/components/DateTimePickers/utils.ts
Normal 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();
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user