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/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. 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", 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/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/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/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/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/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; } diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/SearchBar.tsx index bd22a68b..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/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/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..7fbdc7bf --- /dev/null +++ b/src/utils/dates/DateRangeSelector.tsx @@ -0,0 +1,75 @@ +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'; + +export interface DateRangeSelectorProps { + initialDateRange?: DateInterval | DateRange; + disabled?: boolean; + onDatesChange: (dateRange: DateRange) => void; + defaultText: string; +} + +export const DateRangeSelector = ( + { onDatesChange, initialDateRange, defaultText, 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) ?? defaultText} + + + + {defaultText} + + + {([ 'today', 'yesterday', 'last7Days', 'last30Days', 'last90Days', 'last180days', 'last365Days' ] as DateInterval[]).map( + (interval) => ( + + {rangeOrIntervalToString(interval)} + + ), + )} + + 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..9e69fa70 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,13 @@ const VisitsStats: FC = ({ children, visitsInfo, getVisits, ca
- { + setStartDate(newStartDate ?? null); + setEndDate(newEndDate ?? null); + }} />
{visits.length > 0 && ( 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'); 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}`); + }); +}); diff --git a/test/short-urls/SearchBar.test.tsx b/test/short-urls/SearchBar.test.tsx index 842fcb8f..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/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); }); }); 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/DateRangeSelector.test.tsx b/test/utils/dates/DateRangeSelector.test.tsx new file mode 100644 index 00000000..0df6fa18 --- /dev/null +++ b/test/utils/dates/DateRangeSelector.test.tsx @@ -0,0 +1,58 @@ +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'; +import { Mock } from 'ts-mockery'; + +describe('', () => { + let wrapper: ShallowWrapper; + const onDatesChange = jest.fn(); + const createWrapper = (props: Partial = {}) => { + wrapper = shallow((props)} onDatesChange={onDatesChange} />); + + 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); + }); +}); 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);