import React, { useState, useEffect, useCallback, } from 'react'; import cn from 'classnames'; import isValidDate from 'date-fns/isValid'; import isAfter from 'date-fns/isAfter'; import format from 'date-fns/format'; import parse from 'date-fns/parse'; import differenceInDays from 'date-fns/differenceInCalendarDays'; import getDate from 'date-fns/getDate'; import getMonth from 'date-fns/getMonth'; import getYear from 'date-fns/getYear'; import max from 'date-fns/max'; import min from 'date-fns/min'; import { Toggle } from '@veeqo/ui'; import { Label, DatePickerContainer, CalendarContainer, Calendar, ControlsContainer, DateRangeLabel, Select, SelectButton, SelectItemsContainer, SelectItem, DateRangeInputsContainer, DateRangeInputContainer, DateRangeInputsDivider, DateRangeInput, ComparePeriodContainer, ComparePeriodLabel, SubmitButton, ButtonContainer, } from './styled'; import { ComparePeriods, DatePickerProps, DateRange } from './types'; import { comparePeriodsList, todayDate } from './constants'; import { dateRanges, dateRangesByTimezone } from './dateRanges'; type ClassNamesReturnPayload = { container?: string; calendarContainer?: string; calendar?: string; controls?: string; dateRangeSelect?: string; dateRangeButton?: string; dateRangeItemsContainer?: string; dateRangeItem?: string; startDate?: string; endDate?: string; comparePeriodSwitch?: string; comparePeriodSelect?: string; comparePeriodButton?: string; comparePeriodItemsContainer?: string; comparePeriodItem?: string; }; const generateClassNames = (prefix?: string): ClassNamesReturnPayload => ({ container: prefix ? `${prefix}-container` : undefined, calendarContainer: prefix ? `${prefix}-calendar-container` : undefined, calendar: prefix ? `${prefix}-calendar` : undefined, controls: prefix ? `${prefix}-controls-container` : undefined, dateRangeSelect: prefix ? `${prefix}-date-range-select` : undefined, dateRangeButton: prefix ? `${prefix}-date-range-button` : undefined, dateRangeItemsContainer: prefix ? `${prefix}-date-range-items-container` : undefined, dateRangeItem: prefix ? `${prefix}-date-range-item` : undefined, startDate: prefix ? `${prefix}-start-date-input` : undefined, endDate: prefix ? `${prefix}-end-date-input` : undefined, comparePeriodSwitch: prefix ? `${prefix}-compare-period-switch` : undefined, comparePeriodSelect: prefix ? `${prefix}-compare-period-select` : undefined, comparePeriodButton: prefix ? `${prefix}-compare-period-button` : undefined, comparePeriodItemsContainer: prefix ? `${prefix}-compare-period-items-container` : undefined, comparePeriodItem: prefix ? `${prefix}-compare-period-item` : undefined, }); const isValid = (date: Date) => isValidDate(date) && isAfter(date, new Date('1/1/1000')); const isEqualDates = (startDate: Date, endDate: Date) => ( getDate(startDate) === getDate(endDate) && getMonth(startDate) === getMonth(endDate) && getYear(startDate) === getYear(endDate) ); const DatePicker = ({ className, e2eClassName, startDate, endDate, minDate, maxDate, dateFormat = 'MM/dd/yyyy', showComparePeriod = false, vertical = false, defaultComparePeriod = 'Previous week', timeZone, onDateChange, onComparePeriodChange, submitButtonText, onSubmitButtonClick, }: DatePickerProps) => { const classNames = generateClassNames(className); const e2eClassNames = generateClassNames(e2eClassName); // Date() => MM/dd/yyyy const formatDate = useCallback((date: Date) => format(date, dateFormat), [dateFormat]); // MM/dd/yyyy => Date() const parseDate = (date: string, currentDate: Date | null = null) => { let parsedDate = parse(date, dateFormat, maxDate || todayDate); if (!isValid(parsedDate)) { if (date.length > 0) { parsedDate = parse(date, dateFormat.slice(0, date.length), maxDate || todayDate); } if (!isValid(parsedDate)) { parsedDate = currentDate || maxDate || todayDate; } } return parsedDate; }; /** * Ensures that date falls between the valid range of [minDate, maxDate]. * @param date Date to test against the valid range. * @returns - date if date is already in the valid range. * - minDate if date is before minDate. * - maxDate if date is after maxDate. */ const getNearestDateBetweenValidRange = (date: Date) => { let nearestValidDate = new Date(date); if (minDate) { nearestValidDate = max([nearestValidDate, minDate]); } nearestValidDate = min([nearestValidDate, maxDate || todayDate]); return nearestValidDate; }; const [startDateInputValue, setStartDateInputValue] = useState(formatDate(startDate)); const [endDateInputValue, setEndDateInputValue] = useState( endDate !== null ? formatDate(endDate) : formatDate(startDate), ); const [isDateRangeOpen, setIsDateRangeOpen] = useState(false); const [isComparePeriodVisible, setisComparePeriodVisible] = useState(showComparePeriod && defaultComparePeriod !== 'None'); const [isComparePeriodOpen, setisComparePeriodOpen] = useState(false); const [currentComparePeriod, setCurrentComparePeriod] = useState(defaultComparePeriod); const currentDateRanges = timeZone ? dateRangesByTimezone(timeZone) : dateRanges; useEffect(() => { setStartDateInputValue(formatDate(startDate)); // null in endDate can be when the start date is selected but the end date isn't if (endDate !== null) { setEndDateInputValue(formatDate(endDate)); } else { setEndDateInputValue(formatDate(startDate)); } }, [startDate, endDate, formatDate]); const onInputBlur = (start: string, end: string | null) => { if (!end) { const dateRange: DateRange = [getNearestDateBetweenValidRange(parseDate(start, startDate)), null]; if (isEqualDates(dateRange[0], startDate) && endDate === null) return null; return onDateChange(dateRange); } let dateRange: DateRange = [parseDate(start, startDate), parseDate(end, endDate)]; if (differenceInDays(dateRange[1], dateRange[0]) <= 0) { dateRange = [parseDate(end, startDate), parseDate(end, endDate)]; } // Ensure that dateRange is in the valid range of [minDate, maxDate] dateRange = [getNearestDateBetweenValidRange(dateRange[0]), getNearestDateBetweenValidRange(dateRange[1])]; if ( isEqualDates(dateRange[0], startDate) && isEqualDates(dateRange[1], endDate || startDate) ) return null; return onDateChange(dateRange); }; const onInputKeyDown = (event: React.KeyboardEvent) => { const eventKey = event.key; if (eventKey === 'Enter') { event.currentTarget.blur(); } }; const onChangeDateRange = (dateRange: any) => { onDateChange([dateRange.startDate, dateRange.endDate]); setIsDateRangeOpen(false); }; let currentDateRange = Object.values(currentDateRanges).find((dateRange) => { const isStart = isEqualDates(startDate, dateRange.startDate); const isEnd = isEqualDates(endDate || startDate, dateRange.endDate); return isStart && isEnd; }); if (!currentDateRange) { currentDateRange = { label: 'Custom', startDate, endDate: endDate || startDate, }; } return ( Date range: setStartDateInputValue(date)} onBlur={((e: React.FormEvent) => { onInputBlur(e.currentTarget.value, endDate ? formatDate(endDate) : null); }) as any} onKeyDown={onInputKeyDown} /> setEndDateInputValue(date)} onBlur={((e: React.ChangeEvent) => { onInputBlur(formatDate(startDate), e.currentTarget.value); }) as any} onKeyDown={onInputKeyDown} /> {showComparePeriod ? ( { setisComparePeriodVisible(!isComparePeriodVisible); if (!isComparePeriodVisible && onComparePeriodChange) { onComparePeriodChange(currentComparePeriod); } if (isComparePeriodVisible && onComparePeriodChange) { onComparePeriodChange('None'); } }} switched={isComparePeriodVisible} small /> Compare previous period ) : null} {isComparePeriodVisible ? ( ) : null} {submitButtonText && ( { setIsDateRangeOpen(false); onSubmitButtonClick!([startDate, endDate || startDate]); }} > {submitButtonText} )} ); }; DatePicker.dateRanges = dateRanges; DatePicker.dateRangesByTimezone = dateRangesByTimezone; export default DatePicker;