From f2a8865679c32f97f56bbc6c3405adb51849c051 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Dec 2020 20:57:00 +0100 Subject: [PATCH 1/9] Added new card styles to error pages --- src/common/ErrorHandler.scss | 9 --------- src/common/ErrorHandler.tsx | 16 +++++++++------- src/common/NotFound.tsx | 17 ++++++++++------- test/common/ErrorHandler.test.tsx | 7 ++++--- test/common/NotFound.test.tsx | 20 ++++++++++---------- 5 files changed, 33 insertions(+), 36 deletions(-) delete mode 100644 src/common/ErrorHandler.scss diff --git a/src/common/ErrorHandler.scss b/src/common/ErrorHandler.scss deleted file mode 100644 index 0c757135..00000000 --- a/src/common/ErrorHandler.scss +++ /dev/null @@ -1,9 +0,0 @@ -@import '../utils/mixins/vertical-align.scss'; - -.error-handler { - @include vertical-align(); - - padding: 20px; - text-align: center; - width: 100%; -} diff --git a/src/common/ErrorHandler.tsx b/src/common/ErrorHandler.tsx index e8333ee0..8bf828c8 100644 --- a/src/common/ErrorHandler.tsx +++ b/src/common/ErrorHandler.tsx @@ -1,6 +1,6 @@ import { Component, ReactNode } from 'react'; import { Button } from 'reactstrap'; -import './ErrorHandler.scss'; +import { SimpleCard } from '../utils/SimpleCard'; interface ErrorHandlerState { hasError: boolean; @@ -25,14 +25,16 @@ const ErrorHandler = ( } } - public render(): ReactNode | undefined { + public render(): ReactNode { if (this.state.hasError) { return ( -
-

Oops! This is awkward :S

-

It seems that something went wrong. Try refreshing the page or just click this button.

-
- +
+ +

Oops! This is awkward :S

+

It seems that something went wrong. Try refreshing the page or just click this button.

+
+ +
); } diff --git a/src/common/NotFound.tsx b/src/common/NotFound.tsx index d9d781c4..5770fda3 100644 --- a/src/common/NotFound.tsx +++ b/src/common/NotFound.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; import { Link } from 'react-router-dom'; +import { SimpleCard } from '../utils/SimpleCard'; interface NotFoundProps { to?: string; @@ -7,13 +8,15 @@ interface NotFoundProps { const NotFound: FC = ({ to = '/', children = 'Home' }) => (
-

Oops! We could not find requested route.

-

- Use your browser's back button to navigate to the page you have previously come from, or just press this - button. -

-
- {children} + +

Oops! We could not find requested route.

+

+ Use your browser's back button to navigate to the page you have previously come from, or just press this + button. +

+
+ {children} +
); diff --git a/test/common/ErrorHandler.test.tsx b/test/common/ErrorHandler.test.tsx index 13e57f4e..e3c741a0 100644 --- a/test/common/ErrorHandler.test.tsx +++ b/test/common/ErrorHandler.test.tsx @@ -2,6 +2,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Button } from 'reactstrap'; import { Mock } from 'ts-mockery'; import createErrorHandler from '../../src/common/ErrorHandler'; +import { SimpleCard } from '../../src/utils/SimpleCard'; describe('', () => { const window = Mock.of({ @@ -28,10 +29,10 @@ describe('', () => { it('renders error page when error has occurred', () => { wrapper.setState({ hasError: true }); - expect(wrapper.text()).toContain('Oops! This is awkward :S'); - expect(wrapper.text()).toContain( + expect(wrapper.find(SimpleCard).contains('Oops! This is awkward :S')).toEqual(true); + expect(wrapper.find(SimpleCard).contains( 'It seems that something went wrong. Try refreshing the page or just click this button.', - ); + )).toEqual(true); expect(wrapper.find(Button)).toHaveLength(1); }); }); diff --git a/test/common/NotFound.test.tsx b/test/common/NotFound.test.tsx index ecd1a487..e20a7512 100644 --- a/test/common/NotFound.test.tsx +++ b/test/common/NotFound.test.tsx @@ -1,34 +1,34 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Link } from 'react-router-dom'; import NotFound from '../../src/common/NotFound'; +import { SimpleCard } from '../../src/utils/SimpleCard'; describe('', () => { let wrapper: ShallowWrapper; const createWrapper = (props = {}) => { - wrapper = shallow(); - const content = wrapper.text(); + wrapper = shallow().find(SimpleCard); - return { wrapper, content }; + return wrapper; }; afterEach(() => wrapper?.unmount()); it('shows expected error title', () => { - const { content } = createWrapper(); + const wrapper = createWrapper(); - expect(content).toContain('Oops! We could not find requested route.'); + expect(wrapper.contains('Oops! We could not find requested route.')).toEqual(true); }); it('shows expected error message', () => { - const { content } = createWrapper(); + const wrapper = createWrapper(); - expect(content).toContain( + expect(wrapper.contains( 'Use your browser\'s back button to navigate to the page you have previously come from, or just press this button.', - ); + )).toEqual(true); }); it('shows a link to the home', () => { - const { wrapper } = createWrapper(); + const wrapper = createWrapper(); const link = wrapper.find(Link); expect(link.prop('to')).toEqual('/'); @@ -37,7 +37,7 @@ describe('', () => { }); it('shows a link with provided props', () => { - const { wrapper } = createWrapper({ to: '/foo/bar', children: 'Hello' }); + const wrapper = createWrapper({ to: '/foo/bar', children: 'Hello' }); const link = wrapper.find(Link); expect(link.prop('to')).toEqual('/foo/bar'); From 9b6d4a4d97e91d47e66316c0b651876a5d63567d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Dec 2020 18:39:19 +0100 Subject: [PATCH 2/9] Added max-width to internal container --- src/common/MenuLayout.tsx | 2 +- src/common/NoMenuLayout.scss | 8 +++++++- src/index.scss | 11 +++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 697b3830..15f8c491 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -59,7 +59,7 @@ const MenuLayout = (
hideSidebar()}> -
+
diff --git a/src/common/NoMenuLayout.scss b/src/common/NoMenuLayout.scss index ffacd0af..05273173 100644 --- a/src/common/NoMenuLayout.scss +++ b/src/common/NoMenuLayout.scss @@ -1,3 +1,9 @@ +@import '../utils/base'; + .no-menu-wrapper { - padding: 30px 20px 20px; + padding: 15px 0 0; + + @media (min-width: $mdMin) { + padding: 30px 20px 20px; + } } diff --git a/src/index.scss b/src/index.scss index 8137971b..d5542c25 100644 --- a/src/index.scss +++ b/src/index.scss @@ -29,6 +29,17 @@ body, background-color: rgba(255, 255, 255, .5); } +.container-xl { + @media (min-width: $xlgMin) { + max-width: 1320px; + } + + @media (max-width: $smMax) { + padding-right: 0; + padding-left: 0; + } +} + .dropdown-item:not(:disabled) { cursor: pointer; } From 288f6e2cf81be089b2f3c831e613572ddacd47f5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Dec 2020 19:05:25 +0100 Subject: [PATCH 3/9] Fixed rendering of ShlinkVersions component to match current layout --- src/App.tsx | 6 ++--- src/common/ShlinkVersions.tsx | 14 ++++------ src/common/ShlinkVersionsContainer.tsx | 25 ++++++++++++++++++ src/common/services/provideServices.ts | 6 ++--- src/container/index.ts | 12 ++++++++- test/common/ShlinkVersionsContainer.test.tsx | 27 ++++++++++++++++++++ 6 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/common/ShlinkVersionsContainer.tsx create mode 100644 test/common/ShlinkVersionsContainer.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 75579032..4928f42d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ const App = ( CreateServer: FC, EditServer: FC, Settings: FC, - ShlinkVersions: FC, + ShlinkVersionsContainer: FC, ) => ({ fetchServers, servers }: AppProps) => { // On first load, try to fetch the remote servers if the list is empty useEffect(() => { @@ -41,8 +41,8 @@ const App = (
-
- +
+
diff --git a/src/common/ShlinkVersions.tsx b/src/common/ShlinkVersions.tsx index 5fb3eb6f..188f8f8d 100644 --- a/src/common/ShlinkVersions.tsx +++ b/src/common/ShlinkVersions.tsx @@ -1,16 +1,14 @@ -import classNames from 'classnames'; import { pipe } from 'ramda'; import { ExternalLink } from 'react-external-link'; import { versionToPrintable, versionToSemVer } from '../utils/helpers/version'; -import { isReachableServer, SelectedServer } from '../servers/data'; +import { isReachableServer } from '../servers/data'; +import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer'; const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%'; const normalizeVersion = pipe(versionToSemVer(), versionToPrintable); -export interface ShlinkVersionsProps { - selectedServer: SelectedServer; +export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps { clientVersion?: string; - className?: string; } const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => ( @@ -19,13 +17,11 @@ const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-cli ); -const ShlinkVersions = ( - { selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps, -) => { +const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => { const normalizedClientVersion = normalizeVersion(clientVersion); return ( - + {isReachableServer(selectedServer) && <>Server: - } diff --git a/src/common/ShlinkVersionsContainer.tsx b/src/common/ShlinkVersionsContainer.tsx new file mode 100644 index 00000000..894174fc --- /dev/null +++ b/src/common/ShlinkVersionsContainer.tsx @@ -0,0 +1,25 @@ +import classNames from 'classnames'; +import { isReachableServer, SelectedServer } from '../servers/data'; +import ShlinkVersions from './ShlinkVersions'; + +export interface ShlinkVersionsContainerProps { + selectedServer: SelectedServer; +} + +const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => { + const serverIsReachable = isReachableServer(selectedServer); + const colClasses = classNames('text-center', { + 'col-12': !serverIsReachable, + 'col-lg-10 offset-lg-2 col-md-9 offset-md-3': serverIsReachable, + }); + + return ( +
+
+ +
+
+ ); +}; + +export default ShlinkVersionsContainer; diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index 94f5d3bc..fd631e10 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -5,7 +5,7 @@ import Home from '../Home'; import MenuLayout from '../MenuLayout'; import AsideMenu from '../AsideMenu'; import ErrorHandler from '../ErrorHandler'; -import ShlinkVersions from '../ShlinkVersions'; +import ShlinkVersionsContainer from '../ShlinkVersionsContainer'; import { ConnectDecorator } from '../../container/types'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; @@ -40,8 +40,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton'); - bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions); - bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ])); + bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer); + bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ])); bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console'); }; diff --git a/src/container/index.ts b/src/container/index.ts index 685fbf84..5e746c62 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -31,7 +31,17 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic actionServiceNames.reduce(mapActionService, {}), ); -bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings', 'ShlinkVersions'); +bottle.serviceFactory( + 'App', + App, + 'MainHeader', + 'Home', + 'MenuLayout', + 'CreateServer', + 'EditServer', + 'Settings', + 'ShlinkVersionsContainer', +); bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ])); provideCommonServices(bottle, connect, withRouter); diff --git a/test/common/ShlinkVersionsContainer.test.tsx b/test/common/ShlinkVersionsContainer.test.tsx new file mode 100644 index 00000000..063dcdf4 --- /dev/null +++ b/test/common/ShlinkVersionsContainer.test.tsx @@ -0,0 +1,27 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import ShlinkVersionsContainer from '../../src/common/ShlinkVersionsContainer'; +import { NonReachableServer, NotFoundServer, ReachableServer, SelectedServer } from '../../src/servers/data'; + +describe('', () => { + let wrapper: ShallowWrapper; + + const createWrapper = (selectedServer: SelectedServer) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + + test.each([ + [ null, 'col-12' ], + [ Mock.of({ serverNotFound: true }), 'col-12' ], + [ Mock.of({ serverNotReachable: true }), 'col-12' ], + [ Mock.of({ printableVersion: 'v1.0.0' }), 'col-lg-10 offset-lg-2 col-md-9 offset-md-3' ], + ])('renders proper col classes based on type of selected server', (selectedServer, expectedClasses) => { + const wrapper = createWrapper(selectedServer); + + expect(wrapper.find('div').at(1).prop('className')).toEqual(`text-center ${expectedClasses}`); + }); +}); From 4e236a80dee2562c2a62a3e602143c5ae63c1e01 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Dec 2020 22:58:15 +0100 Subject: [PATCH 4/9] 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); From 3f245a757e9c70174e47f49ad8f1f6df6f460f14 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Dec 2020 23:15:06 +0100 Subject: [PATCH 5/9] Created DateRangeSelector test --- src/utils/dates/DateRangeSelector.tsx | 2 +- test/utils/dates/DateRangeSelector.test.tsx | 57 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 test/utils/dates/DateRangeSelector.test.tsx diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx index a71d792d..3cb80c3b 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/src/utils/dates/DateRangeSelector.tsx @@ -12,7 +12,7 @@ import { import DateRangeRow from './DateRangeRow'; import './DateRangeSelector.scss'; -interface DateRangeSelectorProps { +export interface DateRangeSelectorProps { initialDateRange?: DateInterval | DateRange; disabled?: boolean; onDatesChange: (dateRange: DateRange) => void; diff --git a/test/utils/dates/DateRangeSelector.test.tsx b/test/utils/dates/DateRangeSelector.test.tsx new file mode 100644 index 00000000..7daadeb1 --- /dev/null +++ b/test/utils/dates/DateRangeSelector.test.tsx @@ -0,0 +1,57 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import moment from 'moment'; +import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector'; +import { DateInterval } from '../../../src/utils/dates/types'; + +describe('', () => { + let wrapper: ShallowWrapper; + const onDatesChange = jest.fn(); + const createWrapper = (props: Partial = {}) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + test('proper amount of items is rendered', () => { + const wrapper = createWrapper(); + const items = wrapper.find(DropdownItem); + + expect(items).toHaveLength(12); + expect(items.filter('[divider]')).toHaveLength(2); + expect(items.filter('[header]')).toHaveLength(1); + expect(items.filter('[text]')).toHaveLength(1); + expect(items.filter('[active]')).toHaveLength(8); + }); + + test.each([ + [ undefined, 0 ], + [ 'today' as DateInterval, 1 ], + [ 'yesterday' as DateInterval, 2 ], + [ 'last7Days' as DateInterval, 3 ], + [ 'last30Days' as DateInterval, 4 ], + [ 'last90Days' as DateInterval, 5 ], + [ 'last180days' as DateInterval, 6 ], + [ 'last365Days' as DateInterval, 7 ], + [{ startDate: moment() }, 8 ], + ])('proper element is active based on provided date range', (initialDateRange, expectedActiveIndex) => { + const wrapper = createWrapper({ initialDateRange }); + const items = wrapper.find(DropdownItem).filter('[active]'); + + expect.assertions(8); + items.forEach((item, index) => expect(item.prop('active')).toEqual(index === expectedActiveIndex)); + }); + + test('selecting an element triggers onDatesChange callback', () => { + const wrapper = createWrapper(); + const items = wrapper.find(DropdownItem).filter('[active]'); + + items.at(2).simulate('click'); + items.at(4).simulate('click'); + items.at(1).simulate('click'); + expect(onDatesChange).toHaveBeenCalledTimes(3); + }); +}); From 61a1087d9138f4ac52e7191eaeebc0232b155212 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Dec 2020 23:35:31 +0100 Subject: [PATCH 6/9] Added date range selector to short URLs list --- src/short-urls/SearchBar.tsx | 26 ++++++++++++-------- src/short-urls/ShortUrlsTable.tsx | 2 +- src/utils/dates/DateRangeSelector.tsx | 35 ++++++++++----------------- src/visits/VisitsStats.tsx | 1 + test/short-urls/SearchBar.test.tsx | 12 ++++----- 5 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/SearchBar.tsx index a6da0273..9f78f352 100644 --- a/src/short-urls/SearchBar.tsx +++ b/src/short-urls/SearchBar.tsx @@ -4,9 +4,10 @@ import { isEmpty, pipe } from 'ramda'; import moment from 'moment'; import SearchField from '../utils/SearchField'; import Tag from '../tags/helpers/Tag'; -import DateRangeRow from '../utils/dates/DateRangeRow'; -import { formatDate } from '../utils/helpers/date'; +import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; +import { formatIsoDate } from '../utils/helpers/date'; import ColorGenerator from '../utils/services/ColorGenerator'; +import { DateRange } from '../utils/dates/types'; import { ShortUrlsListParams } from './reducers/shortUrlsListParams'; import './SearchBar.scss'; @@ -19,9 +20,12 @@ const dateOrNull = (date?: string) => date ? moment(date) : null; const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => { const selectedTags = shortUrlsListParams.tags ?? []; - const setDate = (dateName: 'startDate' | 'endDate') => pipe( - formatDate(), - (date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date }), + const setDates = pipe( + ({ startDate, endDate }: DateRange) => ({ + startDate: formatIsoDate(startDate) ?? undefined, + endDate: formatIsoDate(endDate) ?? undefined, + }), + (dates) => listShortUrls({ ...shortUrlsListParams, ...dates }), ); return ( @@ -35,11 +39,13 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
-
diff --git a/src/short-urls/ShortUrlsTable.tsx b/src/short-urls/ShortUrlsTable.tsx index c1a1fb77..55351166 100644 --- a/src/short-urls/ShortUrlsTable.tsx +++ b/src/short-urls/ShortUrlsTable.tsx @@ -45,7 +45,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ return Loading...; } - if (!loading && isEmpty(shortUrlsList)) { + if (!loading && isEmpty(shortUrls?.data)) { return No results found; } diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx index 3cb80c3b..7fbdc7bf 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/src/utils/dates/DateRangeSelector.tsx @@ -16,9 +16,12 @@ export interface DateRangeSelectorProps { initialDateRange?: DateInterval | DateRange; disabled?: boolean; onDatesChange: (dateRange: DateRange) => void; + defaultText: string; } -export const DateRangeSelector = ({ onDatesChange, initialDateRange, disabled = false }: DateRangeSelectorProps) => { +export const DateRangeSelector = ( + { onDatesChange, initialDateRange, defaultText, disabled = false }: DateRangeSelectorProps, +) => { const [ isOpen, toggle ] = useToggle(); const [ activeInterval, setActiveInterval ] = useState( rangeIsInterval(initialDateRange) ? initialDateRange : undefined, @@ -40,35 +43,23 @@ export const DateRangeSelector = ({ onDatesChange, initialDateRange, disabled = return ( - {rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? 'All visits'} + {rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText} - All visits + {defaultText} - Today - - Yesterday - - - Last 7 days - - - Last 30 days - - - Last 90 days - - - Last 180 days - - - Last 365 days - + {([ 'today', 'yesterday', 'last7Days', 'last30Days', 'last90Days', 'last180days', 'last365Days' ] as DateInterval[]).map( + (interval) => ( + + {rangeOrIntervalToString(interval)} + + ), + )} Custom: diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 6389fda1..9e69fa70 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -227,6 +227,7 @@ const VisitsStats: FC = ({ children, visitsInfo, getVisits, ca
{ setStartDate(newStartDate ?? null); setEndDate(newEndDate ?? null); diff --git a/test/short-urls/SearchBar.test.tsx b/test/short-urls/SearchBar.test.tsx index dd0fd28b..f16ba98d 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/dates/DateRangeRow'; +import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector'; import ColorGenerator from '../../src/utils/services/ColorGenerator'; describe('', () => { @@ -20,10 +20,10 @@ describe('', () => { expect(wrapper.find(SearchField)).toHaveLength(1); }); - it('renders a DateRangeRow', () => { + it('renders a DateRangeSelector', () => { wrapper = shallow(); - expect(wrapper.find(DateRangeRow)).toHaveLength(1); + expect(wrapper.find(DateRangeSelector)).toHaveLength(1); }); it('renders no tags when the list of tags is empty', () => { @@ -60,14 +60,14 @@ describe('', () => { expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); - it.each([ 'startDateChange', 'endDateChange' ])('updates short URLs list when date range changes', (event) => { + it('updates short URLs list when date range changes', () => { wrapper = shallow( , ); - const dateRange = wrapper.find(DateRangeRow); + const dateRange = wrapper.find(DateRangeSelector); expect(listShortUrlsMock).not.toHaveBeenCalled(); - dateRange.simulate(event); + dateRange.simulate('datesChange', {}); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); }); From 95abf4f898245745f0e813eb3ff1cea6809eae24 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 14 Dec 2020 23:36:58 +0100 Subject: [PATCH 7/9] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15318b2b..5715b9c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * Charts are now grouped in tabs, so that only one part of the components is rendered at a time. * Amount of highlighted visits is now displayed. + * Date filtering can be now selected through relative times (last 7 days, last 30 days, etc) or absolute dates using date pickers. ### Changed * [#267](https://github.com/shlinkio/shlink-web-client/issues/267) Added some subtle but important improvements on UI/UX. From d39512732ad98148e1bb1b8e8d23d805f24b3bc5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 15 Dec 2020 09:54:45 +0100 Subject: [PATCH 8/9] Fixed DateRangeSelector focus state --- src/utils/dates/DateRangeSelector.scss | 3 ++- test/utils/dates/DateRangeSelector.test.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/utils/dates/DateRangeSelector.scss b/src/utils/dates/DateRangeSelector.scss index c8df5a84..114c2b12 100644 --- a/src/utils/dates/DateRangeSelector.scss +++ b/src/utils/dates/DateRangeSelector.scss @@ -1,8 +1,9 @@ @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):active, +.date-range-selector__btn.date-range-selector__btn:not(:disabled):not(.disabled):focus, .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; diff --git a/test/utils/dates/DateRangeSelector.test.tsx b/test/utils/dates/DateRangeSelector.test.tsx index 7daadeb1..0df6fa18 100644 --- a/test/utils/dates/DateRangeSelector.test.tsx +++ b/test/utils/dates/DateRangeSelector.test.tsx @@ -3,12 +3,13 @@ import { DropdownItem } from 'reactstrap'; import moment from 'moment'; import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector'; import { DateInterval } from '../../../src/utils/dates/types'; +import { Mock } from 'ts-mockery'; describe('', () => { let wrapper: ShallowWrapper; const onDatesChange = jest.fn(); const createWrapper = (props: Partial = {}) => { - wrapper = shallow(); + wrapper = shallow((props)} onDatesChange={onDatesChange} />); return wrapper; }; From ec3ad8412c0869bc037de656e38b098aa906a7d6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 15 Dec 2020 10:01:15 +0100 Subject: [PATCH 9/9] Fixed mutation tests in ci workflow --- .github/workflows/ci.yml | 2 +- package.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74254140..ea2f4f13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: with: node-version: 14.15 - run: npm ci - - run: MUTATION_FILES=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",") npm run mutate:ci + - run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",") build-docker-image: runs-on: ubuntu-latest diff --git a/package.json b/package.json index 03540103..30bb5390 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,7 @@ "test": "node scripts/test.js --env=jsdom --colors", "test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover", "test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html", - "mutate": "./node_modules/.bin/stryker run --concurrency 4", - "mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES" + "mutate": "./node_modules/.bin/stryker run --concurrency 4" }, "dependencies": { "@fortawesome/fontawesome-free": "^5.15.1",