mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
477d4197fb
commit
11335d6f6a
@ -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} />
|
@ -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)} />
|
||||
</>
|
||||
);
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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} />
|
@ -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)} />;
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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) => {
|
@ -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);
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user