Repository: shlinkio/shlink-web-client
Pull request #357 (2020-12-20 18:53:11)

Feature/routable visits sections


Patch:
diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx
index e4a32144..11326487 100644
--- a/src/common/MenuLayout.tsx
+++ b/src/common/MenuLayout.tsx
@@ -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>}
diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx
index 485204dd..86397f73 100644
--- a/src/short-urls/ShortUrlsList.tsx
+++ b/src/short-urls/ShortUrlsList.tsx
@@ -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 });
 
diff --git a/src/utils/helpers/query.ts b/src/utils/helpers/query.ts
new file mode 100644
index 00000000..8c59f6eb
--- /dev/null
+++ b/src/utils/helpers/query.ts
@@ -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' });
diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx
index 37024b66..d7ceb2ef 100644
--- a/src/visits/ShortUrlVisits.tsx
+++ b/src/visits/ShortUrlVisits.tsx
@@ -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>
   );
diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx
index 5f2c0244..b0701737 100644
--- a/src/visits/TagVisits.tsx
+++ b/src/visits/TagVisits.tsx
@@ -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>
   );
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index 1e9dce11..f1b9936a 100644
--- a/src/visits/VisitsStats.tsx
+++ b/src/visits/VisitsStats.tsx
@@ -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>
           )}
diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx
index eab3d774..54e16602 100644
--- a/src/visits/VisitsTable.tsx
+++ b/src/visits/VisitsTable.tsx
@@ -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


Test patch:
diff --git a/test/utils/helpers/query.test.ts b/test/utils/helpers/query.test.ts
new file mode 100644
index 00000000..90969bce
--- /dev/null
+++ b/test/utils/helpers/query.test.ts
@@ -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);
+    });
+  });
+});
diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx
index 9c274a45..f5f00952 100644
--- a/test/visits/VisitsStats.test.tsx
+++ b/test/visits/VisitsStats.test.tsx
@@ -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', () => {