shlink-web-client/src/visits/VisitsStats.tsx

328 lines
12 KiB
TypeScript
Raw Normal View History

import { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC, useRef, PropsWithChildren } from 'react';
import { Button, Progress, Row } from 'reactstrap';
2020-09-05 09:49:18 +03:00
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, Routes, Navigate } from 'react-router-dom';
import classNames from 'classnames';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { Message } from '../utils/Message';
import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
2021-03-06 12:56:49 +03:00
import { Settings } from '../settings/reducers/settings';
import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers';
import { NavPillItem, NavPills } from '../utils/NavPills';
import { ExportBtn } from '../utils/ExportBtn';
import { LineChartCard } from './charts/LineChartCard';
import { VisitsTable } from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
import { DoughnutChartCard } from './charts/DoughnutChartCard';
import { SortableBarChartCard } from './charts/SortableBarChartCard';
2020-09-05 09:49:18 +03:00
export type VisitsStatsProps = PropsWithChildren<{
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
2020-09-05 09:49:18 +03:00
visitsInfo: VisitsInfo;
2021-03-06 12:56:49 +03:00
settings: Settings;
selectedServer: SelectedServer;
2020-09-05 09:49:18 +03:00
cancelGetVisits: () => void;
2020-12-20 21:28:09 +03:00
domain?: string;
2021-03-14 14:49:12 +03:00
exportCsv: (visits: NormalizedVisit[]) => void;
isOrphanVisits?: boolean;
}>;
2020-09-05 09:49:18 +03:00
interface VisitsNavLinkProps {
title: string;
subPath: string;
icon: IconDefinition;
}
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
const sections: Record<Section, VisitsNavLinkProps> = {
2022-02-06 22:07:18 +03:00
byTime: { title: 'By time', subPath: 'by-time', 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 },
};
2020-09-05 09:49:18 +03:00
let selectedBar: string | undefined;
export const VisitsStats: FC<VisitsStatsProps> = ({
children,
visitsInfo,
getVisits,
cancelGetVisits,
domain,
settings,
exportCsv,
selectedServer,
isOrphanVisits = false,
}) => {
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
2022-03-26 14:17:42 +03:00
const [initialInterval, setInitialInterval] = useState<DateInterval>(
fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
);
2022-03-26 14:17:42 +03:00
const [dateRange, setDateRange] = useState<DateRange>(intervalToDateRange(initialInterval));
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
const [visitsFilter, setVisitsFilter] = useState<VisitsFilter>({});
const botsSupported = supportsBotVisits(selectedServer);
const isFirstLoad = useRef(true);
2020-09-05 09:49:18 +03:00
2020-12-20 21:28:09 +03:00
const buildSectionUrl = (subPath?: string) => {
const query = domain ? `?domain=${domain}` : '';
2022-02-06 22:07:18 +03:00
return !subPath ? `${query}` : `${subPath}${query}`;
2020-12-20 21:28:09 +03:00
};
2022-03-26 14:17:42 +03:00
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
2020-09-05 09:49:18 +03:00
() => processStatsFromVisits(normalizedVisits),
2022-03-26 14:17:42 +03:00
[normalizedVisits],
2020-09-05 09:49:18 +03:00
);
const mapLocations = values(citiesForMap);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
selectedBar = undefined;
setHighlightedVisits(selectedVisits);
};
const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
2020-09-05 09:49:18 +03:00
const newSelectedBar = `${prop}_${value}`;
if (selectedBar === newSelectedBar) {
setHighlightedVisits([]);
setHighlightedLabel(undefined);
selectedBar = undefined;
} else {
setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value)));
2020-09-05 09:49:18 +03:00
setHighlightedLabel(value);
selectedBar = newSelectedBar;
}
};
useEffect(() => cancelGetVisits, []);
2020-09-05 09:49:18 +03:00
useEffect(() => {
getVisits({ dateRange, filter: visitsFilter }, isFirstLoad.current);
isFirstLoad.current = false;
2022-03-26 14:17:42 +03:00
}, [dateRange, visitsFilter]);
useEffect(() => {
fallbackInterval && setInitialInterval(fallbackInterval);
2022-03-26 14:17:42 +03:00
}, [fallbackInterval]);
2020-09-05 09:49:18 +03:00
const renderVisitsContent = () => {
if (loadingLarge) {
return (
<Message loading>
This is going to take a while... :S
<Progress value={progress} striped={progress === 100} className="mt-3" />
</Message>
);
}
if (loading) {
return <Message loading />;
}
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while loading visits :(" />
</Result>
);
2020-09-05 09:49:18 +03:00
}
if (isEmpty(visits)) {
return <Message>There are no visits matching current filter</Message>;
2020-09-05 09:49:18 +03:00
}
return (
<>
2022-02-14 22:28:28 +03:00
<NavPills fill>
{Object.values(sections).map(({ title, icon, subPath }, index) => (
<NavPillItem key={index} to={buildSectionUrl(subPath)} replace>
<FontAwesomeIcon icon={icon} />
<span className="ms-2 d-none d-sm-inline">{title}</span>
</NavPillItem>
))}
</NavPills>
<Row>
2022-02-06 22:07:18 +03:00
<Routes>
<Route
path={sections.byTime.subPath}
element={(
<div className="col-12 mt-3">
<LineChartCard
title="Visits during time"
visits={normalizedVisits}
highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel}
2022-02-06 22:07:18 +03:00
setSelectedVisits={setSelectedVisits}
/>
</div>
)}
2022-02-06 22:07:18 +03:00
/>
2020-12-20 21:28:09 +03:00
2022-02-06 22:07:18 +03:00
<Route
path={sections.byContext.subPath}
element={(
<>
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
<DoughnutChartCard title="Operating systems" stats={os} />
</div>
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
<DoughnutChartCard title="Browsers" stats={browsers} />
</div>
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
<SortableBarChartCard
title="Referrers"
stats={referrers}
withPagination={false}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('referer')}
/>
</div>
{isOrphanVisits && (
<div className="mt-3 col-lg-6">
<SortableBarChartCard
title="Visited URLs"
stats={visitedUrls}
highlightedLabel={highlightedLabel}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'visitedUrl')}
sortingItems={{
visitedUrl: 'Visited URL',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('visitedUrl')}
/>
</div>
)}
</>
)}
/>
2020-12-20 21:28:09 +03:00
2022-02-06 22:07:18 +03:00
<Route
path={sections.byLocation.subPath}
element={(
<>
<div className="col-lg-6 mt-3">
<SortableBarChartCard
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-3">
<SortableBarChartCard
title="Cities"
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
highlightedLabel={highlightedLabel}
extraHeaderContent={(activeCities) => mapLocations.length > 0 && (
2022-02-06 22:07:18 +03:00
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
2022-03-26 14:17:42 +03:00
)}
2022-02-06 22:07:18 +03:00
sortingItems={{
name: 'City name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('city')}
/>
</div>
</>
)}
/>
<Route
path={sections.list.subPath}
element={(
<div className="col-12">
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits}
selectedServer={selectedServer}
/>
</div>
)}
/>
2020-12-20 21:28:09 +03:00
2022-02-06 22:07:18 +03:00
<Route path="*" element={<Navigate replace to={buildSectionUrl(sections.byTime.subPath)} />} />
</Routes>
</Row>
</>
2020-09-05 09:49:18 +03:00
);
};
return (
2020-11-14 00:44:26 +03:00
<>
2020-09-05 09:49:18 +03:00
{children}
<section className="mt-3">
2020-09-05 09:49:18 +03:00
<div className="row flex-md-row-reverse">
<div className="col-lg-7 col-xl-6">
<div className="d-md-flex">
<div className="flex-fill">
<DateRangeSelector
updatable
disabled={loading}
initialDateRange={initialInterval}
defaultText="All visits"
onDatesChange={setDateRange}
/>
</div>
<VisitsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
isOrphanVisits={isOrphanVisits}
botsSupported={botsSupported}
selected={visitsFilter}
onChange={setVisitsFilter}
/>
</div>
2020-09-05 09:49:18 +03:00
</div>
{visits.length > 0 && (
<div className="col-lg-5 col-xl-6 mt-3 mt-lg-0">
<div className="d-flex">
2022-05-25 19:26:34 +03:00
<ExportBtn
className="btn-md-block"
amount={normalizedVisits.length}
onClick={() => exportCsv(normalizedVisits)}
/>
<Button
outline
disabled={highlightedVisits.length === 0}
2022-05-25 19:26:34 +03:00
className="btn-md-block ms-2"
onClick={() => setSelectedVisits([])}
>
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
</Button>
</div>
</div>
)}
2020-09-05 09:49:18 +03:00
</div>
</section>
<section className="mt-3">
2020-09-05 09:49:18 +03:00
{renderVisitsContent()}
</section>
2020-11-14 00:44:26 +03:00
</>
2020-09-05 09:49:18 +03:00
);
};