mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 01:20:24 +03:00
Improved visits section so that charts are grouped in sub tabs
This commit is contained in:
parent
a013d40bf1
commit
c74355e363
9 changed files with 196 additions and 139 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
20
src/visits/VisitsStats.scss
Normal file
20
src/visits/VisitsStats.scss
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
height: 300px !important;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
height: 350px !important;
|
||||
height: 400px !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
Loading…
Reference in a new issue