1import React, { FC, FormEvent, ReactNode, useCallback, useEffect, useState } from 'react';
2import { useMedia } from 'react-use';
3import Calendar from 'react-calendar';
4import { css, cx } from '@emotion/css';
5import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data';
6import { Button, ClickOutsideWrapper, HorizontalGroup, Icon, InlineField, Input, Portal } from '../..';
7import { TimeOfDayPicker } from '../TimeOfDayPicker';
8import { getStyles as getCalendarStyles } from '../TimeRangePicker/TimePickerCalendar';
9import { useStyles2, useTheme2 } from '../../../themes';
10import { isValid } from '../utils';
11import { getBodyStyles } from '../TimeRangePicker/CalendarBody';
12
13export interface Props {
14  /** Input date for the component */
15  date?: DateTime;
16  /** Callback for returning the selected date */
17  onChange: (date: DateTime) => void;
18  /** label for the input field */
19  label?: ReactNode;
20  /** Set the latest selectable date */
21  maxDate?: Date;
22}
23
24const stopPropagation = (event: React.MouseEvent<HTMLDivElement>) => event.stopPropagation();
25
26export const DateTimePicker: FC<Props> = ({ date, maxDate, label, onChange }) => {
27  const [isOpen, setOpen] = useState(false);
28
29  const theme = useTheme2();
30  const isFullscreen = useMedia(`(min-width: ${theme.breakpoints.values.lg}px)`);
31  const containerStyles = useStyles2(getCalendarStyles);
32  const styles = useStyles2(getStyles);
33
34  const onApply = useCallback(
35    (date: DateTime) => {
36      setOpen(false);
37      onChange(date);
38    },
39    [onChange]
40  );
41
42  const onOpen = useCallback(
43    (event: FormEvent<HTMLElement>) => {
44      event.preventDefault();
45      setOpen(true);
46    },
47    [setOpen]
48  );
49
50  return (
51    <div data-testid="date-time-picker" style={{ position: 'relative' }}>
52      <DateTimeInput date={date} onChange={onChange} isFullscreen={isFullscreen} onOpen={onOpen} label={label} />
53      {isOpen ? (
54        isFullscreen ? (
55          <ClickOutsideWrapper onClick={() => setOpen(false)}>
56            <DateTimeCalendar
57              date={date}
58              onChange={onApply}
59              isFullscreen={true}
60              onClose={() => setOpen(false)}
61              maxDate={maxDate}
62            />
63          </ClickOutsideWrapper>
64        ) : (
65          <Portal>
66            <ClickOutsideWrapper onClick={() => setOpen(false)}>
67              <div className={styles.modal} onClick={stopPropagation}>
68                <DateTimeCalendar date={date} onChange={onApply} isFullscreen={false} onClose={() => setOpen(false)} />
69              </div>
70              <div className={containerStyles.backdrop} onClick={stopPropagation} />
71            </ClickOutsideWrapper>
72          </Portal>
73        )
74      ) : null}
75    </div>
76  );
77};
78
79interface DateTimeCalendarProps {
80  date?: DateTime;
81  onChange: (date: DateTime) => void;
82  onClose: () => void;
83  isFullscreen: boolean;
84  maxDate?: Date;
85}
86
87interface InputProps {
88  label?: ReactNode;
89  date?: DateTime;
90  isFullscreen: boolean;
91  onChange: (date: DateTime) => void;
92  onOpen: (event: FormEvent<HTMLElement>) => void;
93}
94
95type InputState = {
96  value: string;
97  invalid: boolean;
98};
99
100const DateTimeInput: FC<InputProps> = ({ date, label, onChange, isFullscreen, onOpen }) => {
101  const [internalDate, setInternalDate] = useState<InputState>(() => {
102    return { value: date ? dateTimeFormat(date) : dateTimeFormat(dateTime()), invalid: false };
103  });
104
105  useEffect(() => {
106    if (date) {
107      setInternalDate({
108        invalid: !isValid(dateTimeFormat(date)),
109        value: isDateTime(date) ? dateTimeFormat(date) : date,
110      });
111    }
112  }, [date]);
113
114  const onChangeDate = useCallback((event: FormEvent<HTMLInputElement>) => {
115    const isInvalid = !isValid(event.currentTarget.value);
116    setInternalDate({
117      value: event.currentTarget.value,
118      invalid: isInvalid,
119    });
120  }, []);
121
122  const onFocus = useCallback(
123    (event: FormEvent<HTMLElement>) => {
124      if (!isFullscreen) {
125        return;
126      }
127      onOpen(event);
128    },
129    [isFullscreen, onOpen]
130  );
131
132  const onBlur = useCallback(() => {
133    if (isDateTime(internalDate.value)) {
134      onChange(dateTime(internalDate.value));
135    }
136  }, [internalDate.value, onChange]);
137
138  const icon = <Button aria-label="Time picker" icon="calendar-alt" variant="secondary" onClick={onOpen} />;
139  return (
140    <InlineField
141      label={label}
142      onClick={stopPropagation}
143      invalid={!!(internalDate.value && internalDate.invalid)}
144      className={css`
145        margin-bottom: 0;
146      `}
147    >
148      <Input
149        onClick={stopPropagation}
150        onChange={onChangeDate}
151        addonAfter={icon}
152        value={internalDate.value}
153        onFocus={onFocus}
154        onBlur={onBlur}
155        data-testid="date-time-input"
156        placeholder="Select date/time"
157      />
158    </InlineField>
159  );
160};
161
162const DateTimeCalendar: FC<DateTimeCalendarProps> = ({ date, onClose, onChange, isFullscreen, maxDate }) => {
163  const calendarStyles = useStyles2(getBodyStyles);
164  const styles = useStyles2(getStyles);
165  const [internalDate, setInternalDate] = useState<Date>(() => {
166    if (date && date.isValid()) {
167      return date.toDate();
168    }
169
170    return new Date();
171  });
172
173  const onChangeDate = useCallback((date: Date | Date[]) => {
174    if (!Array.isArray(date)) {
175      setInternalDate((prevState) => {
176        // If we don't use time from prevState
177        // the time will be reset to 00:00:00
178        date.setHours(prevState.getHours());
179        date.setMinutes(prevState.getMinutes());
180        date.setSeconds(prevState.getSeconds());
181
182        return date;
183      });
184    }
185  }, []);
186
187  const onChangeTime = useCallback((date: DateTime) => {
188    setInternalDate(date.toDate());
189  }, []);
190
191  return (
192    <div className={cx(styles.container, { [styles.fullScreen]: isFullscreen })} onClick={stopPropagation}>
193      <Calendar
194        next2Label={null}
195        prev2Label={null}
196        value={internalDate}
197        nextLabel={<Icon name="angle-right" />}
198        nextAriaLabel="Next month"
199        prevLabel={<Icon name="angle-left" />}
200        prevAriaLabel="Previous month"
201        onChange={onChangeDate}
202        locale="en"
203        className={calendarStyles.body}
204        tileClassName={calendarStyles.title}
205        maxDate={maxDate}
206      />
207      <div className={styles.time}>
208        <TimeOfDayPicker showSeconds={true} onChange={onChangeTime} value={dateTime(internalDate)} />
209      </div>
210      <HorizontalGroup>
211        <Button type="button" onClick={() => onChange(dateTime(internalDate))}>
212          Apply
213        </Button>
214        <Button variant="secondary" type="button" onClick={onClose}>
215          Cancel
216        </Button>
217      </HorizontalGroup>
218    </div>
219  );
220};
221
222const getStyles = (theme: GrafanaTheme2) => ({
223  container: css`
224    padding: ${theme.spacing(1)};
225    border: 1px ${theme.colors.border.weak} solid;
226    border-radius: ${theme.shape.borderRadius(1)};
227    background-color: ${theme.colors.background.primary};
228    z-index: ${theme.zIndex.modal};
229  `,
230  fullScreen: css`
231    position: absolute;
232  `,
233  time: css`
234    margin-bottom: ${theme.spacing(2)};
235  `,
236  modal: css`
237    position: fixed;
238    top: 25%;
239    left: 25%;
240    width: 100%;
241    z-index: ${theme.zIndex.modal};
242    max-width: 280px;
243  `,
244});
245