Improved visits section so that charts are grouped in sub tabs

This commit is contained in:
Alejandro Celaya 2020-12-12 20:45:23 +01:00
parent a013d40bf1
commit c74355e363
9 changed files with 196 additions and 139 deletions

View file

@ -92,3 +92,17 @@ body,
.progress-bar {
background-color: $mainColor;
}
.btn-xs-block {
@media (max-width: $xsMax) {
width: 100%;
display: block;
}
}
.btn-md-block {
@media (max-width: $mdMax) {
width: 100%;
display: block;
}
}

View file

@ -1,12 +1,5 @@
@import '../utils/base';
.create-short-url__save-btn {
@media (max-width: $xsMax) {
width: 100%;
display: block;
}
}
.create-short-url .form-group:last-child,
.create-short-url p:last-child {
margin-bottom: 0;

View file

@ -197,7 +197,7 @@ const CreateShortUrl = (
outline
color="primary"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
className="create-short-url__save-btn"
className="btn-xs-block"
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</Button>

View file

@ -0,0 +1,20 @@
@import '../utils/base';
.visits-stats__nav-link {
border-radius: 0 !important;
padding-bottom: calc(.5rem - 3px) !important;
border-bottom: 3px solid transparent;
color: #5d6778;
font-weight: 700;
cursor: pointer;
}
.visits-stats__nav-link:hover {
color: $mainColor !important;
}
.visits-stats__nav-link.active {
border-color: $mainColor;
background-color: white !important;
color: $mainColor !important;
}

View file

@ -1,14 +1,13 @@
import { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC } from 'react';
import { Button, Card, Collapse, Progress } from 'reactstrap';
import classNames from 'classnames';
import { Button, Card, Nav, NavLink, Progress } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown as chevronDown } 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 moment from 'moment';
import DateRangeRow from '../utils/DateRangeRow';
import Message from '../utils/Message';
import { formatDate } from '../utils/helpers/date';
import { useToggle } from '../utils/helpers/hooks';
import { ShlinkVisitsParams } from '../utils/services/types';
import SortableBarGraph from './helpers/SortableBarGraph';
import GraphCard from './helpers/GraphCard';
@ -17,15 +16,23 @@ import VisitsTable from './VisitsTable';
import { NormalizedVisit, Stats, VisitsInfo } from './types';
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import './VisitsStats.scss';
export interface VisitsStatsProps {
matchMedia?: (query: string) => MediaQueryList;
getVisits: (params: Partial<ShlinkVisitsParams>) => void;
visitsInfo: VisitsInfo;
cancelGetVisits: () => void;
}
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 highlightedVisitsToStats = (
highlightedVisits: NormalizedVisit[],
@ -42,19 +49,15 @@ const highlightedVisitsToStats = (
const format = formatDate();
let selectedBar: string | undefined;
const VisitsStats: FC<VisitsStatsProps> = (
{ children, visitsInfo, getVisits, cancelGetVisits, matchMedia = window.matchMedia },
) => {
const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, cancelGetVisits }) => {
const [ startDate, setStartDate ] = useState<moment.Moment | null>(null);
const [ endDate, setEndDate ] = useState<moment.Moment | null>(null);
const [ showTable, toggleTable ] = useToggle();
const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle();
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
const [ isMobileDevice, setIsMobileDevice ] = useState(false);
const [ activeSection, setActiveSection ] = useState<Section>('byTime');
const onSectionChange = (section: Section) => () => setActiveSection(section);
const { visits, loading, loadingLarge, error, progress } = visitsInfo;
const showTableControls = !loading && visits.length > 0;
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
() => processStatsFromVisits(normalizedVisits),
@ -62,7 +65,6 @@ const VisitsStats: FC<VisitsStatsProps> = (
);
const mapLocations = values(citiesForMap);
const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
selectedBar = undefined;
setHighlightedVisits(selectedVisits);
@ -81,15 +83,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
}
};
useEffect(() => {
determineIsMobileDevice();
window.addEventListener('resize', determineIsMobileDevice);
return () => {
cancelGetVisits();
window.removeEventListener('resize', determineIsMobileDevice);
};
}, []);
useEffect(() => () => cancelGetVisits(), []);
useEffect(() => {
getVisits({ startDate: format(startDate) ?? undefined, endDate: format(endDate) ?? undefined });
}, [ startDate, endDate ]);
@ -121,67 +115,106 @@ const VisitsStats: FC<VisitsStatsProps> = (
}
return (
<div className="row">
<div className="col-12 mt-4">
<LineChartCard
title="Visits during time"
visits={normalizedVisits}
highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel}
setSelectedVisits={setSelectedVisits}
/>
<>
<Card className="p-0 mt-4 overflow-hidden" body>
<Nav className="visits-stats__nav" pills justified>
{Object.entries(sections).map(
([ section, { title, icon }]) => (
<NavLink
key={section}
active={activeSection === section}
className="visits-stats__nav-link"
onClick={onSectionChange(section as Section)}
>
<FontAwesomeIcon icon={icon} />
<span className="ml-2 d-none d-sm-inline">{title}</span>
</NavLink>
),
)}
</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' && (
<>
<div className="col-xl-4 col-lg-6 mt-4">
<GraphCard title="Operating systems" stats={os} />
</div>
<div className="col-xl-4 col-lg-6 mt-4">
<GraphCard title="Browsers" stats={browsers} />
</div>
<div className="col-xl-4 mt-4">
<SortableBarGraph
title="Referrers"
stats={referrers}
withPagination={false}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('referer')}
/>
</div>
</>
)}
{activeSection === 'byLocation' && (
<>
<div className="col-lg-6 mt-4">
<SortableBarGraph
title="Countries"
stats={countries}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Country name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('country')}
/>
</div>
<div className="col-lg-6 mt-4">
<SortableBarGraph
title="Cities"
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
highlightedLabel={highlightedLabel}
extraHeaderContent={(activeCities: string[]) =>
mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
}
sortingItems={{
name: 'City name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('city')}
/>
</div>
</>
)}
{activeSection === 'list' && (
<div className="col-12">
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isSticky
/>
</div>
)}
</div>
<div className="col-xl-4 col-lg-6 mt-4">
<GraphCard title="Operating systems" stats={os} />
</div>
<div className="col-xl-4 col-lg-6 mt-4">
<GraphCard title="Browsers" stats={browsers} />
</div>
<div className="col-xl-4 mt-4">
<SortableBarGraph
title="Referrers"
stats={referrers}
withPagination={false}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('referer')}
/>
</div>
<div className="col-lg-6 mt-4">
<SortableBarGraph
title="Countries"
stats={countries}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Country name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('country')}
/>
</div>
<div className="col-lg-6 mt-4">
<SortableBarGraph
title="Cities"
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
highlightedLabel={highlightedLabel}
extraHeaderContent={(activeCities: string[]) =>
mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
}
sortingItems={{
name: 'City name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('city')}
/>
</div>
</div>
</>
);
};
@ -200,47 +233,21 @@ const VisitsStats: FC<VisitsStatsProps> = (
onEndDateChange={setEndDate}
/>
</div>
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
{showTableControls && (
<span className={classNames({ row: isMobileDevice })}>
<span className={classNames({ 'col-6': isMobileDevice })}>
<Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
{showTable ? 'Hide' : 'Show'} table
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
</Button>
</span>
<span className={classNames({ 'col-6': isMobileDevice, 'ml-2': !isMobileDevice })}>
<Button
outline
disabled={highlightedVisits.length === 0}
block={isMobileDevice}
onClick={() => setSelectedVisits([])}
>
Reset selection
</Button>
</span>
</span>
)}
</div>
{visits.length > 0 && (
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
<Button
outline
disabled={highlightedVisits.length === 0}
className="btn-md-block"
onClick={() => setSelectedVisits([])}
>
Reset selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
</Button>
</div>
)}
</div>
</section>
{showTableControls && (
<Collapse
isOpen={showTable}
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
onEntered={setSticky}
onExiting={unsetSticky}
>
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isSticky={tableIsSticky}
/>
</Collapse>
)}
<section>
{renderVisitsContent()}
</section>

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import Moment from 'react-moment';
import classNames from 'classnames';
import { min, splitEvery } from 'ramda';
@ -68,6 +68,7 @@ const VisitsTable = ({
const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined);
const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined });
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
const isFirstLoad = useRef(true);
const [ page, setPage ] = useState(1);
const end = page * PAGE_SIZE;
@ -91,7 +92,12 @@ const VisitsTable = ({
}, []);
useEffect(() => {
setPage(1);
setSelectedVisits([]);
if (isFirstLoad.current) {
isFirstLoad.current = false;
} else {
setSelectedVisits([]);
}
}, [ searchTerm ]);
return (

View file

@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useState } from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { keys, values } from 'ramda';
import classNames from 'classnames';
@ -118,7 +118,7 @@ const DefaultChart = (
}, { ...stats }),
);
const highlightedData = statsAreDefined(highlightedStats) ? fillTheGaps(highlightedStats, labels) : undefined;
const chartRef = useRef<HorizontalBar | Doughnut>();
const [ chartRef, setChartRef ] = useState<HorizontalBar | Doughnut | undefined>()
const options: ChartOptions = {
legend: { display: false },
@ -156,7 +156,7 @@ const DefaultChart = (
<div className="row">
<div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}>
<Component
ref={chartRef as any}
ref={(element) => setChartRef(element ?? undefined)}
key={height}
data={graphData}
options={options}
@ -166,7 +166,7 @@ const DefaultChart = (
</div>
{!isBarChart && (
<div className="col-sm-12 col-md-5">
{chartRef.current?.chartInstance.generateLegend()}
{chartRef?.chartInstance.generateLegend()}
</div>
)}
</div>

View file

@ -4,6 +4,6 @@
height: 300px !important;
@media (min-width: $mdMin) {
height: 350px !important;
height: 400px !important;
}
}

View file

@ -1,5 +1,5 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Card, Progress } from 'reactstrap';
import { Card, NavLink, Progress } from 'reactstrap';
import { Mock } from 'ts-mockery';
import VisitStats from '../../src/visits/VisitsStats';
import Message from '../../src/utils/Message';
@ -7,6 +7,8 @@ import GraphCard from '../../src/visits/helpers/GraphCard';
import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph';
import DateRangeRow from '../../src/utils/DateRangeRow';
import { Visit, VisitsInfo } from '../../src/visits/types';
import LineChartCard from '../../src/visits/helpers/LineChartCard';
import VisitsTable from '../../src/visits/VisitsTable';
describe('<VisitStats />', () => {
const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
@ -20,7 +22,6 @@ describe('<VisitStats />', () => {
getVisits={getVisitsMock}
visitsInfo={Mock.of<VisitsInfo>(visitsInfo)}
cancelGetVisits={() => {}}
matchMedia={() => Mock.of<MediaQueryList>({ matches: false })}
/>,
);
@ -66,12 +67,24 @@ describe('<VisitStats />', () => {
expect(message.html()).toContain('There are no visits matching current filter :(');
});
it('renders all graphics when visits are properly loaded', () => {
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) => {
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).toEqual(5);
expect(graphs.length + sortableBarGraphs.length + lineChart.length).toEqual(expectedGraphics);
expect(table).toHaveLength(expectedTables);
});
it('reloads visits when selected dates change', () => {
@ -88,6 +101,10 @@ describe('<VisitStats />', () => {
it('holds the map button content generator on cities graph extraHeaderContent', () => {
const wrapper = createComponent({ loading: false, error: false, visits });
const locationNav = wrapper.find(NavLink).at(2);
locationNav.simulate('click');
const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]');
const extraHeaderContent = citiesGraph.prop('extraHeaderContent');