From 4e236a80dee2562c2a62a3e602143c5ae63c1e01 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Dec 2020 22:58:15 +0100 Subject: [PATCH] Created new dropdown component to select relative or absolute date ranges --- src/short-urls/SearchBar.tsx | 2 +- src/utils/SortingDropdown.scss | 4 - src/utils/SortingDropdown.tsx | 2 +- src/utils/{ => dates}/DateRangeRow.tsx | 11 ++- src/utils/dates/DateRangeSelector.scss | 18 ++++ src/utils/dates/DateRangeSelector.tsx | 84 +++++++++++++++++++ src/utils/dates/types/index.ts | 80 ++++++++++++++++++ src/utils/helpers/date.ts | 2 + src/visits/VisitsStats.tsx | 12 +-- test/short-urls/SearchBar.test.tsx | 2 +- test/utils/{ => dates}/DateRangeRow.test.tsx | 4 +- test/utils/dates/types/index.test.ts | 88 ++++++++++++++++++++ test/visits/VisitsStats.test.tsx | 13 --- 13 files changed, 288 insertions(+), 34 deletions(-) rename src/utils/{ => dates}/DateRangeRow.tsx (80%) create mode 100644 src/utils/dates/DateRangeSelector.scss create mode 100644 src/utils/dates/DateRangeSelector.tsx create mode 100644 src/utils/dates/types/index.ts rename test/utils/{ => dates}/DateRangeRow.test.tsx (90%) create mode 100644 test/utils/dates/types/index.test.ts diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/SearchBar.tsx index bd22a68b..a6da0273 100644 --- a/src/short-urls/SearchBar.tsx +++ b/src/short-urls/SearchBar.tsx @@ -4,7 +4,7 @@ import { isEmpty, pipe } from 'ramda'; import moment from 'moment'; import SearchField from '../utils/SearchField'; import Tag from '../tags/helpers/Tag'; -import DateRangeRow from '../utils/DateRangeRow'; +import DateRangeRow from '../utils/dates/DateRangeRow'; import { formatDate } from '../utils/helpers/date'; import ColorGenerator from '../utils/services/ColorGenerator'; import { ShortUrlsListParams } from './reducers/shortUrlsListParams'; diff --git a/src/utils/SortingDropdown.scss b/src/utils/SortingDropdown.scss index 3d2ae507..cbb9bcbc 100644 --- a/src/utils/SortingDropdown.scss +++ b/src/utils/SortingDropdown.scss @@ -1,7 +1,3 @@ -.sorting-dropdown__menu { - width: 100%; -} - .sorting-dropdown__menu--link.sorting-dropdown__menu--link { min-width: 11rem; } diff --git a/src/utils/SortingDropdown.tsx b/src/utils/SortingDropdown.tsx index c175c264..76fd1ca4 100644 --- a/src/utils/SortingDropdown.tsx +++ b/src/utils/SortingDropdown.tsx @@ -35,7 +35,7 @@ export default function SortingDropdown( {toPairs(items).map(([ fieldKey, fieldValue ]) => ( diff --git a/src/utils/DateRangeRow.tsx b/src/utils/dates/DateRangeRow.tsx similarity index 80% rename from src/utils/DateRangeRow.tsx rename to src/utils/dates/DateRangeRow.tsx index 6f006c28..7fb0e0cc 100644 --- a/src/utils/DateRangeRow.tsx +++ b/src/utils/dates/DateRangeRow.tsx @@ -1,9 +1,8 @@ import moment from 'moment'; -import DateInput from './DateInput'; +import DateInput from '../DateInput'; +import { DateRange } from './types'; -interface DateRangeRowProps { - startDate?: moment.Moment | null; - endDate?: moment.Moment | null; +interface DateRangeRowProps extends DateRange { onStartDateChange: (date: moment.Moment | null) => void; onEndDateChange: (date: moment.Moment | null) => void; disabled?: boolean; @@ -16,7 +15,7 @@ const DateRangeRow = (
.date-range-selector__btn.date-range-selector__btn.dropdown-toggle { + color: #6c757d; + background-color: white; + text-align: left; + border-color: rgba(0, 0, 0, .125); +} + +.date-range-selector__btn.date-range-selector__btn:after { + @include vertical-align(); + + right: .75rem; +} diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx new file mode 100644 index 00000000..a71d792d --- /dev/null +++ b/src/utils/dates/DateRangeSelector.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; +import { useToggle } from '../helpers/hooks'; +import { + DateInterval, + DateRange, + dateRangeIsEmpty, + rangeOrIntervalToString, + intervalToDateRange, + rangeIsInterval, +} from './types'; +import DateRangeRow from './DateRangeRow'; +import './DateRangeSelector.scss'; + +interface DateRangeSelectorProps { + initialDateRange?: DateInterval | DateRange; + disabled?: boolean; + onDatesChange: (dateRange: DateRange) => void; +} + +export const DateRangeSelector = ({ onDatesChange, initialDateRange, disabled = false }: DateRangeSelectorProps) => { + const [ isOpen, toggle ] = useToggle(); + const [ activeInterval, setActiveInterval ] = useState( + rangeIsInterval(initialDateRange) ? initialDateRange : undefined, + ); + const [ activeDateRange, setActiveDateRange ] = useState( + !rangeIsInterval(initialDateRange) ? initialDateRange : undefined, + ); + const updateDateRange = (dateRange: DateRange) => { + setActiveInterval(undefined); + setActiveDateRange(dateRange); + onDatesChange(dateRange); + }; + const updateInterval = (dateInterval?: DateInterval) => () => { + setActiveInterval(dateInterval); + setActiveDateRange(undefined); + onDatesChange(intervalToDateRange(dateInterval)); + }; + + return ( + + + {rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? 'All visits'} + + + + All visits + + + Today + + Yesterday + + + Last 7 days + + + Last 30 days + + + Last 90 days + + + Last 180 days + + + Last 365 days + + + Custom: + + updateDateRange({ ...activeDateRange, startDate })} + onEndDateChange={(endDate) => updateDateRange({ ...activeDateRange, endDate })} + /> + + + + ); +}; diff --git a/src/utils/dates/types/index.ts b/src/utils/dates/types/index.ts new file mode 100644 index 00000000..61a24eb6 --- /dev/null +++ b/src/utils/dates/types/index.ts @@ -0,0 +1,80 @@ +import moment from 'moment'; +import { filter, isEmpty } from 'ramda'; +import { formatInternational } from '../../helpers/date'; + +export interface DateRange { + startDate?: moment.Moment | null; + endDate?: moment.Moment | null; +} + +export type DateInterval = 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days'; + +export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined + || isEmpty(filter(Boolean, dateRange as any)); + +export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval => typeof range === 'string'; + +const INTERVAL_TO_STRING_MAP: Record = { + today: 'Today', + yesterday: 'Yesterday', + last7Days: 'Last 7 days', + last30Days: 'Last 30 days', + last90Days: 'Last 90 days', + last180days: 'Last 180 days', + last365Days: 'Last 365 days', +}; + +const dateRangeToString = (range?: DateRange): string | undefined => { + if (!range || dateRangeIsEmpty(range)) { + return undefined; + } + + if (range.startDate && !range.endDate) { + return `Since ${formatInternational(range.startDate)}`; + } + + if (!range.startDate && range.endDate) { + return `Until ${formatInternational(range.endDate)}`; + } + + return `${formatInternational(range.startDate)} - ${formatInternational(range.endDate)}`; +}; + +export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => { + if (!range) { + return undefined; + } + + if (!rangeIsInterval(range)) { + return dateRangeToString(range); + } + + return INTERVAL_TO_STRING_MAP[range]; +}; + +export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => { + if (!dateInterval) { + return {}; + } + + switch (dateInterval) { + case 'today': + return { startDate: moment().startOf('day'), endDate: moment() }; + case 'yesterday': + const yesterday = moment().subtract(1, 'day'); + + return { startDate: yesterday.startOf('day'), endDate: yesterday.endOf('day') }; + case 'last7Days': + return { startDate: moment().subtract(7, 'days').startOf('day'), endDate: moment() }; + case 'last30Days': + return { startDate: moment().subtract(30, 'days').startOf('day'), endDate: moment() }; + case 'last90Days': + return { startDate: moment().subtract(90, 'days').startOf('day'), endDate: moment() }; + case 'last180days': + return { startDate: moment().subtract(180, 'days').startOf('day'), endDate: moment() }; + case 'last365Days': + return { startDate: moment().subtract(365, 'days').startOf('day'), endDate: moment() }; + } + + return {}; +}; diff --git a/src/utils/helpers/date.ts b/src/utils/helpers/date.ts index 9d15c41f..419c3e83 100644 --- a/src/utils/helpers/date.ts +++ b/src/utils/helpers/date.ts @@ -12,3 +12,5 @@ const formatDateFromFormat = (date?: NullableDate, format?: string): OptionalStr export const formatDate = (format = 'YYYY-MM-DD') => (date?: NullableDate) => formatDateFromFormat(date, format); export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined); + +export const formatInternational = formatDate(); diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index a2ed0fd8..6389fda1 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons'; import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import moment from 'moment'; -import DateRangeRow from '../utils/DateRangeRow'; +import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import Message from '../utils/Message'; import { formatDate } from '../utils/helpers/date'; import { ShlinkVisitsParams } from '../utils/services/types'; @@ -225,12 +225,12 @@ const VisitsStats: FC = ({ children, visitsInfo, getVisits, ca
- { + setStartDate(newStartDate ?? null); + setEndDate(newEndDate ?? null); + }} />
{visits.length > 0 && ( diff --git a/test/short-urls/SearchBar.test.tsx b/test/short-urls/SearchBar.test.tsx index 842fcb8f..dd0fd28b 100644 --- a/test/short-urls/SearchBar.test.tsx +++ b/test/short-urls/SearchBar.test.tsx @@ -3,7 +3,7 @@ import { Mock } from 'ts-mockery'; import searchBarCreator from '../../src/short-urls/SearchBar'; import SearchField from '../../src/utils/SearchField'; import Tag from '../../src/tags/helpers/Tag'; -import DateRangeRow from '../../src/utils/DateRangeRow'; +import DateRangeRow from '../../src/utils/dates/DateRangeRow'; import ColorGenerator from '../../src/utils/services/ColorGenerator'; describe('', () => { diff --git a/test/utils/DateRangeRow.test.tsx b/test/utils/dates/DateRangeRow.test.tsx similarity index 90% rename from test/utils/DateRangeRow.test.tsx rename to test/utils/dates/DateRangeRow.test.tsx index c8ef8e0f..74cbf2a2 100644 --- a/test/utils/DateRangeRow.test.tsx +++ b/test/utils/dates/DateRangeRow.test.tsx @@ -1,6 +1,6 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import DateRangeRow from '../../src/utils/DateRangeRow'; -import DateInput from '../../src/utils/DateInput'; +import DateRangeRow from '../../../src/utils/dates/DateRangeRow'; +import DateInput from '../../../src/utils/DateInput'; describe('', () => { let wrapper: ShallowWrapper; diff --git a/test/utils/dates/types/index.test.ts b/test/utils/dates/types/index.test.ts new file mode 100644 index 00000000..437fcc95 --- /dev/null +++ b/test/utils/dates/types/index.test.ts @@ -0,0 +1,88 @@ +import moment from 'moment'; +import { + DateInterval, + dateRangeIsEmpty, + intervalToDateRange, + rangeIsInterval, + rangeOrIntervalToString, +} from '../../../../src/utils/dates/types'; + +describe('date-types', () => { + describe('dateRangeIsEmpty', () => { + test.each([ + [ undefined, true ], + [{}, true ], + [{ startDate: null }, true ], + [{ endDate: null }, true ], + [{ startDate: null, endDate: null }, true ], + [{ startDate: undefined }, true ], + [{ endDate: undefined }, true ], + [{ startDate: undefined, endDate: undefined }, true ], + [{ startDate: undefined, endDate: null }, true ], + [{ startDate: null, endDate: undefined }, true ], + [{ startDate: moment() }, false ], + [{ endDate: moment() }, false ], + [{ startDate: moment(), endDate: moment() }, false ], + ])('proper result is returned', (dateRange, expectedResult) => { + expect(dateRangeIsEmpty(dateRange)).toEqual(expectedResult); + }); + }); + + describe('rangeIsInterval', () => { + test.each([ + [ undefined, false ], + [{}, false ], + [ 'today' as DateInterval, true ], + [ 'yesterday' as DateInterval, true ], + ])('proper result is returned', (range, expectedResult) => { + expect(rangeIsInterval(range)).toEqual(expectedResult); + }); + }); + + describe('rangeOrIntervalToString', () => { + test.each([ + [ undefined, undefined ], + [ 'today' as DateInterval, 'Today' ], + [ 'yesterday' as DateInterval, 'Yesterday' ], + [ 'last7Days' as DateInterval, 'Last 7 days' ], + [ 'last30Days' as DateInterval, 'Last 30 days' ], + [ 'last90Days' as DateInterval, 'Last 90 days' ], + [ 'last180days' as DateInterval, 'Last 180 days' ], + [ 'last365Days' as DateInterval, 'Last 365 days' ], + [{}, undefined ], + [{ startDate: null }, undefined ], + [{ endDate: null }, undefined ], + [{ startDate: null, endDate: null }, undefined ], + [{ startDate: undefined }, undefined ], + [{ endDate: undefined }, undefined ], + [{ startDate: undefined, endDate: undefined }, undefined ], + [{ startDate: undefined, endDate: null }, undefined ], + [{ startDate: null, endDate: undefined }, undefined ], + [{ startDate: moment('2020-01-01') }, 'Since 2020-01-01' ], + [{ endDate: moment('2020-01-01') }, 'Until 2020-01-01' ], + [{ startDate: moment('2020-01-01'), endDate: moment('2021-02-02') }, '2020-01-01 - 2021-02-02' ], + ])('proper result is returned', (range, expectedValue) => { + expect(rangeOrIntervalToString(range)).toEqual(expectedValue); + }); + }); + + describe('intervalToDateRange', () => { + const now = () => moment(); + + test.each([ + [ undefined, undefined, undefined ], + [ 'today' as DateInterval, now(), now() ], + [ 'yesterday' as DateInterval, now().subtract(1, 'day'), now().subtract(1, 'day') ], + [ 'last7Days' as DateInterval, now().subtract(7, 'day'), now() ], + [ 'last30Days' as DateInterval, now().subtract(30, 'day'), now() ], + [ 'last90Days' as DateInterval, now().subtract(90, 'day'), now() ], + [ 'last180days' as DateInterval, now().subtract(180, 'day'), now() ], + [ 'last365Days' as DateInterval, now().subtract(365, 'day'), now() ], + ])('proper result is returned', (interval, expectedStartDate, expectedEndDate) => { + const { startDate, endDate } = intervalToDateRange(interval); + + expect(expectedStartDate?.format('YYYY-MM-DD')).toEqual(startDate?.format('YYYY-MM-DD')); + expect(expectedEndDate?.format('YYYY-MM-DD')).toEqual(endDate?.format('YYYY-MM-DD')); + }); + }); +}); diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx index 3c39bfa3..9c274a45 100644 --- a/test/visits/VisitsStats.test.tsx +++ b/test/visits/VisitsStats.test.tsx @@ -5,7 +5,6 @@ import VisitStats from '../../src/visits/VisitsStats'; import Message from '../../src/utils/Message'; import GraphCard from '../../src/visits/helpers/GraphCard'; import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph'; -import DateRangeRow from '../../src/utils/DateRangeRow'; import { Visit, VisitsInfo } from '../../src/visits/types'; import LineChartCard from '../../src/visits/helpers/LineChartCard'; import VisitsTable from '../../src/visits/VisitsTable'; @@ -87,18 +86,6 @@ describe('', () => { expect(table).toHaveLength(expectedTables); }); - it('reloads visits when selected dates change', () => { - const wrapper = createComponent({ loading: false, error: false, visits }); - const dateRange = wrapper.find(DateRangeRow); - - dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00'); - dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00'); - dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00'); - - expect(wrapper.find(DateRangeRow).prop('startDate')).toEqual('2016-01-01T00:00:00+01:00'); - expect(wrapper.find(DateRangeRow).prop('endDate')).toEqual('2016-01-03T00:00:00+01:00'); - }); - it('holds the map button content generator on cities graph extraHeaderContent', () => { const wrapper = createComponent({ loading: false, error: false, visits }); const locationNav = wrapper.find(NavLink).at(2);