Merge pull request #350 from acelaya-forks/feature/ui-improvements

Feature/UI improvements
This commit is contained in:
Alejandro Celaya 2020-12-15 10:05:16 +01:00 committed by GitHub
commit f1f3c3f98b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 489 additions and 106 deletions

View file

@ -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

View file

@ -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.

View file

@ -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",

View file

@ -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>

View file

@ -1,9 +0,0 @@
@import '../utils/mixins/vertical-align.scss';
.error-handler {
@include vertical-align();
padding: 20px;
text-align: center;
width: 100%;
}

View file

@ -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>
); );
} }

View file

@ -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} />

View file

@ -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;
} }
}

View file

@ -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&apos;s back button to navigate to the page you have previously come from, or just press this Use your browser&apos;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>
); );

View file

@ -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} /> - </>
} }

View 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;

View file

@ -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');
}; };

View file

@ -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);

View file

@ -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;
} }

View file

@ -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>

View file

@ -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>;
} }

View file

@ -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;
} }

View file

@ -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)}>

View file

@ -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}

View 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;
}

View 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>
);
};

View 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 {};
};

View file

@ -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();

View file

@ -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 && (

View file

@ -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);
}); });
}); });

View file

@ -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');

View 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}`);
});
});

View file

@ -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);
}); });
}); });

View file

@ -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;

View 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);
});
});

View 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'));
});
});
});

View file

@ -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);