mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 01:37:24 +03:00
Created new dropdown component to select relative or absolute date ranges
This commit is contained in:
parent
288f6e2cf8
commit
4e236a80de
13 changed files with 288 additions and 34 deletions
|
@ -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';
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
.sorting-dropdown__menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sorting-dropdown__menu--link.sorting-dropdown__menu--link {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ export default function SortingDropdown<T extends string = string>(
|
|||
</DropdownToggle>
|
||||
<DropdownMenu
|
||||
right={right}
|
||||
className={classNames('sorting-dropdown__menu', { 'sorting-dropdown__menu--link': !isButton })}
|
||||
className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
|
||||
>
|
||||
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
||||
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
||||
|
|
|
@ -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 = (
|
|||
<div className="col-md-6">
|
||||
<DateInput
|
||||
selected={startDate}
|
||||
placeholderText="Since"
|
||||
placeholderText="Since..."
|
||||
isClearable
|
||||
maxDate={endDate ?? undefined}
|
||||
disabled={disabled}
|
||||
|
@ -27,7 +26,7 @@ const DateRangeRow = (
|
|||
<DateInput
|
||||
className="mt-2 mt-md-0"
|
||||
selected={endDate}
|
||||
placeholderText="Until"
|
||||
placeholderText="Until..."
|
||||
isClearable
|
||||
minDate={startDate ?? undefined}
|
||||
disabled={disabled}
|
18
src/utils/dates/DateRangeSelector.scss
Normal file
18
src/utils/dates/DateRangeSelector.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
@import '../../utils/mixins/vertical-align';
|
||||
|
||||
.date-range-selector__btn.date-range-selector__btn,
|
||||
.date-range-selector__btn.date-range-selector__btn:not(:disabled):not(.disabled):active,
|
||||
.date-range-selector__btn.date-range-selector__btn:not(:disabled):not(.disabled).active,
|
||||
.date-range-selector__btn.date-range-selector__btn:not(:disabled):not(.disabled):hover,
|
||||
.show > .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;
|
||||
}
|
84
src/utils/dates/DateRangeSelector.tsx
Normal file
84
src/utils/dates/DateRangeSelector.tsx
Normal file
|
@ -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 (
|
||||
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled}>
|
||||
<DropdownToggle caret className="date-range-selector__btn btn-block" color="primary">
|
||||
{rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? 'All visits'}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu className="w-100">
|
||||
<DropdownItem
|
||||
active={activeInterval === undefined && dateRangeIsEmpty(activeDateRange)}
|
||||
onClick={updateInterval(undefined)}
|
||||
>
|
||||
All visits
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem active={activeInterval === 'today'} onClick={updateInterval('today')}>Today</DropdownItem>
|
||||
<DropdownItem active={activeInterval === 'yesterday'} onClick={updateInterval('yesterday')}>
|
||||
Yesterday
|
||||
</DropdownItem>
|
||||
<DropdownItem active={activeInterval === 'last7Days'} onClick={updateInterval('last7Days')}>
|
||||
Last 7 days
|
||||
</DropdownItem>
|
||||
<DropdownItem active={activeInterval === 'last30Days'} onClick={updateInterval('last30Days')}>
|
||||
Last 30 days
|
||||
</DropdownItem>
|
||||
<DropdownItem active={activeInterval === 'last90Days'} onClick={updateInterval('last90Days')}>
|
||||
Last 90 days
|
||||
</DropdownItem>
|
||||
<DropdownItem active={activeInterval === 'last180days'} onClick={updateInterval('last180days')}>
|
||||
Last 180 days
|
||||
</DropdownItem>
|
||||
<DropdownItem active={activeInterval === 'last365Days'} onClick={updateInterval('last365Days')}>
|
||||
Last 365 days
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem header>Custom:</DropdownItem>
|
||||
<DropdownItem text>
|
||||
<DateRangeRow
|
||||
{...activeDateRange}
|
||||
onStartDateChange={(startDate) => updateDateRange({ ...activeDateRange, startDate })}
|
||||
onEndDateChange={(endDate) => updateDateRange({ ...activeDateRange, endDate })}
|
||||
/>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
80
src/utils/dates/types/index.ts
Normal file
80
src/utils/dates/types/index.ts
Normal file
|
@ -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<DateInterval, string> = {
|
||||
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 {};
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
|
|||
<section className="mt-4">
|
||||
<div className="row flex-md-row-reverse">
|
||||
<div className="col-lg-7 col-xl-6">
|
||||
<DateRangeRow
|
||||
<DateRangeSelector
|
||||
disabled={loading}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
|
||||
setStartDate(newStartDate ?? null);
|
||||
setEndDate(newEndDate ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{visits.length > 0 && (
|
||||
|
|
|
@ -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('<SearchBar />', () => {
|
||||
|
|
|
@ -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('<DateRangeRow />', () => {
|
||||
let wrapper: ShallowWrapper;
|
88
test/utils/dates/types/index.test.ts
Normal file
88
test/utils/dates/types/index.test.ts
Normal file
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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('<VisitStats />', () => {
|
|||
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);
|
||||
|
|
Loading…
Reference in a new issue