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';
|
||||
|
||||
export default {
|
||||
title: 'Pickers And Editors/DatePicker',
|
||||
title: 'Pickers and Editors/TimePickers/Pickers And Editors/DatePicker',
|
||||
component: DatePicker,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
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`
|
||||
|
@ -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};
|
||||
`,
|
||||
|
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