mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-08 17:27:32 +03:00
Added routes to subsections in visits
This commit is contained in:
parent
214b952e84
commit
83221c1066
8 changed files with 80 additions and 73 deletions
|
@ -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>}
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
5
src/utils/helpers/query.ts
Normal file
5
src/utils/helpers/query.ts
Normal 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' });
|
|
@ -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,9 +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 });
|
||||
|
||||
|
@ -37,7 +36,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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 }> = {
|
||||
const sections: Record<Section, { title: string; subPath?: 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 },
|
||||
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,15 @@ 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) =>
|
||||
(!subPath && pathname.endsWith('/visits')) || (subPath && pathname.endsWith(subPath))
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
<span className="ml-2 d-none d-sm-inline">{title}</span>
|
||||
|
@ -135,19 +145,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}/by-context`}>
|
||||
<div className="col-xl-4 col-lg-6 mt-4">
|
||||
<GraphCard title="Operating systems" stats={os} />
|
||||
</div>
|
||||
|
@ -168,10 +179,9 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
|
|||
onClick={highlightVisitsForProp('referer')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{activeSection === 'byLocation' && (
|
||||
<>
|
||||
</Route>
|
||||
|
||||
<Route exact path={`${baseUrl}/by-location`}>
|
||||
<div className="col-lg-6 mt-4">
|
||||
<SortableBarGraph
|
||||
title="Countries"
|
||||
|
@ -202,18 +212,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}/list`}>
|
||||
<div className="col-12">
|
||||
<VisitsTable
|
||||
visits={normalizedVisits}
|
||||
selectedVisits={highlightedVisits}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
/>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Redirect to={baseUrl} />
|
||||
</Switch>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Reference in a new issue