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 { .progress-bar {
background-color: $mainColor; 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'; @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 .form-group:last-child,
.create-short-url p:last-child { .create-short-url p:last-child {
margin-bottom: 0; margin-bottom: 0;

View file

@ -197,7 +197,7 @@ const CreateShortUrl = (
outline outline
color="primary" color="primary"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)} disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
className="create-short-url__save-btn" className="btn-xs-block"
> >
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'} {shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</Button> </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 { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC } from 'react'; import { useState, useEffect, useMemo, FC } from 'react';
import { Button, Card, Collapse, Progress } from 'reactstrap'; import { Button, Card, Nav, NavLink, Progress } from 'reactstrap';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 moment from 'moment';
import DateRangeRow from '../utils/DateRangeRow'; import DateRangeRow from '../utils/DateRangeRow';
import Message from '../utils/Message'; import Message from '../utils/Message';
import { formatDate } from '../utils/helpers/date'; import { formatDate } from '../utils/helpers/date';
import { useToggle } from '../utils/helpers/hooks';
import { ShlinkVisitsParams } from '../utils/services/types'; import { ShlinkVisitsParams } from '../utils/services/types';
import SortableBarGraph from './helpers/SortableBarGraph'; import SortableBarGraph from './helpers/SortableBarGraph';
import GraphCard from './helpers/GraphCard'; import GraphCard from './helpers/GraphCard';
@ -17,15 +16,23 @@ import VisitsTable from './VisitsTable';
import { NormalizedVisit, Stats, VisitsInfo } from './types'; import { NormalizedVisit, Stats, VisitsInfo } from './types';
import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import OpenMapModalBtn from './helpers/OpenMapModalBtn';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import './VisitsStats.scss';
export interface VisitsStatsProps { export interface VisitsStatsProps {
matchMedia?: (query: string) => MediaQueryList;
getVisits: (params: Partial<ShlinkVisitsParams>) => void; getVisits: (params: Partial<ShlinkVisitsParams>) => void;
visitsInfo: VisitsInfo; visitsInfo: VisitsInfo;
cancelGetVisits: () => void; cancelGetVisits: () => void;
} }
type HighlightableProps = 'referer' | 'country' | 'city'; 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 = ( const highlightedVisitsToStats = (
highlightedVisits: NormalizedVisit[], highlightedVisits: NormalizedVisit[],
@ -42,19 +49,15 @@ const highlightedVisitsToStats = (
const format = formatDate(); const format = formatDate();
let selectedBar: string | undefined; let selectedBar: string | undefined;
const VisitsStats: FC<VisitsStatsProps> = ( const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, cancelGetVisits }) => {
{ children, visitsInfo, getVisits, cancelGetVisits, matchMedia = window.matchMedia },
) => {
const [ startDate, setStartDate ] = useState<moment.Moment | null>(null); const [ startDate, setStartDate ] = useState<moment.Moment | null>(null);
const [ endDate, setEndDate ] = 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 [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>(); 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 { visits, loading, loadingLarge, error, progress } = visitsInfo;
const showTableControls = !loading && visits.length > 0;
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
() => processStatsFromVisits(normalizedVisits), () => processStatsFromVisits(normalizedVisits),
@ -62,7 +65,6 @@ const VisitsStats: FC<VisitsStatsProps> = (
); );
const mapLocations = values(citiesForMap); const mapLocations = values(citiesForMap);
const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => { const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
selectedBar = undefined; selectedBar = undefined;
setHighlightedVisits(selectedVisits); setHighlightedVisits(selectedVisits);
@ -81,15 +83,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
} }
}; };
useEffect(() => { useEffect(() => () => cancelGetVisits(), []);
determineIsMobileDevice();
window.addEventListener('resize', determineIsMobileDevice);
return () => {
cancelGetVisits();
window.removeEventListener('resize', determineIsMobileDevice);
};
}, []);
useEffect(() => { useEffect(() => {
getVisits({ startDate: format(startDate) ?? undefined, endDate: format(endDate) ?? undefined }); getVisits({ startDate: format(startDate) ?? undefined, endDate: format(endDate) ?? undefined });
}, [ startDate, endDate ]); }, [ startDate, endDate ]);
@ -121,7 +115,26 @@ const VisitsStats: FC<VisitsStatsProps> = (
} }
return ( return (
<>
<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"> <div className="row">
{activeSection === 'byTime' && (
<div className="col-12 mt-4"> <div className="col-12 mt-4">
<LineChartCard <LineChartCard
title="Visits during time" title="Visits during time"
@ -131,6 +144,9 @@ const VisitsStats: FC<VisitsStatsProps> = (
setSelectedVisits={setSelectedVisits} setSelectedVisits={setSelectedVisits}
/> />
</div> </div>
)}
{activeSection === 'byContext' && (
<>
<div className="col-xl-4 col-lg-6 mt-4"> <div className="col-xl-4 col-lg-6 mt-4">
<GraphCard title="Operating systems" stats={os} /> <GraphCard title="Operating systems" stats={os} />
</div> </div>
@ -151,6 +167,10 @@ const VisitsStats: FC<VisitsStatsProps> = (
onClick={highlightVisitsForProp('referer')} onClick={highlightVisitsForProp('referer')}
/> />
</div> </div>
</>
)}
{activeSection === 'byLocation' && (
<>
<div className="col-lg-6 mt-4"> <div className="col-lg-6 mt-4">
<SortableBarGraph <SortableBarGraph
title="Countries" title="Countries"
@ -181,7 +201,20 @@ const VisitsStats: FC<VisitsStatsProps> = (
onClick={highlightVisitsForProp('city')} onClick={highlightVisitsForProp('city')}
/> />
</div> </div>
</>
)}
{activeSection === 'list' && (
<div className="col-12">
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isSticky
/>
</div> </div>
)}
</div>
</>
); );
}; };
@ -200,47 +233,21 @@ const VisitsStats: FC<VisitsStatsProps> = (
onEndDateChange={setEndDate} onEndDateChange={setEndDate}
/> />
</div> </div>
{visits.length > 0 && (
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0"> <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 <Button
outline outline
disabled={highlightedVisits.length === 0} disabled={highlightedVisits.length === 0}
block={isMobileDevice} className="btn-md-block"
onClick={() => setSelectedVisits([])} onClick={() => setSelectedVisits([])}
> >
Reset selection Reset selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
</Button> </Button>
</span>
</span>
)}
</div> </div>
)}
</div> </div>
</section> </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> <section>
{renderVisitsContent()} {renderVisitsContent()}
</section> </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 Moment from 'react-moment';
import classNames from 'classnames'; import classNames from 'classnames';
import { min, splitEvery } from 'ramda'; import { min, splitEvery } from 'ramda';
@ -68,6 +68,7 @@ const VisitsTable = ({
const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined); const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined);
const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined }); const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined });
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
const isFirstLoad = useRef(true);
const [ page, setPage ] = useState(1); const [ page, setPage ] = useState(1);
const end = page * PAGE_SIZE; const end = page * PAGE_SIZE;
@ -91,7 +92,12 @@ const VisitsTable = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
if (isFirstLoad.current) {
isFirstLoad.current = false;
} else {
setSelectedVisits([]); setSelectedVisits([]);
}
}, [ searchTerm ]); }, [ searchTerm ]);
return ( return (

View file

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

View file

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

View file

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