Merge pull request #357 from acelaya-forks/feature/routable-visits-sections

Feature/routable visits sections
This commit is contained in:
Alejandro Celaya 2020-12-20 20:01:04 +01:00 committed by GitHub
commit 852e791c80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 106 additions and 76 deletions

View file

@ -65,8 +65,8 @@ const MenuLayout = (
<Route exact path="/server/:serverId/overview" component={Overview} />
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}

View file

@ -2,12 +2,12 @@ import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawe
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, keys, values } from 'ramda';
import { FC, useEffect, useState } from 'react';
import qs from 'qs';
import { RouteComponentProps } from 'react-router';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir, OrderDir } from '../utils/utils';
import { SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { parseQuery } from '../utils/helpers/query';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
import { ShortUrlsTableProps } from './ShortUrlsTable';
@ -65,8 +65,8 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
};
useEffect(() => {
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags;
const { tag } = parseQuery<{ tag?: string }>(location.search);
const tags = tag ? [ tag ] : shortUrlsListParams.tags;
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });

View file

@ -0,0 +1,5 @@
import qs from 'qs';
export const parseQuery = <T>(search: string) => qs.parse(search, { ignoreQueryPrefix: true }) as unknown as T;
export const stringifyQuery = (query: any): string => qs.stringify(query, { arrayFormat: 'brackets' });

View file

@ -1,8 +1,8 @@
import { useEffect } from 'react';
import qs from 'qs';
import { RouteComponentProps } from 'react-router';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../utils/services/types';
import { parseQuery } from '../utils/helpers/query';
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
@ -18,7 +18,7 @@ export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: st
const ShortUrlVisits = boundToMercureHub(({
history: { goBack },
match,
match: { params, url },
location: { search },
shortUrlVisits,
shortUrlDetail,
@ -26,10 +26,8 @@ const ShortUrlVisits = boundToMercureHub(({
getShortUrlDetail,
cancelGetShortUrlVisits,
}: ShortUrlVisitsProps) => {
const { params } = match;
const { shortCode } = params;
const { domain } = qs.parse(search, { ignoreQueryPrefix: true }) as { domain?: string };
const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: Partial<ShlinkVisitsParams>) => getShortUrlVisits(shortCode, { ...params, domain });
useEffect(() => {
@ -37,7 +35,13 @@ const ShortUrlVisits = boundToMercureHub(({
}, []);
return (
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits}>
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetShortUrlVisits}
visitsInfo={shortUrlVisits}
baseUrl={url}
domain={domain}
>
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats>
);

View file

@ -14,17 +14,16 @@ export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> {
const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
history: { goBack },
match,
match: { params, url },
getTagVisits,
tagVisits,
cancelGetTagVisits,
}: TagVisitsProps) => {
const { params } = match;
const { tag } = params;
const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params);
return (
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits}>
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits} baseUrl={url}>
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats>
);

View file

@ -4,6 +4,8 @@ import { Button, Card, Nav, NavLink, Progress } from 'reactstrap';
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 { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom';
import { Location } from 'history';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import Message from '../utils/Message';
import { formatIsoDate } from '../utils/helpers/date';
@ -22,16 +24,18 @@ export interface VisitsStatsProps {
getVisits: (params: Partial<ShlinkVisitsParams>) => void;
visitsInfo: VisitsInfo;
cancelGetVisits: () => void;
baseUrl: string;
domain?: string;
}
type HighlightableProps = 'referer' | 'country' | 'city';
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
const sections: Record<Section, { title: string; icon: IconDefinition }> = {
byTime: { title: 'By time', icon: faCalendarAlt },
byContext: { title: 'By context', icon: faChartPie },
byLocation: { title: 'By location', icon: faMapMarkedAlt },
list: { title: 'List', icon: faList },
const sections: Record<Section, { title: string; subPath: string; icon: IconDefinition }> = {
byTime: { title: 'By time', subPath: '', icon: faCalendarAlt },
byContext: { title: 'By context', subPath: '/by-context', icon: faChartPie },
byLocation: { title: 'By location', subPath: '/by-location', icon: faMapMarkedAlt },
list: { title: 'List', subPath: '/list', icon: faList },
};
const highlightedVisitsToStats = (
@ -49,13 +53,16 @@ const highlightedVisitsToStats = (
let selectedBar: string | undefined;
const initialInterval: DateInterval = 'last30Days';
const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, cancelGetVisits }) => {
const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain }) => {
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
const [ activeSection, setActiveSection ] = useState<Section>('byTime');
const onSectionChange = (section: Section) => () => setActiveSection(section);
const buildSectionUrl = (subPath?: string) => {
const query = domain ? `?domain=${domain}` : '';
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
};
const { visits, loading, loadingLarge, error, progress } = visitsInfo;
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
@ -120,12 +127,14 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
<Card className="visits-stats__nav p-0 mt-4 overflow-hidden" body>
<Nav pills justified>
{Object.entries(sections).map(
([ section, { title, icon }]) => (
([ section, { title, icon, subPath }]) => (
<NavLink
key={section}
active={activeSection === section}
tag={RouterNavLink}
className="visits-stats__nav-link"
onClick={onSectionChange(section as Section)}
to={buildSectionUrl(subPath)}
isActive={(_: null, { pathname }: Location) => pathname.endsWith(`/visits${subPath}`)}
replace
>
<FontAwesomeIcon icon={icon} />
<span className="ml-2 d-none d-sm-inline">{title}</span>
@ -135,19 +144,20 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
</Nav>
</Card>
<div className="row">
{activeSection === 'byTime' && (
<div className="col-12 mt-4">
<LineChartCard
title="Visits during time"
visits={normalizedVisits}
highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel}
setSelectedVisits={setSelectedVisits}
/>
</div>
)}
{activeSection === 'byContext' && (
<>
<Switch>
<Route exact path={baseUrl}>
<div className="col-12 mt-4">
<LineChartCard
title="Visits during time"
visits={normalizedVisits}
highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel}
setSelectedVisits={setSelectedVisits}
/>
</div>
</Route>
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
<div className="col-xl-4 col-lg-6 mt-4">
<GraphCard title="Operating systems" stats={os} />
</div>
@ -168,10 +178,9 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
onClick={highlightVisitsForProp('referer')}
/>
</div>
</>
)}
{activeSection === 'byLocation' && (
<>
</Route>
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
<div className="col-lg-6 mt-4">
<SortableBarGraph
title="Countries"
@ -202,18 +211,20 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
onClick={highlightVisitsForProp('city')}
/>
</div>
</>
)}
{activeSection === 'list' && (
<div className="col-12">
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isSticky
/>
</div>
)}
</Route>
<Route exact path={`${baseUrl}${sections.list.subPath}`}>
<div className="col-12">
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
/>
</div>
</Route>
<Redirect to={baseUrl} />
</Switch>
</div>
</>
);
@ -241,7 +252,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
className="btn-md-block"
onClick={() => setSelectedVisits([])}
>
Reset selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
</Button>
</div>
)}

View file

@ -19,7 +19,6 @@ interface VisitsTableProps {
visits: NormalizedVisit[];
selectedVisits?: NormalizedVisit[];
setSelectedVisits: (visits: NormalizedVisit[]) => void;
isSticky?: boolean;
matchMedia?: (query: string) => MediaQueryList;
}
@ -56,12 +55,9 @@ const VisitsTable = ({
visits,
selectedVisits = [],
setSelectedVisits,
isSticky = false,
matchMedia = window.matchMedia,
}: VisitsTableProps) => {
const headerCellsClass = classNames('visits-table__header-cell', {
'visits-table__sticky': isSticky,
});
const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
@ -105,9 +101,7 @@ const VisitsTable = ({
<thead className="visits-table__header">
<tr>
<th
className={classNames('visits-table__header-cell text-center', {
'visits-table__sticky': isSticky,
})}
className="visits-table__header-cell visits-table__sticky text-center"
onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
)}
@ -183,7 +177,7 @@ const VisitsTable = ({
{resultSet.total > PAGE_SIZE && (
<tfoot>
<tr>
<td colSpan={7} className={classNames('visits-table__footer-cell', { 'visits-table__sticky': isSticky })}>
<td colSpan={7} className="visits-table__footer-cell visits-table__sticky">
<div className="row">
<div className="col-md-6">
<SimplePaginator

View file

@ -0,0 +1,25 @@
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
describe('query', () => {
describe('parseQuery', () => {
it.each([
[ '', {}],
[ 'foo=bar', { foo: 'bar' }],
[ '?foo=bar', { foo: 'bar' }],
[ '?foo=bar&baz=123', { foo: 'bar', baz: '123' }],
])('parses query string as expected', (queryString, expectedResult) => {
expect(parseQuery(queryString)).toEqual(expectedResult);
});
});
describe('stringifyQuery', () => {
it.each([
[{}, '' ],
[{ foo: 'bar' }, 'foo=bar' ],
[{ foo: 'bar', baz: '123' }, 'foo=bar&baz=123' ],
[{ bar: 'foo', list: [ 'one', 'two' ] }, encodeURI('bar=foo&list[]=one&list[]=two') ],
])('stringifies query as expected', (queryObj, expectedResult) => {
expect(stringifyQuery(queryObj)).toEqual(expectedResult);
});
});
});

View file

@ -21,6 +21,7 @@ describe('<VisitStats />', () => {
getVisits={getVisitsMock}
visitsInfo={Mock.of<VisitsInfo>(visitsInfo)}
cancelGetVisits={() => {}}
baseUrl={''}
/>,
);
@ -66,24 +67,15 @@ describe('<VisitStats />', () => {
expect(message.html()).toContain('There are no visits matching current filter :(');
});
it.each([
[ 0, 1, 0 ],
[ 1, 3, 0 ],
[ 2, 2, 0 ],
[ 3, 0, 1 ],
])('renders expected amount of graphics based on active section', (navIndex, expectedGraphics, expectedTables) => {
it('renders expected amount of graphics', () => {
const wrapper = createComponent({ loading: false, error: false, visits });
const nav = wrapper.find(NavLink).at(navIndex);
nav.simulate('click');
const graphs = wrapper.find(GraphCard);
const sortableBarGraphs = wrapper.find(SortableBarGraph);
const lineChart = wrapper.find(LineChartCard);
const table = wrapper.find(VisitsTable);
expect(graphs.length + sortableBarGraphs.length + lineChart.length).toEqual(expectedGraphics);
expect(table).toHaveLength(expectedTables);
expect(graphs.length + sortableBarGraphs.length + lineChart.length).toEqual(6);
expect(table).toHaveLength(1);
});
it('holds the map button content generator on cities graph extraHeaderContent', () => {