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.
-
-
location.reload()}>Take me back
+
+
+ Oops! This is awkward :S
+ It seems that something went wrong. Try refreshing the page or just click this button.
+
+ location.reload()}>Take me back
+
);
}
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);