mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #350 from acelaya-forks/feature/ui-improvements
Feature/UI improvements
This commit is contained in:
commit
f1f3c3f98b
32 changed files with 489 additions and 106 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -44,7 +44,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 14.15
|
node-version: 14.15
|
||||||
- run: npm ci
|
- 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:
|
build-docker-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
@ -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.
|
* 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.
|
* 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
|
### Changed
|
||||||
* [#267](https://github.com/shlinkio/shlink-web-client/issues/267) Added some subtle but important improvements on UI/UX.
|
* [#267](https://github.com/shlinkio/shlink-web-client/issues/267) Added some subtle but important improvements on UI/UX.
|
||||||
|
|
|
@ -17,8 +17,7 @@
|
||||||
"test": "node scripts/test.js --env=jsdom --colors",
|
"test": "node scripts/test.js --env=jsdom --colors",
|
||||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"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",
|
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4",
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||||
|
|
|
@ -16,7 +16,7 @@ const App = (
|
||||||
CreateServer: FC,
|
CreateServer: FC,
|
||||||
EditServer: FC,
|
EditServer: FC,
|
||||||
Settings: FC,
|
Settings: FC,
|
||||||
ShlinkVersions: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers }: AppProps) => {
|
) => ({ fetchServers, servers }: AppProps) => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -41,8 +41,8 @@ const App = (
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shlink-footer text-center text-md-right">
|
<div className="shlink-footer">
|
||||||
<ShlinkVersions />
|
<ShlinkVersionsContainer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
@import '../utils/mixins/vertical-align.scss';
|
|
||||||
|
|
||||||
.error-handler {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, ReactNode } from 'react';
|
import { Component, ReactNode } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import './ErrorHandler.scss';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
interface ErrorHandlerState {
|
interface ErrorHandlerState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
|
@ -25,14 +25,16 @@ const ErrorHandler = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): ReactNode | undefined {
|
public render(): ReactNode {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="error-handler">
|
<div className="home">
|
||||||
|
<SimpleCard className="p-4">
|
||||||
<h1>Oops! This is awkward :S</h1>
|
<h1>Oops! This is awkward :S</h1>
|
||||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||||
<br />
|
<br />
|
||||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||||
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ const MenuLayout = (
|
||||||
<div className="row menu-layout__swipeable-inner">
|
<div className="row menu-layout__swipeable-inner">
|
||||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
<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="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>
|
<Switch>
|
||||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||||
<Route exact path="/server/:serverId/overview" component={Overview} />
|
<Route exact path="/server/:serverId/overview" component={Overview} />
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
.no-menu-wrapper {
|
.no-menu-wrapper {
|
||||||
|
padding: 15px 0 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
padding: 30px 20px 20px;
|
padding: 30px 20px 20px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
interface NotFoundProps {
|
interface NotFoundProps {
|
||||||
to?: string;
|
to?: string;
|
||||||
|
@ -7,6 +8,7 @@ interface NotFoundProps {
|
||||||
|
|
||||||
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
|
<SimpleCard className="p-4">
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
<h2>Oops! We could not find requested route.</h2>
|
||||||
<p>
|
<p>
|
||||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||||
|
@ -14,6 +16,7 @@ const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||||
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
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 SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||||
|
|
||||||
export interface ShlinkVersionsProps {
|
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
|
||||||
selectedServer: SelectedServer;
|
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: 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>
|
</ExternalLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ShlinkVersions = (
|
const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
|
||||||
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
|
|
||||||
) => {
|
|
||||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<small className={classNames('text-muted', className)}>
|
<small className="text-muted">
|
||||||
{isReachableServer(selectedServer) &&
|
{isReachableServer(selectedServer) &&
|
||||||
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||||
}
|
}
|
||||||
|
|
25
src/common/ShlinkVersionsContainer.tsx
Normal file
25
src/common/ShlinkVersionsContainer.tsx
Normal file
|
@ -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;
|
|
@ -5,7 +5,7 @@ import Home from '../Home';
|
||||||
import MenuLayout from '../MenuLayout';
|
import MenuLayout from '../MenuLayout';
|
||||||
import AsideMenu from '../AsideMenu';
|
import AsideMenu from '../AsideMenu';
|
||||||
import ErrorHandler from '../ErrorHandler';
|
import ErrorHandler from '../ErrorHandler';
|
||||||
import ShlinkVersions from '../ShlinkVersions';
|
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
|
||||||
|
@ -40,8 +40,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
||||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||||
};
|
};
|
||||||
|
|
|
@ -31,7 +31,17 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||||
actionServiceNames.reduce(mapActionService, {}),
|
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' ]));
|
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
||||||
|
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
|
|
|
@ -29,6 +29,17 @@ body,
|
||||||
background-color: rgba(255, 255, 255, .5);
|
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) {
|
.dropdown-item:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,10 @@ import { isEmpty, pipe } from 'ramda';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
import Tag from '../tags/helpers/Tag';
|
||||||
import DateRangeRow from '../utils/DateRangeRow';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
||||||
import './SearchBar.scss';
|
import './SearchBar.scss';
|
||||||
|
|
||||||
|
@ -19,9 +20,12 @@ const dateOrNull = (date?: string) => date ? moment(date) : null;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
||||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
const selectedTags = shortUrlsListParams.tags ?? [];
|
||||||
const setDate = (dateName: 'startDate' | 'endDate') => pipe(
|
const setDates = pipe(
|
||||||
formatDate(),
|
({ startDate, endDate }: DateRange) => ({
|
||||||
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date }),
|
startDate: formatIsoDate(startDate) ?? undefined,
|
||||||
|
endDate: formatIsoDate(endDate) ?? undefined,
|
||||||
|
}),
|
||||||
|
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -35,11 +39,13 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||||
<DateRangeRow
|
<DateRangeSelector
|
||||||
startDate={dateOrNull(shortUrlsListParams.startDate)}
|
defaultText="All short URLs"
|
||||||
endDate={dateOrNull(shortUrlsListParams.endDate)}
|
initialDateRange={{
|
||||||
onStartDateChange={setDate('startDate')}
|
startDate: dateOrNull(shortUrlsListParams.startDate),
|
||||||
onEndDateChange={setDate('endDate')}
|
endDate: dateOrNull(shortUrlsListParams.endDate),
|
||||||
|
}}
|
||||||
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -45,7 +45,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
|
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>;
|
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
.sorting-dropdown__menu {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sorting-dropdown__menu--link.sorting-dropdown__menu--link {
|
.sorting-dropdown__menu--link.sorting-dropdown__menu--link {
|
||||||
min-width: 11rem;
|
min-width: 11rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default function SortingDropdown<T extends string = string>(
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
right={right}
|
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 ]) => (
|
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
||||||
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import DateInput from './DateInput';
|
import DateInput from '../DateInput';
|
||||||
|
import { DateRange } from './types';
|
||||||
|
|
||||||
interface DateRangeRowProps {
|
interface DateRangeRowProps extends DateRange {
|
||||||
startDate?: moment.Moment | null;
|
|
||||||
endDate?: moment.Moment | null;
|
|
||||||
onStartDateChange: (date: moment.Moment | null) => void;
|
onStartDateChange: (date: moment.Moment | null) => void;
|
||||||
onEndDateChange: (date: moment.Moment | null) => void;
|
onEndDateChange: (date: moment.Moment | null) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -16,7 +15,7 @@ const DateRangeRow = (
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<DateInput
|
<DateInput
|
||||||
selected={startDate}
|
selected={startDate}
|
||||||
placeholderText="Since"
|
placeholderText="Since..."
|
||||||
isClearable
|
isClearable
|
||||||
maxDate={endDate ?? undefined}
|
maxDate={endDate ?? undefined}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
@ -27,7 +26,7 @@ const DateRangeRow = (
|
||||||
<DateInput
|
<DateInput
|
||||||
className="mt-2 mt-md-0"
|
className="mt-2 mt-md-0"
|
||||||
selected={endDate}
|
selected={endDate}
|
||||||
placeholderText="Until"
|
placeholderText="Until..."
|
||||||
isClearable
|
isClearable
|
||||||
minDate={startDate ?? undefined}
|
minDate={startDate ?? undefined}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
19
src/utils/dates/DateRangeSelector.scss
Normal file
19
src/utils/dates/DateRangeSelector.scss
Normal file
|
@ -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;
|
||||||
|
}
|
75
src/utils/dates/DateRangeSelector.tsx
Normal file
75
src/utils/dates/DateRangeSelector.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
80
src/utils/dates/types/index.ts
Normal file
80
src/utils/dates/types/index.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import moment from 'moment';
|
||||||
|
import { filter, isEmpty } from 'ramda';
|
||||||
|
import { formatInternational } from '../../helpers/date';
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
startDate?: moment.Moment | null;
|
||||||
|
endDate?: moment.Moment | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateInterval = 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days';
|
||||||
|
|
||||||
|
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
||||||
|
|| isEmpty(filter(Boolean, dateRange as any));
|
||||||
|
|
||||||
|
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval => typeof range === 'string';
|
||||||
|
|
||||||
|
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string> = {
|
||||||
|
today: 'Today',
|
||||||
|
yesterday: 'Yesterday',
|
||||||
|
last7Days: 'Last 7 days',
|
||||||
|
last30Days: 'Last 30 days',
|
||||||
|
last90Days: 'Last 90 days',
|
||||||
|
last180days: 'Last 180 days',
|
||||||
|
last365Days: 'Last 365 days',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateRangeToString = (range?: DateRange): string | undefined => {
|
||||||
|
if (!range || dateRangeIsEmpty(range)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.startDate && !range.endDate) {
|
||||||
|
return `Since ${formatInternational(range.startDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range.startDate && range.endDate) {
|
||||||
|
return `Until ${formatInternational(range.endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatInternational(range.startDate)} - ${formatInternational(range.endDate)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
|
||||||
|
if (!range) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rangeIsInterval(range)) {
|
||||||
|
return dateRangeToString(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
return INTERVAL_TO_STRING_MAP[range];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||||
|
if (!dateInterval) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (dateInterval) {
|
||||||
|
case 'today':
|
||||||
|
return { startDate: moment().startOf('day'), endDate: moment() };
|
||||||
|
case 'yesterday':
|
||||||
|
const yesterday = moment().subtract(1, 'day');
|
||||||
|
|
||||||
|
return { startDate: yesterday.startOf('day'), endDate: yesterday.endOf('day') };
|
||||||
|
case 'last7Days':
|
||||||
|
return { startDate: moment().subtract(7, 'days').startOf('day'), endDate: moment() };
|
||||||
|
case 'last30Days':
|
||||||
|
return { startDate: moment().subtract(30, 'days').startOf('day'), endDate: moment() };
|
||||||
|
case 'last90Days':
|
||||||
|
return { startDate: moment().subtract(90, 'days').startOf('day'), endDate: moment() };
|
||||||
|
case 'last180days':
|
||||||
|
return { startDate: moment().subtract(180, 'days').startOf('day'), endDate: moment() };
|
||||||
|
case 'last365Days':
|
||||||
|
return { startDate: moment().subtract(365, 'days').startOf('day'), endDate: moment() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
|
@ -12,3 +12,5 @@ const formatDateFromFormat = (date?: NullableDate, format?: string): OptionalStr
|
||||||
export const formatDate = (format = 'YYYY-MM-DD') => (date?: NullableDate) => formatDateFromFormat(date, format);
|
export const formatDate = (format = 'YYYY-MM-DD') => (date?: NullableDate) => formatDateFromFormat(date, format);
|
||||||
|
|
||||||
export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined);
|
export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined);
|
||||||
|
|
||||||
|
export const formatInternational = formatDate();
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons';
|
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import DateRangeRow from '../utils/DateRangeRow';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import { formatDate } from '../utils/helpers/date';
|
import { formatDate } from '../utils/helpers/date';
|
||||||
import { ShlinkVisitsParams } from '../utils/services/types';
|
import { ShlinkVisitsParams } from '../utils/services/types';
|
||||||
|
@ -225,12 +225,13 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<div className="row flex-md-row-reverse">
|
<div className="row flex-md-row-reverse">
|
||||||
<div className="col-lg-7 col-xl-6">
|
<div className="col-lg-7 col-xl-6">
|
||||||
<DateRangeRow
|
<DateRangeSelector
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
startDate={startDate}
|
defaultText="All visits"
|
||||||
endDate={endDate}
|
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
|
||||||
onStartDateChange={setStartDate}
|
setStartDate(newStartDate ?? null);
|
||||||
onEndDateChange={setEndDate}
|
setEndDate(newEndDate ?? null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{visits.length > 0 && (
|
{visits.length > 0 && (
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import createErrorHandler from '../../src/common/ErrorHandler';
|
import createErrorHandler from '../../src/common/ErrorHandler';
|
||||||
|
import { SimpleCard } from '../../src/utils/SimpleCard';
|
||||||
|
|
||||||
describe('<ErrorHandler />', () => {
|
describe('<ErrorHandler />', () => {
|
||||||
const window = Mock.of<Window>({
|
const window = Mock.of<Window>({
|
||||||
|
@ -28,10 +29,10 @@ describe('<ErrorHandler />', () => {
|
||||||
it('renders error page when error has occurred', () => {
|
it('renders error page when error has occurred', () => {
|
||||||
wrapper.setState({ hasError: true });
|
wrapper.setState({ hasError: true });
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Oops! This is awkward :S');
|
expect(wrapper.find(SimpleCard).contains('Oops! This is awkward :S')).toEqual(true);
|
||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.find(SimpleCard).contains(
|
||||||
'It seems that something went wrong. Try refreshing the page or just click this button.',
|
'It seems that something went wrong. Try refreshing the page or just click this button.',
|
||||||
);
|
)).toEqual(true);
|
||||||
expect(wrapper.find(Button)).toHaveLength(1);
|
expect(wrapper.find(Button)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,34 +1,34 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import NotFound from '../../src/common/NotFound';
|
import NotFound from '../../src/common/NotFound';
|
||||||
|
import { SimpleCard } from '../../src/utils/SimpleCard';
|
||||||
|
|
||||||
describe('<NotFound />', () => {
|
describe('<NotFound />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (props = {}) => {
|
const createWrapper = (props = {}) => {
|
||||||
wrapper = shallow(<NotFound {...props} />);
|
wrapper = shallow(<NotFound {...props} />).find(SimpleCard);
|
||||||
const content = wrapper.text();
|
|
||||||
|
|
||||||
return { wrapper, content };
|
return wrapper;
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => wrapper?.unmount());
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
it('shows expected error title', () => {
|
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', () => {
|
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.',
|
'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', () => {
|
it('shows a link to the home', () => {
|
||||||
const { wrapper } = createWrapper();
|
const wrapper = createWrapper();
|
||||||
const link = wrapper.find(Link);
|
const link = wrapper.find(Link);
|
||||||
|
|
||||||
expect(link.prop('to')).toEqual('/');
|
expect(link.prop('to')).toEqual('/');
|
||||||
|
@ -37,7 +37,7 @@ describe('<NotFound />', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a link with provided props', () => {
|
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);
|
const link = wrapper.find(Link);
|
||||||
|
|
||||||
expect(link.prop('to')).toEqual('/foo/bar');
|
expect(link.prop('to')).toEqual('/foo/bar');
|
||||||
|
|
27
test/common/ShlinkVersionsContainer.test.tsx
Normal file
27
test/common/ShlinkVersionsContainer.test.tsx
Normal file
|
@ -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}`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,7 +3,7 @@ import { Mock } from 'ts-mockery';
|
||||||
import searchBarCreator from '../../src/short-urls/SearchBar';
|
import searchBarCreator from '../../src/short-urls/SearchBar';
|
||||||
import SearchField from '../../src/utils/SearchField';
|
import SearchField from '../../src/utils/SearchField';
|
||||||
import Tag from '../../src/tags/helpers/Tag';
|
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';
|
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
||||||
|
|
||||||
describe('<SearchBar />', () => {
|
describe('<SearchBar />', () => {
|
||||||
|
@ -20,10 +20,10 @@ describe('<SearchBar />', () => {
|
||||||
expect(wrapper.find(SearchField)).toHaveLength(1);
|
expect(wrapper.find(SearchField)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a DateRangeRow', () => {
|
it('renders a DateRangeSelector', () => {
|
||||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
|
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', () => {
|
it('renders no tags when the list of tags is empty', () => {
|
||||||
|
@ -60,14 +60,14 @@ describe('<SearchBar />', () => {
|
||||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
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(
|
wrapper = shallow(
|
||||||
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />,
|
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />,
|
||||||
);
|
);
|
||||||
const dateRange = wrapper.find(DateRangeRow);
|
const dateRange = wrapper.find(DateRangeSelector);
|
||||||
|
|
||||||
expect(listShortUrlsMock).not.toHaveBeenCalled();
|
expect(listShortUrlsMock).not.toHaveBeenCalled();
|
||||||
dateRange.simulate(event);
|
dateRange.simulate('datesChange', {});
|
||||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import DateRangeRow from '../../src/utils/DateRangeRow';
|
import DateRangeRow from '../../../src/utils/dates/DateRangeRow';
|
||||||
import DateInput from '../../src/utils/DateInput';
|
import DateInput from '../../../src/utils/DateInput';
|
||||||
|
|
||||||
describe('<DateRangeRow />', () => {
|
describe('<DateRangeRow />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
58
test/utils/dates/DateRangeSelector.test.tsx
Normal file
58
test/utils/dates/DateRangeSelector.test.tsx
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
88
test/utils/dates/types/index.test.ts
Normal file
88
test/utils/dates/types/index.test.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import moment from 'moment';
|
||||||
|
import {
|
||||||
|
DateInterval,
|
||||||
|
dateRangeIsEmpty,
|
||||||
|
intervalToDateRange,
|
||||||
|
rangeIsInterval,
|
||||||
|
rangeOrIntervalToString,
|
||||||
|
} from '../../../../src/utils/dates/types';
|
||||||
|
|
||||||
|
describe('date-types', () => {
|
||||||
|
describe('dateRangeIsEmpty', () => {
|
||||||
|
test.each([
|
||||||
|
[ undefined, true ],
|
||||||
|
[{}, true ],
|
||||||
|
[{ startDate: null }, true ],
|
||||||
|
[{ endDate: null }, true ],
|
||||||
|
[{ startDate: null, endDate: null }, true ],
|
||||||
|
[{ startDate: undefined }, true ],
|
||||||
|
[{ endDate: undefined }, true ],
|
||||||
|
[{ startDate: undefined, endDate: undefined }, true ],
|
||||||
|
[{ startDate: undefined, endDate: null }, true ],
|
||||||
|
[{ startDate: null, endDate: undefined }, true ],
|
||||||
|
[{ startDate: moment() }, false ],
|
||||||
|
[{ endDate: moment() }, false ],
|
||||||
|
[{ startDate: moment(), endDate: moment() }, false ],
|
||||||
|
])('proper result is returned', (dateRange, expectedResult) => {
|
||||||
|
expect(dateRangeIsEmpty(dateRange)).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rangeIsInterval', () => {
|
||||||
|
test.each([
|
||||||
|
[ undefined, false ],
|
||||||
|
[{}, false ],
|
||||||
|
[ 'today' as DateInterval, true ],
|
||||||
|
[ 'yesterday' as DateInterval, true ],
|
||||||
|
])('proper result is returned', (range, expectedResult) => {
|
||||||
|
expect(rangeIsInterval(range)).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rangeOrIntervalToString', () => {
|
||||||
|
test.each([
|
||||||
|
[ undefined, undefined ],
|
||||||
|
[ 'today' as DateInterval, 'Today' ],
|
||||||
|
[ 'yesterday' as DateInterval, 'Yesterday' ],
|
||||||
|
[ 'last7Days' as DateInterval, 'Last 7 days' ],
|
||||||
|
[ 'last30Days' as DateInterval, 'Last 30 days' ],
|
||||||
|
[ 'last90Days' as DateInterval, 'Last 90 days' ],
|
||||||
|
[ 'last180days' as DateInterval, 'Last 180 days' ],
|
||||||
|
[ 'last365Days' as DateInterval, 'Last 365 days' ],
|
||||||
|
[{}, undefined ],
|
||||||
|
[{ startDate: null }, undefined ],
|
||||||
|
[{ endDate: null }, undefined ],
|
||||||
|
[{ startDate: null, endDate: null }, undefined ],
|
||||||
|
[{ startDate: undefined }, undefined ],
|
||||||
|
[{ endDate: undefined }, undefined ],
|
||||||
|
[{ startDate: undefined, endDate: undefined }, undefined ],
|
||||||
|
[{ startDate: undefined, endDate: null }, undefined ],
|
||||||
|
[{ startDate: null, endDate: undefined }, undefined ],
|
||||||
|
[{ startDate: moment('2020-01-01') }, 'Since 2020-01-01' ],
|
||||||
|
[{ endDate: moment('2020-01-01') }, 'Until 2020-01-01' ],
|
||||||
|
[{ startDate: moment('2020-01-01'), endDate: moment('2021-02-02') }, '2020-01-01 - 2021-02-02' ],
|
||||||
|
])('proper result is returned', (range, expectedValue) => {
|
||||||
|
expect(rangeOrIntervalToString(range)).toEqual(expectedValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('intervalToDateRange', () => {
|
||||||
|
const now = () => moment();
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[ undefined, undefined, undefined ],
|
||||||
|
[ 'today' as DateInterval, now(), now() ],
|
||||||
|
[ 'yesterday' as DateInterval, now().subtract(1, 'day'), now().subtract(1, 'day') ],
|
||||||
|
[ 'last7Days' as DateInterval, now().subtract(7, 'day'), now() ],
|
||||||
|
[ 'last30Days' as DateInterval, now().subtract(30, 'day'), now() ],
|
||||||
|
[ 'last90Days' as DateInterval, now().subtract(90, 'day'), now() ],
|
||||||
|
[ 'last180days' as DateInterval, now().subtract(180, 'day'), now() ],
|
||||||
|
[ 'last365Days' as DateInterval, now().subtract(365, 'day'), now() ],
|
||||||
|
])('proper result is returned', (interval, expectedStartDate, expectedEndDate) => {
|
||||||
|
const { startDate, endDate } = intervalToDateRange(interval);
|
||||||
|
|
||||||
|
expect(expectedStartDate?.format('YYYY-MM-DD')).toEqual(startDate?.format('YYYY-MM-DD'));
|
||||||
|
expect(expectedEndDate?.format('YYYY-MM-DD')).toEqual(endDate?.format('YYYY-MM-DD'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -5,7 +5,6 @@ import VisitStats from '../../src/visits/VisitsStats';
|
||||||
import Message from '../../src/utils/Message';
|
import Message from '../../src/utils/Message';
|
||||||
import GraphCard from '../../src/visits/helpers/GraphCard';
|
import GraphCard from '../../src/visits/helpers/GraphCard';
|
||||||
import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph';
|
import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph';
|
||||||
import DateRangeRow from '../../src/utils/DateRangeRow';
|
|
||||||
import { Visit, VisitsInfo } from '../../src/visits/types';
|
import { Visit, VisitsInfo } from '../../src/visits/types';
|
||||||
import LineChartCard from '../../src/visits/helpers/LineChartCard';
|
import LineChartCard from '../../src/visits/helpers/LineChartCard';
|
||||||
import VisitsTable from '../../src/visits/VisitsTable';
|
import VisitsTable from '../../src/visits/VisitsTable';
|
||||||
|
@ -87,18 +86,6 @@ describe('<VisitStats />', () => {
|
||||||
expect(table).toHaveLength(expectedTables);
|
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', () => {
|
it('holds the map button content generator on cities graph extraHeaderContent', () => {
|
||||||
const wrapper = createComponent({ loading: false, error: false, visits });
|
const wrapper = createComponent({ loading: false, error: false, visits });
|
||||||
const locationNav = wrapper.find(NavLink).at(2);
|
const locationNav = wrapper.find(NavLink).at(2);
|
||||||
|
|
Loading…
Reference in a new issue