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