Repository: shlinkio/shlink-web-client
Pull request #350 (2020-12-14 22:37:21)

Feature/UI improvements
Refs #285 


Patch:
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 = (
           </Switch>
         </div>
 
-        <div className="shlink-footer text-center text-md-right">
-          <ShlinkVersions />
+        <div className="shlink-footer">
+          <ShlinkVersionsContainer />
         </div>
       </div>
     </div>
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 (
-        <div className="error-handler">
-          <h1>Oops! This is awkward :S</h1>
-          <p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
-          <br />
-          <Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
+        <div className="home">
+          <SimpleCard className="p-4">
+            <h1>Oops! This is awkward :S</h1>
+            <p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
+            <br />
+            <Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
+          </SimpleCard>
         </div>
       );
     }
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 = (
         <div className="row menu-layout__swipeable-inner">
           <AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
           <div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
-            <div className="menu-layout__container">
+            <div className="menu-layout__container container-xl">
               <Switch>
                 <Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
                 <Route exact path="/server/:serverId/overview" component={Overview} />
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<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
   <div className="home">
-    <h2>Oops! We could not find requested route.</h2>
-    <p>
-      Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
-      button.
-    </p>
-    <br />
-    <Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
+    <SimpleCard className="p-4">
+      <h2>Oops! We could not find requested route.</h2>
+      <p>
+        Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
+        button.
+      </p>
+      <br />
+      <Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
+    </SimpleCard>
   </div>
 );
 
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
   </ExternalLink>
 );
 
-const ShlinkVersions = (
-  { selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
-) => {
+const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
   const normalizedClientVersion = normalizeVersion(clientVersion);
 
   return (
-    <small className={classNames('text-muted', className)}>
+    <small className="text-muted">
       {isReachableServer(selectedServer) &&
         <>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
       }
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 (
+    <div className="row">
+      <div className={colClasses}>
+        <ShlinkVersions selectedServer={selectedServer} />
+      </div>
+    </div>
+  );
+};
+
+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
       <div className="mt-3">
         <div className="row">
           <div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
-            <DateRangeRow
-              startDate={dateOrNull(shortUrlsListParams.startDate)}
-              endDate={dateOrNull(shortUrlsListParams.endDate)}
-              onStartDateChange={setDate('startDate')}
-              onEndDateChange={setDate('endDate')}
+            <DateRangeSelector
+              defaultText="All short URLs"
+              initialDateRange={{
+                startDate: dateOrNull(shortUrlsListParams.startDate),
+                endDate: dateOrNull(shortUrlsListParams.endDate),
+              }}
+              onDatesChange={setDates}
             />
           </div>
         </div>
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<ShortUrlsRowProps>) => ({
       return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
     }
 
-    if (!loading && isEmpty(shortUrlsList)) {
+    if (!loading && isEmpty(shortUrls?.data)) {
       return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
     }
 
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<T extends string = string>(
       </DropdownToggle>
       <DropdownMenu
         right={right}
-        className={classNames('sorting-dropdown__menu', { 'sorting-dropdown__menu--link': !isButton })}
+        className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
       >
         {toPairs(items).map(([ fieldKey, fieldValue ]) => (
           <DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}>
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 = (
     <div className="col-md-6">
       <DateInput
         selected={startDate}
-        placeholderText="Since"
+        placeholderText="Since..."
         isClearable
         maxDate={endDate ?? undefined}
         disabled={disabled}
@@ -27,7 +26,7 @@ const DateRangeRow = (
       <DateInput
         className="mt-2 mt-md-0"
         selected={endDate}
-        placeholderText="Until"
+        placeholderText="Until..."
         isClearable
         minDate={startDate ?? undefined}
         disabled={disabled}
diff --git a/src/utils/dates/DateRangeSelector.scss b/src/utils/dates/DateRangeSelector.scss
new file mode 100644
index 00000000..114c2b12
--- /dev/null
+++ b/src/utils/dates/DateRangeSelector.scss
@@ -0,0 +1,19 @@
+@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):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;
+  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 (
+    <Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled}>
+      <DropdownToggle caret className="date-range-selector__btn btn-block" color="primary">
+        {rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}
+      </DropdownToggle>
+      <DropdownMenu className="w-100">
+        <DropdownItem
+          active={activeInterval === undefined && dateRangeIsEmpty(activeDateRange)}
+          onClick={updateInterval(undefined)}
+        >
+          {defaultText}
+        </DropdownItem>
+        <DropdownItem divider />
+        {([ 'today', 'yesterday', 'last7Days', 'last30Days', 'last90Days', 'last180days', 'last365Days' ] as DateInterval[]).map(
+          (interval) => (
+            <DropdownItem key={interval} active={activeInterval === interval} onClick={updateInterval(interval)}>
+              {rangeOrIntervalToString(interval)}
+            </DropdownItem>
+          ),
+        )}
+        <DropdownItem divider />
+        <DropdownItem header>Custom:</DropdownItem>
+        <DropdownItem text>
+          <DateRangeRow
+            {...activeDateRange}
+            onStartDateChange={(startDate) => updateDateRange({ ...activeDateRange, startDate })}
+            onEndDateChange={(endDate) => updateDateRange({ ...activeDateRange, endDate })}
+          />
+        </DropdownItem>
+      </DropdownMenu>
+    </Dropdown>
+  );
+};
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<DateInterval, string> = {
+  today: 'Today',
+  yesterday: 'Yesterday',
+  last7Days: 'Last 7 days',
+  last30Days: 'Last 30 days',
+  last90Days: 'Last 90 days',
+  last180days: 'Last 180 days',
+  last365Days: 'Last 365 days',
+};
+
+const dateRangeToString = (range?: DateRange): string | undefined => {
+  if (!range || dateRangeIsEmpty(range)) {
+    return undefined;
+  }
+
+  if (range.startDate && !range.endDate) {
+    return `Since ${formatInternational(range.startDate)}`;
+  }
+
+  if (!range.startDate && range.endDate) {
+    return `Until ${formatInternational(range.endDate)}`;
+  }
+
+  return `${formatInternational(range.startDate)} - ${formatInternational(range.endDate)}`;
+};
+
+export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
+  if (!range) {
+    return undefined;
+  }
+
+  if (!rangeIsInterval(range)) {
+    return dateRangeToString(range);
+  }
+
+  return INTERVAL_TO_STRING_MAP[range];
+};
+
+export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
+  if (!dateInterval) {
+    return {};
+  }
+
+  switch (dateInterval) {
+    case 'today':
+      return { startDate: moment().startOf('day'), endDate: moment() };
+    case 'yesterday':
+      const yesterday = moment().subtract(1, 'day');
+
+      return { startDate: yesterday.startOf('day'), endDate: yesterday.endOf('day') };
+    case 'last7Days':
+      return { startDate: moment().subtract(7, 'days').startOf('day'), endDate: moment() };
+    case 'last30Days':
+      return { startDate: moment().subtract(30, 'days').startOf('day'), endDate: moment() };
+    case 'last90Days':
+      return { startDate: moment().subtract(90, 'days').startOf('day'), endDate: moment() };
+    case 'last180days':
+      return { startDate: moment().subtract(180, 'days').startOf('day'), endDate: moment() };
+    case 'last365Days':
+      return { startDate: moment().subtract(365, 'days').startOf('day'), endDate: moment() };
+  }
+
+  return {};
+};
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<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
       <section className="mt-4">
         <div className="row flex-md-row-reverse">
           <div className="col-lg-7 col-xl-6">
-            <DateRangeRow
+            <DateRangeSelector
               disabled={loading}
-              startDate={startDate}
-              endDate={endDate}
-              onStartDateChange={setStartDate}
-              onEndDateChange={setEndDate}
+              defaultText="All visits"
+              onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
+                setStartDate(newStartDate ?? null);
+                setEndDate(newEndDate ?? null);
+              }}
             />
           </div>
           {visits.length > 0 && (


Test patch:
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('<ErrorHandler />', () => {
   const window = Mock.of<Window>({
@@ -28,10 +29,10 @@ describe('<ErrorHandler />', () => {
   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('<NotFound />', () => {
   let wrapper: ShallowWrapper;
   const createWrapper = (props = {}) => {
-    wrapper = shallow(<NotFound {...props} />);
-    const content = wrapper.text();
+    wrapper = shallow(<NotFound {...props} />).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('<NotFound />', () => {
   });
 
   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('<ShlinkVersionsContainer />', () => {
+  let wrapper: ShallowWrapper;
+
+  const createWrapper = (selectedServer: SelectedServer) => {
+    wrapper = shallow(<ShlinkVersionsContainer selectedServer={selectedServer} />);
+
+    return wrapper;
+  };
+
+  afterEach(() => wrapper?.unmount());
+
+  test.each([
+    [ null, 'col-12' ],
+    [ Mock.of<NotFoundServer>({ serverNotFound: true }), 'col-12' ],
+    [ Mock.of<NonReachableServer>({ serverNotReachable: true }), 'col-12' ],
+    [ Mock.of<ReachableServer>({ 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('<SearchBar />', () => {
@@ -20,10 +20,10 @@ describe('<SearchBar />', () => {
     expect(wrapper.find(SearchField)).toHaveLength(1);
   });
 
-  it('renders a DateRangeRow', () => {
+  it('renders a DateRangeSelector', () => {
     wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
 
-    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('<SearchBar />', () => {
     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(
       <SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />,
     );
-    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('<DateRangeRow />', () => {
   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('<DateRangeSelector />', () => {
+  let wrapper: ShallowWrapper;
+  const onDatesChange = jest.fn();
+  const createWrapper = (props: Partial<DateRangeSelectorProps> = {}) => {
+    wrapper = shallow(<DateRangeSelector {...Mock.of<DateRangeSelectorProps>(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('<VisitStats />', () => {
     expect(table).toHaveLength(expectedTables);
   });
 
-  it('reloads visits when selected dates change', () => {
-    const wrapper = createComponent({ loading: false, error: false, visits });
-    const dateRange = wrapper.find(DateRangeRow);
-
-    dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00');
-    dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00');
-    dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00');
-
-    expect(wrapper.find(DateRangeRow).prop('startDate')).toEqual('2016-01-01T00:00:00+01:00');
-    expect(wrapper.find(DateRangeRow).prop('endDate')).toEqual('2016-01-03T00:00:00+01:00');
-  });
-
   it('holds the map button content generator on cities graph extraHeaderContent', () => {
     const wrapper = createComponent({ loading: false, error: false, visits });
     const locationNav = wrapper.find(NavLink).at(2);