grafana/ui: Add DatePicker (#35742)

* Move DatePicker to grafana/ui

* Export the pickers

* Reuse TimePicker styles

* Fix date formatting

* Remove mockdate

* Add release tags

* Switch to input type='text'

* Move DatePicker to pickers

* Add mdx files

* Update types

* Update tests
This commit is contained in:
Alex Khomenko 2021-06-16 15:57:12 +03:00 committed by GitHub
parent 477d4197fb
commit 11335d6f6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 375 additions and 10 deletions

View File

@ -0,0 +1,25 @@
import { ArgsTable} from '@storybook/addon-docs/blocks';
import { DatePicker } from './DatePicker';
# DatePicker
A component with a calendar view for selecting a date.
### Usage
```tsx
import React, { useState } from 'react';
import { DatePicker, Button } from '@grafana/ui';
const [date, setDate] = useState<Date>(new Date());
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Show Calendar</Button>
<DatePicker isOpen={open} value={date} onChange={(newDate) => setDate(newDate)} onClose={() => setOpen(false)} />
</>
)
```
### Props
<ArgsTable of={DatePicker} />

View File

@ -0,0 +1,28 @@
import React, { useState } from 'react';
import { DatePicker } from './DatePicker';
import { Button } from '../../Button/Button';
import mdx from './DatePicker.mdx';
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
export default {
title: 'Pickers And Editors/DatePicker',
component: DatePicker,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
export const Basic = () => {
const [date, setDate] = useState<Date>(new Date());
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Show Calendar</Button>
<DatePicker isOpen={open} value={date} onChange={(newDate) => setDate(newDate)} onClose={() => setOpen(false)} />
</>
);
};

View File

@ -0,0 +1,54 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { DatePicker } from './DatePicker';
describe('DatePicker', () => {
it('does not render calendar when isOpen is false', () => {
render(<DatePicker isOpen={false} onChange={jest.fn()} onClose={jest.fn()} />);
expect(screen.queryByTestId('date-picker')).not.toBeInTheDocument();
});
it('renders calendar when isOpen is true', () => {
render(<DatePicker isOpen={true} onChange={jest.fn()} onClose={jest.fn()} />);
expect(screen.getByTestId('date-picker')).toBeInTheDocument();
});
it('renders calendar with default date', () => {
render(<DatePicker isOpen={true} onChange={jest.fn()} onClose={jest.fn()} value={new Date(1400000000000)} />);
expect(screen.getByText('May 2014')).toBeInTheDocument();
});
it('renders calendar with date passed in', () => {
render(<DatePicker isOpen={true} value={new Date(1607431703363)} onChange={jest.fn()} onClose={jest.fn()} />);
expect(screen.getByText('December 2020')).toBeInTheDocument();
});
it('calls onChange when date is selected', () => {
const onChange = jest.fn();
render(<DatePicker isOpen={true} onChange={onChange} onClose={jest.fn()} />);
expect(onChange).not.toHaveBeenCalled();
// clicking the date
fireEvent.click(screen.getByText('14'));
expect(onChange).toHaveBeenCalledTimes(1);
});
it('calls onClose when outside of wrapper is clicked', () => {
const onClose = jest.fn();
render(<DatePicker isOpen={true} onChange={jest.fn()} onClose={onClose} />);
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(document);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,71 @@
import React, { memo } from 'react';
import Calendar from 'react-calendar/dist/entry.nostyle';
import { css } from 'emotion';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
import { Icon } from '../../Icon/Icon';
import { getBodyStyles } from '../TimeRangePicker/TimePickerCalendar';
/** @public */
export interface DatePickerProps {
isOpen?: boolean;
onClose: () => void;
onChange: (value: Date) => void;
value?: Date;
}
/** @public */
export const DatePicker = memo<DatePickerProps>((props) => {
const styles = useStyles2(getStyles);
const { isOpen, onClose } = props;
if (!isOpen) {
return null;
}
return (
<ClickOutsideWrapper useCapture={true} includeButtonPress={false} onClick={onClose}>
<div className={styles.modal} data-testid="date-picker">
<Body {...props} />
</div>
</ClickOutsideWrapper>
);
});
DatePicker.displayName = 'DatePicker';
const Body = memo<DatePickerProps>(({ value, onChange }) => {
const styles = useStyles2(getBodyStyles);
return (
<Calendar
className={styles.body}
tileClassName={styles.title}
value={value || new Date()}
nextLabel={<Icon name="angle-right" />}
prevLabel={<Icon name="angle-left" />}
onChange={(ev) => {
if (!Array.isArray(ev)) {
onChange(ev);
}
}}
locale="en"
/>
);
});
Body.displayName = 'Body';
export const getStyles = (theme: GrafanaTheme2) => {
return {
modal: css`
z-index: ${theme.zIndex.modal};
position: absolute;
box-shadow: ${theme.shadows.z3};
background-color: ${theme.colors.background.primary};
border: 1px solid ${theme.colors.border.weak};
border-radius: 2px 0 0 2px;
`,
};
};

View File

@ -0,0 +1,18 @@
import { ArgsTable} from '@storybook/addon-docs/blocks';
import { DatePickerWithInput } from './DatePickerWithInput';
# DatePickerWithInput
An input with a calendar view, used to select a date.
### Usage
```tsx
import React, { useState } from 'react';
import { DatePickerWithInput } from '@grafana/ui';
const [date, setDate] = useState<Date | string>(new Date());
return <DatePickerWithInput width={40} value={date} onChange={(newDate) => setDate(newDate)} />;
```
### Props
<ArgsTable of={DatePickerWithInput} />

View File

@ -0,0 +1,21 @@
import React, { useState } from 'react';
import { DatePickerWithInput } from './DatePickerWithInput';
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
import mdx from './DatePickerWithInput.mdx';
export default {
title: 'Pickers And Editors/DatePickerWithInput',
component: DatePickerWithInput,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
export const Basic = () => {
const [date, setDate] = useState<Date | string>(new Date());
return <DatePickerWithInput width={40} value={date} onChange={(newDate) => setDate(newDate)} />;
};

View File

@ -0,0 +1,66 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { DatePickerWithInput } from './DatePickerWithInput';
import { dateTimeFormat } from '@grafana/data';
describe('DatePickerWithInput', () => {
it('renders date input', () => {
render(<DatePickerWithInput onChange={jest.fn()} value={new Date(1400000000000)} />);
expect(screen.getByDisplayValue(dateTimeFormat(1400000000000, { format: 'L' }))).toBeInTheDocument();
});
it('renders date input with date passed in', () => {
render(<DatePickerWithInput value={new Date(1607431703363)} onChange={jest.fn()} />);
expect(screen.getByDisplayValue(dateTimeFormat(1607431703363, { format: 'L' }))).toBeInTheDocument();
});
it('does not render calendar', () => {
render(<DatePickerWithInput onChange={jest.fn()} />);
expect(screen.queryByTestId('date-picker')).not.toBeInTheDocument();
});
describe('input is clicked', () => {
it('renders input', () => {
render(<DatePickerWithInput onChange={jest.fn()} />);
fireEvent.click(screen.getByPlaceholderText('Date'));
expect(screen.getByPlaceholderText('Date')).toBeInTheDocument();
});
it('renders calendar', () => {
render(<DatePickerWithInput onChange={jest.fn()} />);
fireEvent.click(screen.getByPlaceholderText('Date'));
expect(screen.queryByTestId('date-picker')).toBeInTheDocument();
});
});
it('calls onChange after date is selected', () => {
const onChange = jest.fn();
render(<DatePickerWithInput onChange={onChange} />);
// open calendar and select a date
fireEvent.click(screen.getByPlaceholderText('Date'));
fireEvent.click(screen.getByText('14'));
expect(onChange).toHaveBeenCalledTimes(1);
});
it('closes calendar after outside wrapper is clicked', () => {
render(<DatePickerWithInput onChange={jest.fn()} />);
// open calendar and click outside
fireEvent.click(screen.getByPlaceholderText('Date'));
expect(screen.getByTestId('date-picker')).toBeInTheDocument();
fireEvent.click(document);
expect(screen.queryByTestId('date-picker')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,75 @@
import React, { ChangeEvent } from 'react';
import { css } from '@emotion/css';
import { dateTimeFormat } from '@grafana/data';
import { DatePicker } from '../DatePicker/DatePicker';
import { Props as InputProps, Input } from '../../Input/Input';
import { useStyles } from '../../../themes';
export const formatDate = (date: Date | string) => dateTimeFormat(date, { format: 'L' });
/** @public */
export interface DatePickerWithInputProps extends Omit<InputProps, 'ref' | 'value' | 'onChange'> {
value?: Date | string;
onChange: (value: Date | string) => void;
/** Hide the calendar when date is selected */
closeOnSelect?: boolean;
placeholder?: string;
}
/** @public */
export const DatePickerWithInput = ({
value,
onChange,
closeOnSelect,
placeholder = 'Date',
...rest
}: DatePickerWithInputProps) => {
const [open, setOpen] = React.useState(false);
const styles = useStyles(getStyles);
return (
<div className={styles.container}>
<Input
type="text"
autoComplete={'off'}
placeholder={placeholder}
value={value ? formatDate(value) : value}
onClick={() => setOpen(true)}
onChange={(ev: ChangeEvent<HTMLInputElement>) => {
// Allow resetting the date
if (ev.target.value === '') {
onChange('');
}
}}
className={styles.input}
{...rest}
/>
<DatePicker
isOpen={open}
value={value && typeof value !== 'string' ? value : new Date()}
onChange={(ev) => {
onChange(ev);
if (closeOnSelect) {
setOpen(false);
}
}}
onClose={() => setOpen(false)}
/>
</div>
);
};
const getStyles = () => {
return {
container: css`
position: relative;
`,
input: css`
/* hides the native Calendar picker icon given when using type=date */
input[type='date']::-webkit-inner-spin-button,
input[type='date']::-webkit-calendar-picker-indicator {
display: none;
-webkit-appearance: none;
`,
};
};

View File

@ -26,6 +26,7 @@ import { Themeable } from '../../types';
import { otherOptions, quickOptions } from './rangeOptions';
import { ButtonGroup, ToolbarButton } from '../Button';
/** @public */
export interface TimeRangePickerProps extends Themeable {
hideText?: boolean;
value: TimeRange;
@ -179,6 +180,7 @@ const formattedRange = (value: TimeRange, timeZone?: TimeZone) => {
return rangeUtil.describeTimeRange(adjustedTimeRange, timeZone);
};
/** @public */
export const TimeRangePicker = withTheme(UnthemedTimeRangePicker);
const getStyles = stylesFactory((theme: GrafanaTheme) => {

View File

@ -9,7 +9,7 @@ import { Icon } from '../../Icon/Icon';
import { Portal } from '../../Portal/Portal';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed = false) => {
export const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed = false) => {
return {
container: css`
top: -1px;
@ -74,7 +74,7 @@ const getFooterStyles = stylesFactory((theme: GrafanaTheme2) => {
};
});
const getBodyStyles = stylesFactory((theme: GrafanaTheme2) => {
export const getBodyStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
title: css`
color: ${theme.colors.text};
@ -245,7 +245,7 @@ const Header = memo<Props>(({ onClose }) => {
Header.displayName = 'Header';
const Body = memo<Props>(({ onChange, from, to, timeZone }) => {
export const Body = memo<Props>(({ onChange, from, to, timeZone }) => {
const value = inputToValue(from, to);
const theme = useTheme2();
const onCalendarChange = useOnCalendarChange(onChange, timeZone);

View File

@ -2,7 +2,7 @@
@import 'Drawer/Drawer';
@import 'RefreshPicker/RefreshPicker';
@import 'Forms/Legacy/Select/Select';
@import 'TimePicker/TimeOfDayPicker';
@import 'DateTimePickers/TimeOfDayPicker';
@import 'Tooltip/Tooltip';
@import 'Slider/Slider';
@import 'uPlot/Plot';

View File

@ -21,9 +21,14 @@ export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export { UnitPicker } from './UnitPicker/UnitPicker';
export { StatsPicker } from './StatsPicker/StatsPicker';
export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker';
export { TimeRangePicker } from './TimePicker/TimeRangePicker';
export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
export { TimeZonePicker } from './TimePicker/TimeZonePicker';
export { TimeRangePicker, TimeRangePickerProps } from './DateTimePickers/TimeRangePicker';
export { TimeOfDayPicker } from './DateTimePickers/TimeOfDayPicker';
export { TimeZonePicker } from './DateTimePickers/TimeZonePicker';
export { DatePicker, DatePickerProps } from './DateTimePickers/DatePicker/DatePicker';
export {
DatePickerWithInput,
DatePickerWithInputProps,
} from './DateTimePickers/DatePickerWithInput/DatePickerWithInput';
export { List } from './List/List';
export { TagsInput } from './TagsInput/TagsInput';
export { Pagination } from './Pagination/Pagination';
@ -195,8 +200,8 @@ export { Checkbox } from './Forms/Checkbox';
export { TextArea } from './TextArea/TextArea';
export { FileUpload } from './FileUpload/FileUpload';
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
export { RelativeTimeRangePicker } from './TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker';
export { TimeRangeInput } from './DateTimePickers/TimeRangeInput';
export { RelativeTimeRangePicker } from './DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker';
export { Card, Props as CardProps, getCardStyles } from './Card/Card';
export { CardContainer, CardContainerProps } from './Card/CardContainer';
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';

View File

@ -1,7 +1,7 @@
import React from 'react';
import { LocalStorageValueProvider } from '../LocalStorageValueProvider';
import { TimeRange, isDateTime, toUtc } from '@grafana/data';
import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui/src/components/TimePicker/TimeRangePicker';
import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui';
const LOCAL_STORAGE_KEY = 'grafana.dashboard.timepicker.history';