mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-13 02:53:47 +00:00
Extract shlink-web-component outside of src folder
This commit is contained in:
41
shlink-web-component/visits/DomainVisits.tsx
Normal file
41
shlink-web-component/visits/DomainVisits.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import type { ShlinkVisitsParams } from '../api-contract';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { DomainVisits as DomainVisitsState, LoadDomainVisits } from './reducers/domainVisits';
|
||||
import type { NormalizedVisit } from './types';
|
||||
import { toApiParams } from './types/helpers';
|
||||
import { VisitsHeader } from './VisitsHeader';
|
||||
import { VisitsStats } from './VisitsStats';
|
||||
|
||||
export interface DomainVisitsProps {
|
||||
getDomainVisits: (params: LoadDomainVisits) => void;
|
||||
domainVisits: DomainVisitsState;
|
||||
cancelGetDomainVisits: () => void;
|
||||
}
|
||||
|
||||
export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
|
||||
getDomainVisits,
|
||||
domainVisits,
|
||||
cancelGetDomainVisits,
|
||||
}: DomainVisitsProps) => {
|
||||
const goBack = useGoBack();
|
||||
const { domain = '' } = useParams();
|
||||
const [authority, domainId = authority] = domain.split('_');
|
||||
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
|
||||
getDomainVisits({ domain: domainId, query: toApiParams(params), doIntervalFallback });
|
||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits);
|
||||
|
||||
return (
|
||||
<VisitsStats
|
||||
getVisits={loadVisits}
|
||||
cancelGetVisits={cancelGetDomainVisits}
|
||||
visitsInfo={domainVisits}
|
||||
exportCsv={exportCsv}
|
||||
>
|
||||
<VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} />
|
||||
</VisitsStats>
|
||||
);
|
||||
}, () => [Topics.visits]);
|
||||
37
shlink-web-component/visits/NonOrphanVisits.tsx
Normal file
37
shlink-web-component/visits/NonOrphanVisits.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { LoadVisits, VisitsInfo } from './reducers/types';
|
||||
import type { NormalizedVisit, VisitsParams } from './types';
|
||||
import { toApiParams } from './types/helpers';
|
||||
import { VisitsHeader } from './VisitsHeader';
|
||||
import { VisitsStats } from './VisitsStats';
|
||||
|
||||
export interface NonOrphanVisitsProps {
|
||||
getNonOrphanVisits: (params: LoadVisits) => void;
|
||||
nonOrphanVisits: VisitsInfo;
|
||||
cancelGetNonOrphanVisits: () => void;
|
||||
}
|
||||
|
||||
export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
|
||||
getNonOrphanVisits,
|
||||
nonOrphanVisits,
|
||||
cancelGetNonOrphanVisits,
|
||||
}: NonOrphanVisitsProps) => {
|
||||
const goBack = useGoBack();
|
||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
|
||||
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
|
||||
getNonOrphanVisits({ query: toApiParams(params), doIntervalFallback });
|
||||
|
||||
return (
|
||||
<VisitsStats
|
||||
getVisits={loadVisits}
|
||||
cancelGetVisits={cancelGetNonOrphanVisits}
|
||||
visitsInfo={nonOrphanVisits}
|
||||
exportCsv={exportCsv}
|
||||
>
|
||||
<VisitsHeader title="Non-orphan visits" goBack={goBack} visits={nonOrphanVisits.visits} />
|
||||
</VisitsStats>
|
||||
);
|
||||
}, () => [Topics.visits]);
|
||||
40
shlink-web-component/visits/OrphanVisits.tsx
Normal file
40
shlink-web-component/visits/OrphanVisits.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { LoadOrphanVisits } from './reducers/orphanVisits';
|
||||
import type { VisitsInfo } from './reducers/types';
|
||||
import type { NormalizedVisit, VisitsParams } from './types';
|
||||
import { toApiParams } from './types/helpers';
|
||||
import { VisitsHeader } from './VisitsHeader';
|
||||
import { VisitsStats } from './VisitsStats';
|
||||
|
||||
export interface OrphanVisitsProps {
|
||||
getOrphanVisits: (params: LoadOrphanVisits) => void;
|
||||
orphanVisits: VisitsInfo;
|
||||
cancelGetOrphanVisits: () => void;
|
||||
}
|
||||
|
||||
export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
|
||||
getOrphanVisits,
|
||||
orphanVisits,
|
||||
cancelGetOrphanVisits,
|
||||
}: OrphanVisitsProps) => {
|
||||
const goBack = useGoBack();
|
||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
|
||||
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getOrphanVisits(
|
||||
{ query: toApiParams(params), orphanVisitsType: params.filter?.orphanVisitsType, doIntervalFallback },
|
||||
);
|
||||
|
||||
return (
|
||||
<VisitsStats
|
||||
getVisits={loadVisits}
|
||||
cancelGetVisits={cancelGetOrphanVisits}
|
||||
visitsInfo={orphanVisits}
|
||||
exportCsv={exportCsv}
|
||||
isOrphanVisits
|
||||
>
|
||||
<VisitsHeader title="Orphan visits" goBack={goBack} visits={orphanVisits.visits} />
|
||||
</VisitsStats>
|
||||
);
|
||||
}, () => [Topics.orphanVisits]);
|
||||
60
shlink-web-component/visits/ShortUrlVisits.tsx
Normal file
60
shlink-web-component/visits/ShortUrlVisits.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import { parseQuery } from '../../src/utils/helpers/query';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { ShortUrlIdentifier } from '../short-urls/data';
|
||||
import { urlDecodeShortCode } from '../short-urls/helpers';
|
||||
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||
import type { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
|
||||
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
|
||||
import type { NormalizedVisit, VisitsParams } from './types';
|
||||
import { toApiParams } from './types/helpers';
|
||||
import { VisitsStats } from './VisitsStats';
|
||||
|
||||
export interface ShortUrlVisitsProps {
|
||||
getShortUrlVisits: (params: LoadShortUrlVisits) => void;
|
||||
shortUrlVisits: ShortUrlVisitsState;
|
||||
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
cancelGetShortUrlVisits: () => void;
|
||||
}
|
||||
|
||||
export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
|
||||
shortUrlVisits,
|
||||
shortUrlDetail,
|
||||
getShortUrlVisits,
|
||||
getShortUrlDetail,
|
||||
cancelGetShortUrlVisits,
|
||||
}: ShortUrlVisitsProps) => {
|
||||
const { shortCode = '' } = useParams<{ shortCode: string }>();
|
||||
const { search } = useLocation();
|
||||
const goBack = useGoBack();
|
||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({
|
||||
shortCode: urlDecodeShortCode(shortCode),
|
||||
query: { ...toApiParams(params), domain },
|
||||
doIntervalFallback,
|
||||
});
|
||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
|
||||
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
|
||||
visits,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getShortUrlDetail({ shortCode: urlDecodeShortCode(shortCode), domain });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VisitsStats
|
||||
getVisits={loadVisits}
|
||||
cancelGetVisits={cancelGetShortUrlVisits}
|
||||
visitsInfo={shortUrlVisits}
|
||||
exportCsv={exportCsv}
|
||||
>
|
||||
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
|
||||
</VisitsStats>
|
||||
);
|
||||
}, (_, params) => (params.shortCode ? [Topics.shortUrlVisits(urlDecodeShortCode(params.shortCode))] : []));
|
||||
3
shlink-web-component/visits/ShortUrlVisitsHeader.scss
Normal file
3
shlink-web-component/visits/ShortUrlVisitsHeader.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.short-url-visits-header__created-at {
|
||||
cursor: default;
|
||||
}
|
||||
45
shlink-web-component/visits/ShortUrlVisitsHeader.tsx
Normal file
45
shlink-web-component/visits/ShortUrlVisitsHeader.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { Time } from '../../src/utils/dates/Time';
|
||||
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||
import type { ShortUrlVisits } from './reducers/shortUrlVisits';
|
||||
import { VisitsHeader } from './VisitsHeader';
|
||||
import './ShortUrlVisitsHeader.scss';
|
||||
|
||||
interface ShortUrlVisitsHeaderProps {
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
shortUrlVisits: ShortUrlVisits;
|
||||
goBack: () => void;
|
||||
}
|
||||
|
||||
export const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortUrlVisitsHeaderProps) => {
|
||||
const { shortUrl, loading } = shortUrlDetail;
|
||||
const { visits } = shortUrlVisits;
|
||||
const shortLink = shortUrl?.shortUrl ?? '';
|
||||
const longLink = shortUrl?.longUrl ?? '';
|
||||
const title = shortUrl?.title;
|
||||
|
||||
const renderDate = () => (!shortUrl ? <small>Loading...</small> : (
|
||||
<span>
|
||||
<b id="created" className="short-url-visits-header__created-at">
|
||||
<Time date={shortUrl.dateCreated} relative />
|
||||
</b>
|
||||
<UncontrolledTooltip placement="bottom" target="created">
|
||||
<Time date={shortUrl.dateCreated} />
|
||||
</UncontrolledTooltip>
|
||||
</span>
|
||||
));
|
||||
const visitsStatsTitle = <>Visits for <ExternalLink href={shortLink} /></>;
|
||||
|
||||
return (
|
||||
<VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}>
|
||||
<hr />
|
||||
<div>Created: {renderDate()}</div>
|
||||
<div className="long-url-container">
|
||||
{`${title ? 'Title' : 'Long URL'}: `}
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && <ExternalLink href={longLink}>{title ?? longLink}</ExternalLink>}
|
||||
</div>
|
||||
</VisitsHeader>
|
||||
);
|
||||
};
|
||||
41
shlink-web-component/visits/TagVisits.tsx
Normal file
41
shlink-web-component/visits/TagVisits.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { ShlinkVisitsParams } from '../../api/types';
|
||||
import type { ReportExporter } from '../../src/common/services/ReportExporter';
|
||||
import { useGoBack } from '../../src/utils/helpers/hooks';
|
||||
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
|
||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import type { LoadTagVisits, TagVisits as TagVisitsState } from './reducers/tagVisits';
|
||||
import { TagVisitsHeader } from './TagVisitsHeader';
|
||||
import type { NormalizedVisit } from './types';
|
||||
import { toApiParams } from './types/helpers';
|
||||
import { VisitsStats } from './VisitsStats';
|
||||
|
||||
export interface TagVisitsProps {
|
||||
getTagVisits: (params: LoadTagVisits) => void;
|
||||
tagVisits: TagVisitsState;
|
||||
cancelGetTagVisits: () => void;
|
||||
}
|
||||
|
||||
export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExporter) => boundToMercureHub(({
|
||||
getTagVisits,
|
||||
tagVisits,
|
||||
cancelGetTagVisits,
|
||||
}: TagVisitsProps) => {
|
||||
const goBack = useGoBack();
|
||||
const { tag = '' } = useParams();
|
||||
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
|
||||
getTagVisits({ tag, query: toApiParams(params), doIntervalFallback });
|
||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
|
||||
|
||||
return (
|
||||
<VisitsStats
|
||||
getVisits={loadVisits}
|
||||
cancelGetVisits={cancelGetTagVisits}
|
||||
visitsInfo={tagVisits}
|
||||
exportCsv={exportCsv}
|
||||
>
|
||||
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
||||
</VisitsStats>
|
||||
);
|
||||
}, () => [Topics.visits]);
|
||||
23
shlink-web-component/visits/TagVisitsHeader.tsx
Normal file
23
shlink-web-component/visits/TagVisitsHeader.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
|
||||
import { Tag } from '../tags/helpers/Tag';
|
||||
import type { TagVisits } from './reducers/tagVisits';
|
||||
import { VisitsHeader } from './VisitsHeader';
|
||||
import './ShortUrlVisitsHeader.scss';
|
||||
|
||||
interface TagVisitsHeaderProps {
|
||||
tagVisits: TagVisits;
|
||||
goBack: () => void;
|
||||
colorGenerator: ColorGenerator;
|
||||
}
|
||||
|
||||
export const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }: TagVisitsHeaderProps) => {
|
||||
const { visits, tag } = tagVisits;
|
||||
const visitsStatsTitle = (
|
||||
<span className="d-flex align-items-center justify-content-center">
|
||||
<span className="me-2">Visits for</span>
|
||||
<Tag text={tag} colorGenerator={colorGenerator} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
|
||||
};
|
||||
38
shlink-web-component/visits/VisitsHeader.tsx
Normal file
38
shlink-web-component/visits/VisitsHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Button, Card } from 'reactstrap';
|
||||
import type { ShortUrl } from '../short-urls/data';
|
||||
import { ShortUrlVisitsCount } from '../short-urls/helpers/ShortUrlVisitsCount';
|
||||
import type { Visit } from './types';
|
||||
|
||||
type VisitsHeaderProps = PropsWithChildren<{
|
||||
visits: Visit[];
|
||||
goBack: () => void;
|
||||
title: ReactNode;
|
||||
shortUrl?: ShortUrl;
|
||||
}>;
|
||||
|
||||
export const VisitsHeader: FC<VisitsHeaderProps> = ({ visits, goBack, shortUrl, children, title }) => (
|
||||
<header>
|
||||
<Card body>
|
||||
<h2 className="d-flex justify-content-between align-items-center mb-0">
|
||||
<Button color="link" size="lg" className="p-0 me-3" onClick={goBack}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</Button>
|
||||
<span className="text-center d-none d-sm-block">
|
||||
<small>{title}</small>
|
||||
</span>
|
||||
<span className="badge badge-main ms-3">
|
||||
Visits:{' '}
|
||||
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
|
||||
</span>
|
||||
</h2>
|
||||
<h3 className="text-center d-block d-sm-none mb-0 mt-3">
|
||||
<small>{title}</small>
|
||||
</h3>
|
||||
|
||||
{children && <div className="mt-md-2">{children}</div>}
|
||||
</Card>
|
||||
</header>
|
||||
);
|
||||
335
shlink-web-component/visits/VisitsStats.tsx
Normal file
335
shlink-web-component/visits/VisitsStats.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||
import { faCalendarAlt, faChartPie, faList, faMapMarkedAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { isEmpty, pipe, propEq, values } from 'ramda';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { Button, Progress, Row } from 'reactstrap';
|
||||
import { ShlinkApiError } from '../../src/api/ShlinkApiError';
|
||||
import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
|
||||
import { ExportBtn } from '../../src/utils/ExportBtn';
|
||||
import type { DateInterval, DateRange } from '../../src/utils/helpers/dateIntervals';
|
||||
import { toDateRange } from '../../src/utils/helpers/dateIntervals';
|
||||
import { prettify } from '../../src/utils/helpers/numbers';
|
||||
import { Message } from '../../src/utils/Message';
|
||||
import { NavPillItem, NavPills } from '../../src/utils/NavPills';
|
||||
import { Result } from '../../src/utils/Result';
|
||||
import { useSetting } from '../utils/settings';
|
||||
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
||||
import { LineChartCard } from './charts/LineChartCard';
|
||||
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||
import { useVisitsQuery } from './helpers/hooks';
|
||||
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
|
||||
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
||||
import type { VisitsInfo } from './reducers/types';
|
||||
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
||||
import type { NormalizedOrphanVisit, NormalizedVisit, VisitsParams } from './types';
|
||||
import type { HighlightableProps } from './types/helpers';
|
||||
import { highlightedVisitsToStats } from './types/helpers';
|
||||
import { VisitsTable } from './VisitsTable';
|
||||
|
||||
export type VisitsStatsProps = PropsWithChildren<{
|
||||
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
||||
visitsInfo: VisitsInfo;
|
||||
cancelGetVisits: () => void;
|
||||
exportCsv: (visits: NormalizedVisit[]) => void;
|
||||
isOrphanVisits?: boolean;
|
||||
}>;
|
||||
|
||||
interface VisitsNavLinkProps {
|
||||
title: string;
|
||||
subPath: string;
|
||||
icon: IconDefinition;
|
||||
}
|
||||
|
||||
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
|
||||
|
||||
const sections: Record<Section, VisitsNavLinkProps> = {
|
||||
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 },
|
||||
};
|
||||
|
||||
let selectedBar: string | undefined;
|
||||
|
||||
export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||
children,
|
||||
visitsInfo,
|
||||
getVisits,
|
||||
cancelGetVisits,
|
||||
exportCsv,
|
||||
isOrphanVisits = false,
|
||||
}) => {
|
||||
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
|
||||
const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery();
|
||||
const visitsSettings = useSetting('visits');
|
||||
const setDates = pipe(
|
||||
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||
dateRange: {
|
||||
startDate: theStartDate ?? undefined,
|
||||
endDate: theEndDate ?? undefined,
|
||||
},
|
||||
}),
|
||||
updateFiltering,
|
||||
);
|
||||
const initialInterval = useRef<DateRange | DateInterval>(
|
||||
dateRange ?? fallbackInterval ?? visitsSettings?.defaultInterval ?? 'last30Days',
|
||||
);
|
||||
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
|
||||
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
|
||||
const isFirstLoad = useRef(true);
|
||||
const { search } = useLocation();
|
||||
|
||||
const buildSectionUrl = (subPath?: string) => (!subPath ? search : `${subPath}${search}`);
|
||||
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
|
||||
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
|
||||
() => processStatsFromVisits(normalizedVisits),
|
||||
[normalizedVisits],
|
||||
);
|
||||
const resolvedFilter = useMemo(() => ({
|
||||
...visitsFilter,
|
||||
excludeBots: visitsFilter.excludeBots ?? visitsSettings?.excludeBots,
|
||||
}), [visitsFilter]);
|
||||
const mapLocations = values(citiesForMap);
|
||||
|
||||
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
|
||||
selectedBar = undefined;
|
||||
setHighlightedVisits(selectedVisits);
|
||||
};
|
||||
const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
|
||||
const newSelectedBar = `${prop}_${value}`;
|
||||
|
||||
if (selectedBar === newSelectedBar) {
|
||||
setHighlightedVisits([]);
|
||||
setHighlightedLabel(undefined);
|
||||
selectedBar = undefined;
|
||||
} else {
|
||||
setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value)));
|
||||
setHighlightedLabel(value);
|
||||
selectedBar = newSelectedBar;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => cancelGetVisits, []);
|
||||
useEffect(() => {
|
||||
const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current));
|
||||
getVisits({ dateRange: resolvedDateRange, filter: resolvedFilter }, isFirstLoad.current);
|
||||
isFirstLoad.current = false;
|
||||
}, [dateRange, visitsFilter]);
|
||||
useEffect(() => {
|
||||
// As soon as the fallback is loaded, if the initial interval used the settings one, we do fall back
|
||||
if (fallbackInterval && initialInterval.current === (visitsSettings?.defaultInterval ?? 'last30Days')) {
|
||||
initialInterval.current = fallbackInterval;
|
||||
}
|
||||
}, [fallbackInterval]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty(visits)) {
|
||||
return <Message>There are no visits matching current filter</Message>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<Routes>
|
||||
<Route
|
||||
path={sections.byTime.subPath}
|
||||
element={(
|
||||
<div className="col-12 mt-3">
|
||||
<LineChartCard
|
||||
title="Visits during time"
|
||||
visits={normalizedVisits}
|
||||
highlightedVisits={highlightedVisits}
|
||||
highlightedLabel={highlightedLabel}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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 && (
|
||||
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
|
||||
)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate replace to={buildSectionUrl(sections.byTime.subPath)} />} />
|
||||
</Routes>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
<section className="mt-3">
|
||||
<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.current}
|
||||
defaultText="All visits"
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
<VisitsFilterDropdown
|
||||
className="ms-0 ms-md-2 mt-3 mt-md-0"
|
||||
isOrphanVisits={isOrphanVisits}
|
||||
selected={resolvedFilter}
|
||||
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{visits.length > 0 && (
|
||||
<div className="col-lg-5 col-xl-6 mt-3 mt-lg-0">
|
||||
<div className="d-flex">
|
||||
<ExportBtn
|
||||
className="btn-md-block"
|
||||
amount={normalizedVisits.length}
|
||||
onClick={() => exportCsv(normalizedVisits)}
|
||||
/>
|
||||
<Button
|
||||
outline
|
||||
disabled={highlightedVisits.length === 0}
|
||||
className="btn-md-block ms-2"
|
||||
onClick={() => setSelectedVisits([])}
|
||||
>
|
||||
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-3">
|
||||
{renderVisitsContent()}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
39
shlink-web-component/visits/VisitsTable.scss
Normal file
39
shlink-web-component/visits/VisitsTable.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
@import '../../src/utils/base';
|
||||
@import '../../src/utils/mixins/sticky-cell';
|
||||
|
||||
.visits-table {
|
||||
margin: 1.5rem 0 0;
|
||||
position: relative;
|
||||
background-color: var(--primary-color);
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.visits-table__header-cell {
|
||||
cursor: pointer;
|
||||
margin-bottom: 55px;
|
||||
|
||||
@include sticky-cell();
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
&.visits-table__sticky {
|
||||
top: $headerHeight + 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visits-table__header-icon {
|
||||
float: right;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.visits-table__footer-cell.visits-table__footer-cell {
|
||||
bottom: 0;
|
||||
margin-top: 34px;
|
||||
padding: .5rem;
|
||||
|
||||
@include sticky-cell();
|
||||
}
|
||||
|
||||
.visits-table__sticky.visits-table__sticky {
|
||||
position: sticky;
|
||||
}
|
||||
216
shlink-web-component/visits/VisitsTable.tsx
Normal file
216
shlink-web-component/visits/VisitsTable.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { min, splitEvery } from 'ramda';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { SimplePaginator } from '../../src/common/SimplePaginator';
|
||||
import { Time } from '../../src/utils/dates/Time';
|
||||
import { prettify } from '../../src/utils/helpers/numbers';
|
||||
import type { Order } from '../../src/utils/helpers/ordering';
|
||||
import { determineOrderDir, sortList } from '../../src/utils/helpers/ordering';
|
||||
import { SearchField } from '../../src/utils/SearchField';
|
||||
import { TableOrderIcon } from '../../src/utils/table/TableOrderIcon';
|
||||
import type { MediaMatcher } from '../../src/utils/types';
|
||||
import type { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||
import './VisitsTable.scss';
|
||||
|
||||
export interface VisitsTableProps {
|
||||
visits: NormalizedVisit[];
|
||||
selectedVisits?: NormalizedVisit[];
|
||||
setSelectedVisits: (visits: NormalizedVisit[]) => void;
|
||||
matchMedia?: MediaMatcher;
|
||||
isOrphanVisits?: boolean;
|
||||
}
|
||||
|
||||
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
|
||||
type VisitsOrder = Order<OrderableFields>;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
|
||||
`${browser} ${os} ${referer} ${country} ${city} ${(rest as NormalizedOrphanVisit).visitedUrl}`.toLowerCase().includes(
|
||||
searchTerm.toLowerCase(),
|
||||
);
|
||||
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
||||
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
||||
const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList<NormalizedVisit>(visits, order as any);
|
||||
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => {
|
||||
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [...allVisits];
|
||||
const sortedVisits = sortVisits(order, filteredVisits);
|
||||
const total = sortedVisits.length;
|
||||
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
|
||||
|
||||
return { visitsGroups, total };
|
||||
};
|
||||
|
||||
export const VisitsTable = ({
|
||||
visits,
|
||||
selectedVisits = [],
|
||||
setSelectedVisits,
|
||||
matchMedia = window.matchMedia,
|
||||
isOrphanVisits = false,
|
||||
}: VisitsTableProps) => {
|
||||
const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
|
||||
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
const [isMobileDevice, setIsMobileDevice] = useState(matchMobile());
|
||||
const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined);
|
||||
const [order, setOrder] = useState<VisitsOrder>({});
|
||||
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [searchTerm, order]);
|
||||
const isFirstLoad = useRef(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const end = page * PAGE_SIZE;
|
||||
const start = end - PAGE_SIZE;
|
||||
const fullSizeColSpan = 8 + Number(isOrphanVisits);
|
||||
|
||||
const orderByColumn = (field: OrderableFields) =>
|
||||
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
||||
const renderOrderIcon = (field: OrderableFields) =>
|
||||
<TableOrderIcon currentOrder={order} field={field} className="visits-table__header-icon" />;
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setIsMobileDevice(matchMobile());
|
||||
|
||||
window.addEventListener('resize', listener);
|
||||
|
||||
return () => window.removeEventListener('resize', listener);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
|
||||
!isFirstLoad.current && setSelectedVisits([]);
|
||||
isFirstLoad.current = false;
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="table-responsive-md">
|
||||
<table className="table table-bordered table-hover table-sm visits-table">
|
||||
<thead className="visits-table__header">
|
||||
<tr>
|
||||
<th
|
||||
className={`${headerCellsClass} text-center`}
|
||||
onClick={() => setSelectedVisits(
|
||||
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
|
||||
</th>
|
||||
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
|
||||
<FontAwesomeIcon icon={botIcon} />
|
||||
{renderOrderIcon('potentialBot')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('date')}>
|
||||
Date
|
||||
{renderOrderIcon('date')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('country')}>
|
||||
Country
|
||||
{renderOrderIcon('country')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('city')}>
|
||||
City
|
||||
{renderOrderIcon('city')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
|
||||
Browser
|
||||
{renderOrderIcon('browser')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('os')}>
|
||||
OS
|
||||
{renderOrderIcon('os')}
|
||||
</th>
|
||||
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
|
||||
Referrer
|
||||
{renderOrderIcon('referer')}
|
||||
</th>
|
||||
{isOrphanVisits && (
|
||||
<th className={headerCellsClass} onClick={orderByColumn('visitedUrl')}>
|
||||
Visited URL
|
||||
{renderOrderIcon('visitedUrl')}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={fullSizeColSpan} className="p-0">
|
||||
<SearchField noBorder large={false} onChange={setSearchTerm} />
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!resultSet.visitsGroups[page - 1]?.length && (
|
||||
<tr>
|
||||
<td colSpan={fullSizeColSpan} className="text-center">
|
||||
No visits found with current filtering
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{resultSet.visitsGroups[page - 1]?.map((visit, index) => {
|
||||
const isSelected = selectedVisits.includes(visit);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className={classNames({ 'table-active': isSelected })}
|
||||
onClick={() => setSelectedVisits(
|
||||
isSelected ? selectedVisits.filter((v) => v !== visit) : [...selectedVisits, visit],
|
||||
)}
|
||||
>
|
||||
<td className="text-center">
|
||||
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{visit.potentialBot && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
|
||||
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
|
||||
Potentially a visit from a bot or crawler
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td><Time date={visit.date} /></td>
|
||||
<td>{visit.country}</td>
|
||||
<td>{visit.city}</td>
|
||||
<td>{visit.browser}</td>
|
||||
<td>{visit.os}</td>
|
||||
<td>{visit.referer}</td>
|
||||
{isOrphanVisits && <td>{(visit as NormalizedOrphanVisit).visitedUrl}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{resultSet.total > PAGE_SIZE && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={fullSizeColSpan} className="visits-table__footer-cell visits-table__sticky">
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<SimplePaginator
|
||||
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)}
|
||||
currentPage={page}
|
||||
setCurrentPage={setPage}
|
||||
centered={isMobileDevice}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames('col-md-6', {
|
||||
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
|
||||
'text-center mt-3': isMobileDevice,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
Visits <b>{prettify(start + 1)}</b> to{' '}
|
||||
<b>{prettify(min(end, resultSet.total))}</b> of{' '}
|
||||
<b>{prettify(resultSet.total)}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
shlink-web-component/visits/charts/ChartCard.scss
Normal file
4
shlink-web-component/visits/charts/ChartCard.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.chart-card__footer--sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
16
shlink-web-component/visits/charts/ChartCard.tsx
Normal file
16
shlink-web-component/visits/charts/ChartCard.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Card, CardBody, CardFooter, CardHeader } from 'reactstrap';
|
||||
import './ChartCard.scss';
|
||||
|
||||
type ChartCardProps = PropsWithChildren<{
|
||||
title: Function | string;
|
||||
footer?: ReactNode;
|
||||
}>;
|
||||
|
||||
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
|
||||
<Card role="document">
|
||||
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>{children}</CardBody>
|
||||
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
73
shlink-web-component/visits/charts/DoughnutChart.tsx
Normal file
73
shlink-web-component/visits/charts/DoughnutChart.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||
import { keys, values } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { renderPieChartLabel } from '../../../src/utils/helpers/charts';
|
||||
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../../src/utils/theme';
|
||||
import type { Stats } from '../types';
|
||||
import { DoughnutChartLegend } from './DoughnutChartLegend';
|
||||
|
||||
interface DoughnutChartProps {
|
||||
stats: Stats;
|
||||
}
|
||||
|
||||
const generateChartDatasets = (data: number[]): ChartDataset[] => [
|
||||
{
|
||||
data,
|
||||
backgroundColor: [
|
||||
'#97BBCD',
|
||||
'#F7464A',
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#57A773',
|
||||
'#414066',
|
||||
'#08B2E3',
|
||||
'#B6C454',
|
||||
'#DCDCDC',
|
||||
'#463730',
|
||||
],
|
||||
borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
|
||||
borderWidth: 2,
|
||||
},
|
||||
];
|
||||
const generateChartData = (labels: string[], data: number[]): ChartData => ({
|
||||
labels,
|
||||
datasets: generateChartDatasets(data),
|
||||
});
|
||||
|
||||
export const DoughnutChart: FC<DoughnutChartProps> = memo(({ stats }) => {
|
||||
const [chartRef, setChartRef] = useState<Chart | undefined>(); // Cannot use useRef here
|
||||
const labels = keys(stats);
|
||||
const data = values(stats);
|
||||
|
||||
const options: ChartOptions = {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
intersect: true,
|
||||
callbacks: { label: renderPieChartLabel },
|
||||
},
|
||||
},
|
||||
};
|
||||
const chartData = generateChartData(labels, data);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12 col-md-7">
|
||||
<Doughnut
|
||||
height={300}
|
||||
data={chartData as any}
|
||||
options={options as any}
|
||||
ref={(element) => {
|
||||
setChartRef(element ?? undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-12 col-md-5">
|
||||
{chartRef && <DoughnutChartLegend chart={chartRef} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
15
shlink-web-component/visits/charts/DoughnutChartCard.tsx
Normal file
15
shlink-web-component/visits/charts/DoughnutChartCard.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FC } from 'react';
|
||||
import type { Stats } from '../types';
|
||||
import { ChartCard } from './ChartCard';
|
||||
import { DoughnutChart } from './DoughnutChart';
|
||||
|
||||
interface DoughnutChartCardProps {
|
||||
title: string;
|
||||
stats: Stats;
|
||||
}
|
||||
|
||||
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
|
||||
<ChartCard title={title}>
|
||||
<DoughnutChart stats={stats} />
|
||||
</ChartCard>
|
||||
);
|
||||
29
shlink-web-component/visits/charts/DoughnutChartLegend.scss
Normal file
29
shlink-web-component/visits/charts/DoughnutChartLegend.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@import '../../../src/utils/base';
|
||||
|
||||
.doughnut-chart-legend {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.doughnut-chart-legend__item:not(:first-child) {
|
||||
margin-top: .3rem;
|
||||
}
|
||||
|
||||
.doughnut-chart-legend__item-color {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.doughnut-chart-legend__item-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
28
shlink-web-component/visits/charts/DoughnutChartLegend.tsx
Normal file
28
shlink-web-component/visits/charts/DoughnutChartLegend.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Chart } from 'chart.js';
|
||||
import type { FC } from 'react';
|
||||
import './DoughnutChartLegend.scss';
|
||||
|
||||
interface DoughnutChartLegendProps {
|
||||
chart: Chart;
|
||||
}
|
||||
|
||||
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
|
||||
const { config } = chart;
|
||||
const { labels = [], datasets = [] } = config.data ?? {};
|
||||
const [{ backgroundColor: colors }] = datasets;
|
||||
const { defaultColor } = config.options ?? {} as any;
|
||||
|
||||
return (
|
||||
<ul className="doughnut-chart-legend">
|
||||
{(labels as string[]).map((label, index) => (
|
||||
<li key={label} className="doughnut-chart-legend__item d-flex">
|
||||
<div
|
||||
className="doughnut-chart-legend__item-color"
|
||||
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
|
||||
/>
|
||||
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
134
shlink-web-component/visits/charts/HorizontalBarChart.tsx
Normal file
134
shlink-web-component/visits/charts/HorizontalBarChart.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
|
||||
import { keys, values } from 'ramda';
|
||||
import type { FC, MutableRefObject } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Bar, getElementAtEvent } from 'react-chartjs-2';
|
||||
import { pointerOnHover, renderChartLabel } from '../../../src/utils/helpers/charts';
|
||||
import { prettify } from '../../../src/utils/helpers/numbers';
|
||||
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../../src/utils/theme';
|
||||
import type { Stats } from '../types';
|
||||
import { fillTheGaps } from '../utils';
|
||||
|
||||
export interface HorizontalBarChartProps {
|
||||
stats: Stats;
|
||||
max?: number;
|
||||
highlightedStats?: Stats;
|
||||
highlightedLabel?: string;
|
||||
onClick?: (label: string) => void;
|
||||
}
|
||||
|
||||
const dropLabelIfHidden = (label: string) => (label.startsWith('hidden') ? '' : label);
|
||||
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
|
||||
const determineHeight = (labels: string[]): number | undefined => (labels.length > 20 ? labels.length * 10 : undefined);
|
||||
|
||||
const generateChartDatasets = (
|
||||
data: number[],
|
||||
highlightedData: number[],
|
||||
highlightedLabel?: string,
|
||||
): ChartDataset[] => {
|
||||
const mainDataset: ChartDataset = {
|
||||
data,
|
||||
label: highlightedLabel ? 'Non-selected' : 'Visits',
|
||||
backgroundColor: MAIN_COLOR_ALPHA,
|
||||
borderColor: MAIN_COLOR,
|
||||
borderWidth: 2,
|
||||
};
|
||||
|
||||
if (highlightedData.every((value) => value === 0)) {
|
||||
return [mainDataset];
|
||||
}
|
||||
|
||||
const highlightedDataset: ChartDataset = {
|
||||
label: highlightedLabel ?? 'Selected',
|
||||
data: highlightedData,
|
||||
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
|
||||
borderColor: HIGHLIGHTED_COLOR,
|
||||
borderWidth: 2,
|
||||
};
|
||||
|
||||
return [mainDataset, highlightedDataset];
|
||||
};
|
||||
const generateChartData = (
|
||||
labels: string[],
|
||||
data: number[],
|
||||
highlightedData: number[],
|
||||
highlightedLabel?: string,
|
||||
): ChartData => ({
|
||||
labels,
|
||||
datasets: generateChartDatasets(data, highlightedData, highlightedLabel),
|
||||
});
|
||||
|
||||
const chartElementAtEvent = (labels: string[], [chart]: InteractionItem[], onClick?: (label: string) => void) => {
|
||||
if (!onClick || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick(labels[chart.index]);
|
||||
};
|
||||
|
||||
export const HorizontalBarChart: FC<HorizontalBarChartProps> = (
|
||||
{ stats, highlightedStats, highlightedLabel, onClick, max },
|
||||
) => {
|
||||
const labels = keys(stats).map(dropLabelIfHidden);
|
||||
const data = values(
|
||||
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
|
||||
if (acc[highlightedKey]) {
|
||||
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { ...stats }),
|
||||
);
|
||||
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
|
||||
const refWithStats = useRef(null);
|
||||
const refWithoutStats = useRef(null);
|
||||
|
||||
const options: ChartOptions = {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'y',
|
||||
// Do not show tooltip on items with empty label when in a bar chart
|
||||
filter: ({ label }) => label !== '',
|
||||
callbacks: { label: renderChartLabel },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
stacked: true,
|
||||
max,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
},
|
||||
},
|
||||
y: { stacked: true },
|
||||
},
|
||||
onHover: pointerOnHover,
|
||||
indexAxis: 'y',
|
||||
};
|
||||
const chartData = generateChartData(labels, data, highlightedData, highlightedLabel);
|
||||
const height = determineHeight(labels);
|
||||
|
||||
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
|
||||
const renderChartComponent = (customKey: string, theRef: MutableRefObject<any>) => (
|
||||
<Bar
|
||||
ref={theRef}
|
||||
key={`${height}_${customKey}`}
|
||||
data={chartData as any}
|
||||
options={options as any}
|
||||
height={height}
|
||||
onClick={(e) => chartElementAtEvent(labels, getElementAtEvent(theRef.current, e), onClick)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||
{highlightedStats !== undefined && renderChartComponent('with_stats', refWithStats)}
|
||||
{highlightedStats === undefined && renderChartComponent('without_stats', refWithoutStats)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
9
shlink-web-component/visits/charts/LineChartCard.scss
Normal file
9
shlink-web-component/visits/charts/LineChartCard.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@import '../../../src/utils/base';
|
||||
|
||||
.line-chart-card__body canvas {
|
||||
height: 300px !important;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
height: 400px !important;
|
||||
}
|
||||
}
|
||||
270
shlink-web-component/visits/charts/LineChartCard.tsx
Normal file
270
shlink-web-component/visits/charts/LineChartCard.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
|
||||
import {
|
||||
add,
|
||||
differenceInDays,
|
||||
differenceInHours,
|
||||
differenceInMonths,
|
||||
differenceInWeeks,
|
||||
endOfISOWeek,
|
||||
format,
|
||||
parseISO,
|
||||
startOfISOWeek,
|
||||
} from 'date-fns';
|
||||
import { always, cond, countBy, reverse } from 'ramda';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { getElementAtEvent, Line } from 'react-chartjs-2';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
UncontrolledDropdown,
|
||||
} from 'reactstrap';
|
||||
import { pointerOnHover, renderChartLabel } from '../../../src/utils/helpers/charts';
|
||||
import { STANDARD_DATE_FORMAT } from '../../../src/utils/helpers/date';
|
||||
import { useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import { prettify } from '../../../src/utils/helpers/numbers';
|
||||
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../../src/utils/theme';
|
||||
import { ToggleSwitch } from '../../../src/utils/ToggleSwitch';
|
||||
import { rangeOf } from '../../../src/utils/utils';
|
||||
import type { NormalizedVisit, Stats } from '../types';
|
||||
import { fillTheGaps } from '../utils';
|
||||
import './LineChartCard.scss';
|
||||
|
||||
interface LineChartCardProps {
|
||||
title: string;
|
||||
highlightedLabel?: string;
|
||||
visits: NormalizedVisit[];
|
||||
highlightedVisits: NormalizedVisit[];
|
||||
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
|
||||
}
|
||||
|
||||
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
|
||||
|
||||
const STEPS_MAP: Record<Step, string> = {
|
||||
monthly: 'Month',
|
||||
weekly: 'Week',
|
||||
daily: 'Day',
|
||||
hourly: 'Hour',
|
||||
};
|
||||
|
||||
const STEP_TO_DURATION_MAP: Record<Step, (amount: number) => Duration> = {
|
||||
hourly: (hours: number) => ({ hours }),
|
||||
daily: (days: number) => ({ days }),
|
||||
weekly: (weeks: number) => ({ weeks }),
|
||||
monthly: (months: number) => ({ months }),
|
||||
};
|
||||
|
||||
const STEP_TO_DIFF_FUNC_MAP: Record<Step, (dateLeft: Date, dateRight: Date) => number> = {
|
||||
hourly: differenceInHours,
|
||||
daily: differenceInDays,
|
||||
weekly: differenceInWeeks,
|
||||
monthly: differenceInMonths,
|
||||
};
|
||||
|
||||
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
|
||||
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
|
||||
daily: (date) => format(date, STANDARD_DATE_FORMAT),
|
||||
weekly(date) {
|
||||
const firstWeekDay = format(startOfISOWeek(date), STANDARD_DATE_FORMAT);
|
||||
const lastWeekDay = format(endOfISOWeek(date), STANDARD_DATE_FORMAT);
|
||||
|
||||
return `${firstWeekDay} - ${lastWeekDay}`;
|
||||
},
|
||||
monthly: (date) => format(date, 'yyyy-MM'),
|
||||
};
|
||||
|
||||
const determineInitialStep = (oldestVisitDate: string): Step => {
|
||||
const now = new Date();
|
||||
const oldestDate = parseISO(oldestVisitDate);
|
||||
const matcher = cond<never, Step | undefined>([
|
||||
[() => differenceInDays(now, oldestDate) <= 2, always<Step>('hourly')], // Less than 2 days
|
||||
[() => differenceInMonths(now, oldestDate) <= 1, always<Step>('daily')], // Between 2 days and 1 month
|
||||
[() => differenceInMonths(now, oldestDate) <= 6, always<Step>('weekly')], // Between 1 and 6 months
|
||||
]);
|
||||
|
||||
return matcher() ?? 'monthly';
|
||||
};
|
||||
|
||||
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
|
||||
(visit) => STEP_TO_DATE_FORMAT[step](parseISO(visit.date)),
|
||||
visits,
|
||||
);
|
||||
|
||||
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
|
||||
visits.reduce<Record<string, NormalizedVisit[]>>(
|
||||
(acc, visit) => {
|
||||
const key = STEP_TO_DATE_FORMAT[step](parseISO(visit.date));
|
||||
|
||||
acc[key] = acc[key] ?? [];
|
||||
acc[key].push(visit);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
|
||||
const diffFunc = STEP_TO_DIFF_FUNC_MAP[step];
|
||||
const formatter = STEP_TO_DATE_FORMAT[step];
|
||||
const newerDate = parseISO(visits[0].date);
|
||||
const oldestDate = parseISO(visits[visits.length - 1].date);
|
||||
const size = diffFunc(newerDate, oldestDate);
|
||||
const duration = STEP_TO_DURATION_MAP[step];
|
||||
|
||||
return [
|
||||
formatter(oldestDate),
|
||||
...rangeOf(size, (num) => formatter(add(oldestDate, duration(num)))),
|
||||
];
|
||||
};
|
||||
|
||||
const generateLabelsAndGroupedVisits = (
|
||||
visits: NormalizedVisit[],
|
||||
groupedVisitsWithGaps: Stats,
|
||||
step: Step,
|
||||
skipNoElements: boolean,
|
||||
): [string[], number[]] => {
|
||||
if (skipNoElements) {
|
||||
return [Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps)];
|
||||
}
|
||||
|
||||
const labels = generateLabels(step, visits);
|
||||
|
||||
return [labels, fillTheGaps(groupedVisitsWithGaps, labels)];
|
||||
};
|
||||
|
||||
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
|
||||
label,
|
||||
data,
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
});
|
||||
|
||||
let selectedLabel: string | null = null;
|
||||
|
||||
const chartElementAtEvent = (
|
||||
labels: string[],
|
||||
datasetsByPoint: Record<string, NormalizedVisit[]>,
|
||||
[chart]: InteractionItem[],
|
||||
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
|
||||
) => {
|
||||
if (!setSelectedVisits || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { index } = chart;
|
||||
|
||||
if (selectedLabel === labels[index]) {
|
||||
setSelectedVisits([]);
|
||||
selectedLabel = null;
|
||||
} else {
|
||||
setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] ? datasetsByPoint[labels[index]] : []);
|
||||
selectedLabel = labels[index] ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
export const LineChartCard = (
|
||||
{ title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps,
|
||||
) => {
|
||||
const [step, setStep] = useState<Step>(
|
||||
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
|
||||
);
|
||||
const [skipNoVisits, toggleSkipNoVisits] = useToggle(true);
|
||||
const refWithHighlightedVisits = useRef(null);
|
||||
const refWithoutHighlightedVisits = useRef(null);
|
||||
|
||||
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [step, visits]);
|
||||
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [step, visits]);
|
||||
const [labels, groupedVisits] = useMemo(
|
||||
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
|
||||
[visits, step, skipNoVisits],
|
||||
);
|
||||
const groupedHighlighted = useMemo(
|
||||
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
||||
[highlightedVisits, step, labels],
|
||||
);
|
||||
const generateChartDatasets = (): ChartDataset[] => {
|
||||
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
|
||||
|
||||
if (highlightedVisits.length === 0) {
|
||||
return [mainDataset];
|
||||
}
|
||||
|
||||
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
|
||||
|
||||
return [mainDataset, highlightedDataset];
|
||||
};
|
||||
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
|
||||
|
||||
const options: ChartOptions = {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
intersect: false,
|
||||
axis: 'x',
|
||||
callbacks: { label: renderChartLabel },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: STEPS_MAP[step] },
|
||||
},
|
||||
},
|
||||
onHover: pointerOnHover,
|
||||
};
|
||||
const renderLineChart = (theRef: MutableRefObject<any>) => (
|
||||
<Line
|
||||
ref={theRef}
|
||||
data={generateChartData() as any}
|
||||
options={options as any}
|
||||
onClick={(e) =>
|
||||
chartElementAtEvent(labels, datasetsByPoint, getElementAtEvent(theRef.current, e), setSelectedVisits)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader role="heading">
|
||||
{title}
|
||||
<div className="float-end">
|
||||
<UncontrolledDropdown>
|
||||
<DropdownToggle caret color="link" className="btn-sm p-0">
|
||||
Group by
|
||||
</DropdownToggle>
|
||||
<DropdownMenu end>
|
||||
{Object.entries(STEPS_MAP).map(([value, menuText]) => (
|
||||
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
|
||||
{menuText}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
</div>
|
||||
<div className="float-end me-2">
|
||||
<ToggleSwitch checked={skipNoVisits} onChange={toggleSkipNoVisits}>
|
||||
<small>Skip dates with no visits</small>
|
||||
</ToggleSwitch>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="line-chart-card__body">
|
||||
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||
{highlightedVisits.length > 0 && renderLineChart(refWithHighlightedVisits)}
|
||||
{highlightedVisits.length === 0 && renderLineChart(refWithoutHighlightedVisits)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
141
shlink-web-component/visits/charts/SortableBarChartCard.tsx
Normal file
141
shlink-web-component/visits/charts/SortableBarChartCard.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { SimplePaginator } from '../../../src/common/SimplePaginator';
|
||||
import { roundTen } from '../../../src/utils/helpers/numbers';
|
||||
import type { Order } from '../../../src/utils/helpers/ordering';
|
||||
import { OrderingDropdown } from '../../../src/utils/OrderingDropdown';
|
||||
import { PaginationDropdown } from '../../../src/utils/PaginationDropdown';
|
||||
import { rangeOf } from '../../../src/utils/utils';
|
||||
import type { Stats, StatsRow } from '../types';
|
||||
import { ChartCard } from './ChartCard';
|
||||
import type { HorizontalBarChartProps } from './HorizontalBarChart';
|
||||
import { HorizontalBarChart } from './HorizontalBarChart';
|
||||
|
||||
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
|
||||
title: Function | string;
|
||||
sortingItems: Record<string, string>;
|
||||
withPagination?: boolean;
|
||||
extraHeaderContent?: (activeCities?: string[]) => ReactNode;
|
||||
}
|
||||
|
||||
const toLowerIfString = (value: any) => (type(value) === 'String' ? toLower(value) : value);
|
||||
const pickKeyFromPair = ([key]: StatsRow) => key;
|
||||
const pickValueFromPair = ([, value]: StatsRow) => value;
|
||||
|
||||
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||
stats,
|
||||
highlightedStats,
|
||||
title,
|
||||
sortingItems,
|
||||
extraHeaderContent,
|
||||
withPagination = true,
|
||||
...rest
|
||||
}) => {
|
||||
const [order, setOrder] = useState<Order<string>>({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(50);
|
||||
|
||||
const getSortedPairsForStats = (statsToSort: Stats, sorting: Record<string, string>) => {
|
||||
const pairs = toPairs(statsToSort);
|
||||
const sortedPairs = !order.field ? pairs : sortBy(
|
||||
pipe<StatsRow[], string | number, string | number>(
|
||||
order.field === Object.keys(sorting)[0] ? pickKeyFromPair : pickValueFromPair,
|
||||
toLowerIfString,
|
||||
),
|
||||
pairs,
|
||||
);
|
||||
|
||||
return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
};
|
||||
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
|
||||
const page = pages[currentPage - 1];
|
||||
|
||||
if (currentPage < pages.length) {
|
||||
return page;
|
||||
}
|
||||
|
||||
const firstPageLength = pages[0].length;
|
||||
|
||||
// Using the "hidden" key, the chart will just replace the label by an empty string
|
||||
return [...page, ...rangeOf(firstPageLength - page.length, (i): StatsRow => [`hidden_${i}`, 0])];
|
||||
};
|
||||
const renderPagination = (pagesCount: number) =>
|
||||
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
|
||||
const determineStats = (statsToSort: Stats, sorting: Record<string, string>, theHighlightedStats?: Stats) => {
|
||||
const sortedPairs = getSortedPairsForStats(statsToSort, sorting);
|
||||
const sortedKeys = sortedPairs.map(pickKeyFromPair);
|
||||
// The highlighted stats have to be ordered based on the regular stats, not on its own values
|
||||
const sortedHighlightedPairs = theHighlightedStats && toPairs(
|
||||
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...theHighlightedStats },
|
||||
);
|
||||
|
||||
if (sortedPairs.length <= itemsPerPage) {
|
||||
return {
|
||||
currentPageStats: fromPairs(sortedPairs),
|
||||
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
|
||||
};
|
||||
}
|
||||
|
||||
const pages = splitEvery(itemsPerPage, sortedPairs);
|
||||
const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
|
||||
|
||||
return {
|
||||
currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
|
||||
currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
|
||||
pagination: renderPagination(pages.length),
|
||||
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
|
||||
};
|
||||
};
|
||||
|
||||
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
|
||||
stats,
|
||||
sortingItems,
|
||||
highlightedStats && Object.keys(highlightedStats).length > 0 ? highlightedStats : undefined,
|
||||
);
|
||||
const activeCities = Object.keys(currentPageStats);
|
||||
const computeTitle = () => (
|
||||
<>
|
||||
{title}
|
||||
<div className="float-end">
|
||||
<OrderingDropdown
|
||||
isButton={false}
|
||||
right
|
||||
items={sortingItems}
|
||||
order={order}
|
||||
onChange={(field, dir) => {
|
||||
setOrder({ field, dir });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{withPagination && Object.keys(stats).length > 50 && (
|
||||
<div className="float-end">
|
||||
<PaginationDropdown
|
||||
toggleClassName="btn-sm p-0 me-3"
|
||||
ranges={[50, 100, 200, 500]}
|
||||
value={itemsPerPage}
|
||||
setValue={(value) => {
|
||||
setItemsPerPage(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{extraHeaderContent && (
|
||||
<div className="float-end">
|
||||
{extraHeaderContent(pagination ? activeCities : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={computeTitle}
|
||||
footer={pagination}
|
||||
>
|
||||
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
45
shlink-web-component/visits/helpers/MapModal.scss
Normal file
45
shlink-web-component/visits/helpers/MapModal.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
@import '../../../src/utils/base';
|
||||
@import '../../../src/utils/mixins/fit-with-margin';
|
||||
|
||||
.map-modal__modal.map-modal__modal {
|
||||
@media (min-width: $mdMin) {
|
||||
$margin: 20px;
|
||||
|
||||
@include fit-with-margin($margin);
|
||||
}
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
$margin: 10px;
|
||||
|
||||
@include fit-with-margin($margin);
|
||||
}
|
||||
}
|
||||
|
||||
.map-modal__modal-content.map-modal__modal-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal__modal-title.map-modal__modal-title {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 1001;
|
||||
padding: .5rem 1rem 1rem;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
background: linear-gradient(rgba(0, 0, 0, .5), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
.map-modal__modal-body.map-modal__modal-body {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-modal__modal.map-modal__modal .leaflet-container.leaflet-container {
|
||||
flex: 1 1 auto;
|
||||
border-radius: .3rem;
|
||||
}
|
||||
|
||||
.map-modal__modal.map-modal__modal .leaflet-top.leaflet-top .leaflet-control.leaflet-control {
|
||||
margin-top: 60px;
|
||||
}
|
||||
56
shlink-web-component/visits/helpers/MapModal.tsx
Normal file
56
shlink-web-component/visits/helpers/MapModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { prop } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import type { MapContainerProps } from 'react-leaflet';
|
||||
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
|
||||
import { Modal, ModalBody } from 'reactstrap';
|
||||
import type { CityStats } from '../types';
|
||||
import './MapModal.scss';
|
||||
|
||||
interface MapModalProps {
|
||||
toggle: () => void;
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
locations?: CityStats[];
|
||||
}
|
||||
|
||||
const OpenStreetMapTile: FC = () => (
|
||||
<TileLayer
|
||||
attribution='&copy <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
);
|
||||
|
||||
const calculateMapProps = (locations: CityStats[]): MapContainerProps => {
|
||||
if (locations.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (locations.length > 1) {
|
||||
return { bounds: locations.map(prop('latLong')) };
|
||||
}
|
||||
|
||||
// When there's only one location, an error is thrown if trying to calculate the bounds.
|
||||
// When that happens, we use "zoom" and "center" as a workaround
|
||||
const [{ latLong: center }] = locations;
|
||||
|
||||
return { zoom: 10, center };
|
||||
};
|
||||
|
||||
export const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => (
|
||||
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
|
||||
<ModalBody className="map-modal__modal-body">
|
||||
<h3 className="map-modal__modal-title">
|
||||
{title}
|
||||
<button type="button" className="btn-close float-end" aria-label="Close" onClick={toggle} />
|
||||
</h3>
|
||||
<MapContainer {...calculateMapProps(locations)}>
|
||||
<OpenStreetMapTile />
|
||||
{locations.map(({ cityName, latLong, count }, index) => (
|
||||
<Marker key={index} position={latLong}>
|
||||
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{cityName}</b></Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
4
shlink-web-component/visits/helpers/OpenMapModalBtn.scss
Normal file
4
shlink-web-component/visits/helpers/OpenMapModalBtn.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.open-map-modal-btn__btn.open-map-modal-btn__btn {
|
||||
padding: 0;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
55
shlink-web-component/visits/helpers/OpenMapModalBtn.tsx
Normal file
55
shlink-web-component/visits/helpers/OpenMapModalBtn.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useState } from 'react';
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
|
||||
import { useDomId, useToggle } from '../../../src/utils/helpers/hooks';
|
||||
import type { CityStats } from '../types';
|
||||
import { MapModal } from './MapModal';
|
||||
import './OpenMapModalBtn.scss';
|
||||
|
||||
interface OpenMapModalBtnProps {
|
||||
modalTitle: string;
|
||||
activeCities?: string[];
|
||||
locations?: CityStats[];
|
||||
}
|
||||
|
||||
export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapModalBtnProps) => {
|
||||
const [mapIsOpened, , openMap, closeMap] = useToggle();
|
||||
const [dropdownIsOpened, toggleDropdown, openDropdown] = useToggle();
|
||||
const [locationsToShow, setLocationsToShow] = useState<CityStats[]>([]);
|
||||
const id = useDomId();
|
||||
|
||||
const filterLocations = (cities: CityStats[]) => (
|
||||
!activeCities ? cities : cities.filter(({ cityName }) => activeCities?.includes(cityName))
|
||||
);
|
||||
const onClick = () => {
|
||||
if (!activeCities) {
|
||||
setLocationsToShow(locations);
|
||||
openMap();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openDropdown();
|
||||
};
|
||||
const openMapWithLocations = (filtered: boolean) => () => {
|
||||
setLocationsToShow(filtered ? filterLocations(locations) : locations);
|
||||
openMap();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="link" className="open-map-modal-btn__btn" id={id} onClick={onClick}>
|
||||
<FontAwesomeIcon icon={mapIcon} />
|
||||
</Button>
|
||||
<UncontrolledTooltip placement="left" target={id}>Show in map</UncontrolledTooltip>
|
||||
<Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown} inNavbar>
|
||||
<DropdownMenu end>
|
||||
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
|
||||
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
shlink-web-component/visits/helpers/VisitsFilterDropdown.tsx
Normal file
48
shlink-web-component/visits/helpers/VisitsFilterDropdown.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { DropdownItemProps } from 'reactstrap';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { DropdownBtn } from '../../../src/utils/DropdownBtn';
|
||||
import { hasValue } from '../../../src/utils/utils';
|
||||
import type { OrphanVisitType, VisitsFilter } from '../types';
|
||||
|
||||
interface VisitsFilterDropdownProps {
|
||||
onChange: (filters: VisitsFilter) => void;
|
||||
selected?: VisitsFilter;
|
||||
className?: string;
|
||||
isOrphanVisits: boolean;
|
||||
}
|
||||
|
||||
export const VisitsFilterDropdown = (
|
||||
{ onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps,
|
||||
) => {
|
||||
const { orphanVisitsType, excludeBots = false } = selected;
|
||||
const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({
|
||||
active: orphanVisitsType === type,
|
||||
onClick: () => onChange({ ...selected, orphanVisitsType: type === selected?.orphanVisitsType ? undefined : type }),
|
||||
});
|
||||
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
|
||||
|
||||
return (
|
||||
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
|
||||
<DropdownItem header>Bots:</DropdownItem>
|
||||
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
|
||||
|
||||
{isOrphanVisits && (
|
||||
<>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem header>Orphan visits type:</DropdownItem>
|
||||
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
|
||||
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>
|
||||
<DropdownItem {...propsForOrphanVisitsTypeItem('regular_404')}>Regular 404</DropdownItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownItem divider />
|
||||
<DropdownItem
|
||||
disabled={!hasValue(selected)}
|
||||
onClick={() => onChange({ excludeBots: false, orphanVisitsType: undefined })}
|
||||
>
|
||||
<i>Clear filters</i>
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
||||
};
|
||||
67
shlink-web-component/visits/helpers/hooks.ts
Normal file
67
shlink-web-component/visits/helpers/hooks.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { DeepPartial } from '@reduxjs/toolkit';
|
||||
import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatIsoDate } from '../../../src/utils/helpers/date';
|
||||
import type { DateRange } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { datesToDateRange } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
|
||||
import type { BooleanString } from '../../../src/utils/utils';
|
||||
import { parseBooleanToString } from '../../../src/utils/utils';
|
||||
import type { OrphanVisitType, VisitsFilter } from '../types';
|
||||
|
||||
interface VisitsQuery {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orphanVisitsType?: OrphanVisitType;
|
||||
excludeBots?: BooleanString;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
interface VisitsFiltering {
|
||||
dateRange?: DateRange;
|
||||
visitsFilter: VisitsFilter;
|
||||
}
|
||||
|
||||
interface VisitsFilteringAndDomain {
|
||||
filtering: VisitsFiltering;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
type UpdateFiltering = (extra: DeepPartial<VisitsFiltering>) => void;
|
||||
|
||||
export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
|
||||
const navigate = useNavigate();
|
||||
const { search } = useLocation();
|
||||
|
||||
const { filtering, domain: theDomain } = useMemo(
|
||||
pipe(
|
||||
() => parseQuery<VisitsQuery>(search),
|
||||
({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({
|
||||
domain,
|
||||
filtering: {
|
||||
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined,
|
||||
visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined },
|
||||
},
|
||||
}),
|
||||
),
|
||||
[search],
|
||||
);
|
||||
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
|
||||
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
|
||||
const { excludeBots, orphanVisitsType } = visitsFilter;
|
||||
const query: VisitsQuery = {
|
||||
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
|
||||
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
|
||||
excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
|
||||
orphanVisitsType,
|
||||
domain: theDomain,
|
||||
};
|
||||
const stringifiedQuery = stringifyQuery(query);
|
||||
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||
|
||||
navigate(queryString, { replace: true, relative: 'route' });
|
||||
};
|
||||
|
||||
return [filtering, updateFiltering];
|
||||
};
|
||||
144
shlink-web-component/visits/reducers/common.ts
Normal file
144
shlink-web-component/visits/reducers/common.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||
import type { ShlinkState } from '../../../src/container/types';
|
||||
import type { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { dateToMatchingInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { createAsyncThunk } from '../../../src/utils/helpers/redux';
|
||||
import type { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api-contract';
|
||||
import { parseApiError } from '../../api-contract/utils';
|
||||
import type { CreateVisit, Visit } from '../types';
|
||||
import type { LoadVisits, VisitsInfo, VisitsLoaded } from './types';
|
||||
import { createNewVisits } from './visitCreation';
|
||||
|
||||
const ITEMS_PER_PAGE = 5000;
|
||||
const PARALLEL_REQUESTS_COUNT = 4;
|
||||
const PARALLEL_STARTING_PAGE = 2;
|
||||
|
||||
const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => currentPage >= pagesCount;
|
||||
const calcProgress = (total: number, current: number): number => (current * 100) / total;
|
||||
|
||||
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
|
||||
type LastVisitLoader = (excludeBots?: boolean) => Promise<Visit | undefined>;
|
||||
|
||||
interface VisitsAsyncThunkOptions<T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded> {
|
||||
typePrefix: string;
|
||||
createLoaders: (params: T) => [VisitsLoader, LastVisitLoader];
|
||||
getExtraFulfilledPayload: (params: T) => Partial<R>;
|
||||
shouldCancel: (getState: () => ShlinkState) => boolean;
|
||||
}
|
||||
|
||||
export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
|
||||
{ typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
|
||||
) => {
|
||||
const progressChanged = createAction<number>(`${typePrefix}/progressChanged`);
|
||||
const large = createAction<void>(`${typePrefix}/large`);
|
||||
const fallbackToInterval = createAction<DateInterval>(`${typePrefix}/fallbackToInterval`);
|
||||
|
||||
const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise<Partial<R>> => {
|
||||
const [visitsLoader, lastVisitLoader] = createLoaders(params);
|
||||
|
||||
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
|
||||
Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten);
|
||||
|
||||
const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise<Visit[]> => {
|
||||
if (shouldCancel(getState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await loadVisitsInParallel(pagesBlocks[index]);
|
||||
|
||||
dispatch(progressChanged(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE)));
|
||||
|
||||
if (index < pagesBlocks.length - 1) {
|
||||
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const loadVisits = async (page = 1) => {
|
||||
const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE);
|
||||
|
||||
// If pagination was not returned, then this is an old shlink version. Just return data
|
||||
if (!pagination || isLastPage(pagination)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// If there are more pages, make requests in blocks of 4
|
||||
const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1);
|
||||
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
|
||||
|
||||
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
|
||||
dispatch(large());
|
||||
}
|
||||
|
||||
return data.concat(await loadPagesBlocks(pagesBlocks));
|
||||
};
|
||||
|
||||
const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader(params.query?.excludeBots)]);
|
||||
|
||||
if (!visits.length && lastVisit) {
|
||||
dispatch(fallbackToInterval(dateToMatchingInterval(lastVisit.date)));
|
||||
}
|
||||
|
||||
return { ...getExtraFulfilledPayload(params), visits };
|
||||
});
|
||||
|
||||
// Enhance the async thunk with extra actions
|
||||
return Object.assign(asyncThunk, { progressChanged, large, fallbackToInterval });
|
||||
};
|
||||
|
||||
export const lastVisitLoaderForLoader = (
|
||||
doIntervalFallback: boolean,
|
||||
loader: (params: ShlinkVisitsParams) => Promise<ShlinkVisits>,
|
||||
): LastVisitLoader => async (excludeBots?: boolean) => (
|
||||
!doIntervalFallback
|
||||
? Promise.resolve(undefined)
|
||||
: loader({ page: 1, itemsPerPage: 1, excludeBots }).then(({ data }) => data[0])
|
||||
);
|
||||
|
||||
interface VisitsReducerOptions<State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>> {
|
||||
name: string;
|
||||
asyncThunkCreator: AT;
|
||||
initialState: State;
|
||||
filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[];
|
||||
}
|
||||
|
||||
export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
|
||||
{ name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions<State, AT>,
|
||||
) => {
|
||||
const { pending, rejected, fulfilled, large, progressChanged, fallbackToInterval } = asyncThunkCreator;
|
||||
const { reducer, actions } = createSlice({
|
||||
name,
|
||||
initialState,
|
||||
reducers: {
|
||||
cancelGetVisits: (state) => ({ ...state, cancelLoad: true }),
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(pending, () => ({ ...initialState, loading: true }));
|
||||
builder.addCase(rejected, (_, { error }) => (
|
||||
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||
));
|
||||
builder.addCase(fulfilled, (state, { payload }) => (
|
||||
{ ...state, ...payload, loading: false, loadingLarge: false, error: false }
|
||||
));
|
||||
|
||||
builder.addCase(large, (state) => ({ ...state, loadingLarge: true }));
|
||||
builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress }));
|
||||
builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => (
|
||||
{ ...state, fallbackInterval }
|
||||
));
|
||||
|
||||
builder.addCase(createNewVisits, (state, { payload }) => {
|
||||
const { visits } = state;
|
||||
// @ts-expect-error TODO Fix type inference
|
||||
const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit);
|
||||
|
||||
return !newVisits.length ? state : { ...state, visits: [...newVisits, ...visits] };
|
||||
});
|
||||
},
|
||||
});
|
||||
const { cancelGetVisits } = actions;
|
||||
|
||||
return { reducer, cancelGetVisits };
|
||||
};
|
||||
59
shlink-web-component/visits/reducers/domainVisits.ts
Normal file
59
shlink-web-component/visits/reducers/domainVisits.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { isBetween } from '../../../src/utils/helpers/date';
|
||||
import type { ShlinkApiClient } from '../../api-contract';
|
||||
import { domainMatches } from '../../short-urls/helpers';
|
||||
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||
import type { LoadVisits, VisitsInfo } from './types';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/domainVisits';
|
||||
|
||||
export const DEFAULT_DOMAIN = 'DEFAULT';
|
||||
|
||||
interface WithDomain {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface DomainVisits extends VisitsInfo, WithDomain {}
|
||||
|
||||
export interface LoadDomainVisits extends LoadVisits, WithDomain {}
|
||||
|
||||
const initialState: DomainVisits = {
|
||||
visits: [],
|
||||
domain: '',
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
export const getDomainVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
|
||||
typePrefix: `${REDUCER_PREFIX}/getDomainVisits`,
|
||||
createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits) => {
|
||||
const { getDomainVisits: getVisits } = apiClient;
|
||||
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
|
||||
domain,
|
||||
{ ...query, page, itemsPerPage },
|
||||
);
|
||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params));
|
||||
|
||||
return [visitsLoader, lastVisitLoader];
|
||||
},
|
||||
getExtraFulfilledPayload: ({ domain, query = {} }: LoadDomainVisits) => ({ domain, query }),
|
||||
shouldCancel: (getState) => getState().domainVisits.cancelLoad,
|
||||
});
|
||||
|
||||
export const domainVisitsReducerCreator = (
|
||||
asyncThunkCreator: ReturnType<typeof getDomainVisits>,
|
||||
) => createVisitsReducer({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
// @ts-expect-error TODO Fix type inference
|
||||
asyncThunkCreator,
|
||||
filterCreatedVisits: ({ domain, query = {} }, createdVisits) => {
|
||||
const { startDate, endDate } = query;
|
||||
return createdVisits.filter(
|
||||
({ shortUrl, visit }) =>
|
||||
shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate),
|
||||
);
|
||||
},
|
||||
});
|
||||
41
shlink-web-component/visits/reducers/nonOrphanVisits.ts
Normal file
41
shlink-web-component/visits/reducers/nonOrphanVisits.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { isBetween } from '../../../src/utils/helpers/date';
|
||||
import type { ShlinkApiClient } from '../../api-contract';
|
||||
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||
import type { VisitsInfo } from './types';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/orphanVisits';
|
||||
|
||||
const initialState: VisitsInfo = {
|
||||
visits: [],
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
export const getNonOrphanVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
|
||||
typePrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`,
|
||||
createLoaders: ({ query = {}, doIntervalFallback = false }) => {
|
||||
const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = apiClient;
|
||||
const visitsLoader = async (page: number, itemsPerPage: number) =>
|
||||
shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage });
|
||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits);
|
||||
|
||||
return [visitsLoader, lastVisitLoader];
|
||||
},
|
||||
getExtraFulfilledPayload: ({ query = {} }) => ({ query }),
|
||||
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
|
||||
});
|
||||
|
||||
export const nonOrphanVisitsReducerCreator = (
|
||||
asyncThunkCreator: ReturnType<typeof getNonOrphanVisits>,
|
||||
) => createVisitsReducer({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
asyncThunkCreator,
|
||||
filterCreatedVisits: ({ query = {} }, createdVisits) => {
|
||||
const { startDate, endDate } = query;
|
||||
return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate));
|
||||
},
|
||||
});
|
||||
53
shlink-web-component/visits/reducers/orphanVisits.ts
Normal file
53
shlink-web-component/visits/reducers/orphanVisits.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { isBetween } from '../../../src/utils/helpers/date';
|
||||
import type { ShlinkApiClient } from '../../api-contract';
|
||||
import type { OrphanVisit, OrphanVisitType } from '../types';
|
||||
import { isOrphanVisit } from '../types/helpers';
|
||||
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||
import type { LoadVisits, VisitsInfo } from './types';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/orphanVisits';
|
||||
|
||||
export interface LoadOrphanVisits extends LoadVisits {
|
||||
orphanVisitsType?: OrphanVisitType;
|
||||
}
|
||||
|
||||
const initialState: VisitsInfo = {
|
||||
visits: [],
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
|
||||
!orphanVisitsType || orphanVisitsType === visit.type;
|
||||
|
||||
export const getOrphanVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
|
||||
typePrefix: `${REDUCER_PREFIX}/getOrphanVisits`,
|
||||
createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits) => {
|
||||
const { getOrphanVisits: getVisits } = apiClient;
|
||||
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage })
|
||||
.then((result) => {
|
||||
const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType));
|
||||
return { ...result, data: visits };
|
||||
});
|
||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits);
|
||||
|
||||
return [visitsLoader, lastVisitLoader];
|
||||
},
|
||||
getExtraFulfilledPayload: ({ query = {} }: LoadOrphanVisits) => ({ query }),
|
||||
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
|
||||
});
|
||||
|
||||
export const orphanVisitsReducerCreator = (
|
||||
asyncThunkCreator: ReturnType<typeof getOrphanVisits>,
|
||||
) => createVisitsReducer({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
asyncThunkCreator,
|
||||
filterCreatedVisits: ({ query = {} }, createdVisits) => {
|
||||
const { startDate, endDate } = query;
|
||||
return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate));
|
||||
},
|
||||
});
|
||||
62
shlink-web-component/visits/reducers/shortUrlVisits.ts
Normal file
62
shlink-web-component/visits/reducers/shortUrlVisits.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { isBetween } from '../../../src/utils/helpers/date';
|
||||
import type { ShlinkApiClient } from '../../api-contract';
|
||||
import type { ShortUrlIdentifier } from '../../short-urls/data';
|
||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
||||
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||
import type { LoadVisits, VisitsInfo } from './types';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/shortUrlVisits';
|
||||
|
||||
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
|
||||
|
||||
export interface LoadShortUrlVisits extends LoadVisits {
|
||||
shortCode: string;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlVisits = {
|
||||
visits: [],
|
||||
shortCode: '',
|
||||
domain: undefined, // Deprecated. Value from query params can be used instead
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
export const getShortUrlVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
|
||||
typePrefix: `${REDUCER_PREFIX}/getShortUrlVisits`,
|
||||
createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits) => {
|
||||
const { getShortUrlVisits: shlinkGetShortUrlVisits } = apiClient;
|
||||
const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits(
|
||||
shortCode,
|
||||
{ ...query, page, itemsPerPage },
|
||||
);
|
||||
const lastVisitLoader = lastVisitLoaderForLoader(
|
||||
doIntervalFallback,
|
||||
async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }),
|
||||
);
|
||||
|
||||
return [visitsLoader, lastVisitLoader];
|
||||
},
|
||||
getExtraFulfilledPayload: ({ shortCode, query = {} }: LoadShortUrlVisits) => (
|
||||
{ shortCode, query, domain: query.domain }
|
||||
),
|
||||
shouldCancel: (getState) => getState().shortUrlVisits.cancelLoad,
|
||||
});
|
||||
|
||||
export const shortUrlVisitsReducerCreator = (
|
||||
asyncThunkCreator: ReturnType<typeof getShortUrlVisits>,
|
||||
) => createVisitsReducer({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
// @ts-expect-error TODO Fix type inference
|
||||
asyncThunkCreator,
|
||||
filterCreatedVisits: ({ shortCode, domain, query = {} }: ShortUrlVisits, createdVisits) => {
|
||||
const { startDate, endDate } = query;
|
||||
return createdVisits.filter(
|
||||
({ shortUrl, visit }) =>
|
||||
shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
|
||||
);
|
||||
},
|
||||
});
|
||||
53
shlink-web-component/visits/reducers/tagVisits.ts
Normal file
53
shlink-web-component/visits/reducers/tagVisits.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { isBetween } from '../../../src/utils/helpers/date';
|
||||
import type { ShlinkApiClient } from '../../api-contract';
|
||||
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||
import type { LoadVisits, VisitsInfo } from './types';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/tagVisits';
|
||||
|
||||
interface WithTag {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface TagVisits extends VisitsInfo, WithTag {}
|
||||
|
||||
export interface LoadTagVisits extends LoadVisits, WithTag {}
|
||||
|
||||
const initialState: TagVisits = {
|
||||
visits: [],
|
||||
tag: '',
|
||||
loading: false,
|
||||
loadingLarge: false,
|
||||
error: false,
|
||||
cancelLoad: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
export const getTagVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
|
||||
typePrefix: `${REDUCER_PREFIX}/getTagVisits`,
|
||||
createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits) => {
|
||||
const { getTagVisits: getVisits } = apiClient;
|
||||
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
|
||||
tag,
|
||||
{ ...query, page, itemsPerPage },
|
||||
);
|
||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params));
|
||||
|
||||
return [visitsLoader, lastVisitLoader];
|
||||
},
|
||||
getExtraFulfilledPayload: ({ tag, query = {} }: LoadTagVisits) => ({ tag, query }),
|
||||
shouldCancel: (getState) => getState().tagVisits.cancelLoad,
|
||||
});
|
||||
|
||||
export const tagVisitsReducerCreator = (asyncThunkCreator: ReturnType<typeof getTagVisits>) => createVisitsReducer({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
// @ts-expect-error TODO Fix type inference
|
||||
asyncThunkCreator,
|
||||
filterCreatedVisits: ({ tag, query = {} }: TagVisits, createdVisits) => {
|
||||
const { startDate, endDate } = query;
|
||||
return createdVisits.filter(
|
||||
({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate),
|
||||
);
|
||||
},
|
||||
});
|
||||
25
shlink-web-component/visits/reducers/types/index.ts
Normal file
25
shlink-web-component/visits/reducers/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { DateInterval } from '../../../../src/utils/helpers/dateIntervals';
|
||||
import type { ProblemDetailsError, ShlinkVisitsParams } from '../../../api-contract';
|
||||
import type { Visit } from '../../types';
|
||||
|
||||
export interface VisitsInfo {
|
||||
visits: Visit[];
|
||||
loading: boolean;
|
||||
loadingLarge: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
progress: number;
|
||||
cancelLoad: boolean;
|
||||
query?: ShlinkVisitsParams;
|
||||
fallbackInterval?: DateInterval;
|
||||
}
|
||||
|
||||
export interface LoadVisits {
|
||||
query?: ShlinkVisitsParams;
|
||||
doIntervalFallback?: boolean;
|
||||
}
|
||||
|
||||
export type VisitsLoaded<T = {}> = T & {
|
||||
visits: Visit[];
|
||||
query?: ShlinkVisitsParams;
|
||||
};
|
||||
12
shlink-web-component/visits/reducers/visitCreation.ts
Normal file
12
shlink-web-component/visits/reducers/visitCreation.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import type { CreateVisit } from '../types';
|
||||
|
||||
export type CreateVisitsAction = PayloadAction<{
|
||||
createdVisits: CreateVisit[];
|
||||
}>;
|
||||
|
||||
export const createNewVisits = createAction(
|
||||
'shlink/visitCreation/createNewVisits',
|
||||
(createdVisits: CreateVisit[]) => ({ payload: { createdVisits } }),
|
||||
);
|
||||
99
shlink-web-component/visits/reducers/visitsOverview.ts
Normal file
99
shlink-web-component/visits/reducers/visitsOverview.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk } from '../../../src/utils/helpers/redux';
|
||||
import type { ShlinkApiClient, ShlinkVisitsOverview } from '../../api-contract';
|
||||
import type { CreateVisit } from '../types';
|
||||
import { groupNewVisitsByType } from '../types/helpers';
|
||||
import { createNewVisits } from './visitCreation';
|
||||
|
||||
const REDUCER_PREFIX = 'shlink/visitsOverview';
|
||||
|
||||
export type PartialVisitsSummary = {
|
||||
total: number;
|
||||
nonBots?: number;
|
||||
bots?: number;
|
||||
};
|
||||
|
||||
export type ParsedVisitsOverview = {
|
||||
nonOrphanVisits: PartialVisitsSummary;
|
||||
orphanVisits: PartialVisitsSummary;
|
||||
};
|
||||
|
||||
export interface VisitsOverview extends ParsedVisitsOverview {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export type GetVisitsOverviewAction = PayloadAction<ShlinkVisitsOverview>;
|
||||
|
||||
const initialState: VisitsOverview = {
|
||||
nonOrphanVisits: {
|
||||
total: 0,
|
||||
},
|
||||
orphanVisits: {
|
||||
total: 0,
|
||||
},
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
const countBots = (visits: CreateVisit[]) => visits.filter(({ visit }) => visit.potentialBot).length;
|
||||
|
||||
export const loadVisitsOverview = (apiClient: ShlinkApiClient) => createAsyncThunk(
|
||||
`${REDUCER_PREFIX}/loadVisitsOverview`,
|
||||
(): Promise<ParsedVisitsOverview> => apiClient.getVisitsOverview().then(
|
||||
({ nonOrphanVisits, visitsCount, orphanVisits, orphanVisitsCount }) => ({
|
||||
nonOrphanVisits: {
|
||||
total: nonOrphanVisits?.total ?? visitsCount,
|
||||
nonBots: nonOrphanVisits?.nonBots,
|
||||
bots: nonOrphanVisits?.bots,
|
||||
},
|
||||
orphanVisits: {
|
||||
total: orphanVisits?.total ?? orphanVisitsCount,
|
||||
nonBots: orphanVisits?.nonBots,
|
||||
bots: orphanVisits?.bots,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const visitsOverviewReducerCreator = (
|
||||
loadVisitsOverviewThunk: ReturnType<typeof loadVisitsOverview>,
|
||||
) => createSlice({
|
||||
name: REDUCER_PREFIX,
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(loadVisitsOverviewThunk.pending, () => ({ ...initialState, loading: true }));
|
||||
builder.addCase(loadVisitsOverviewThunk.rejected, () => ({ ...initialState, error: true }));
|
||||
builder.addCase(loadVisitsOverviewThunk.fulfilled, (_, { payload }) => ({ ...initialState, ...payload }));
|
||||
|
||||
builder.addCase(createNewVisits, ({ nonOrphanVisits, orphanVisits, ...rest }, { payload }) => {
|
||||
const { nonOrphanVisits: newNonOrphanVisits, orphanVisits: newOrphanVisits } = groupNewVisitsByType(
|
||||
payload.createdVisits,
|
||||
);
|
||||
|
||||
const newNonOrphanTotalVisits = newNonOrphanVisits.length;
|
||||
const newNonOrphanBotVisits = countBots(newNonOrphanVisits);
|
||||
const newNonOrphanNonBotVisits = newNonOrphanTotalVisits - newNonOrphanBotVisits;
|
||||
|
||||
const newOrphanTotalVisits = newOrphanVisits.length;
|
||||
const newOrphanBotVisits = countBots(newOrphanVisits);
|
||||
const newOrphanNonBotVisits = newOrphanTotalVisits - newOrphanBotVisits;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
nonOrphanVisits: {
|
||||
total: nonOrphanVisits.total + newNonOrphanTotalVisits,
|
||||
bots: nonOrphanVisits.bots && nonOrphanVisits.bots + newNonOrphanBotVisits,
|
||||
nonBots: nonOrphanVisits.nonBots && nonOrphanVisits.nonBots + newNonOrphanNonBotVisits,
|
||||
},
|
||||
orphanVisits: {
|
||||
total: orphanVisits.total + newOrphanTotalVisits,
|
||||
bots: orphanVisits.bots && orphanVisits.bots + newOrphanBotVisits,
|
||||
nonBots: orphanVisits.nonBots && orphanVisits.nonBots + newOrphanNonBotVisits,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
107
shlink-web-component/visits/services/VisitsParser.ts
Normal file
107
shlink-web-component/visits/services/VisitsParser.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { isNil, map } from 'ramda';
|
||||
import { hasValue } from '../../../src/utils/utils';
|
||||
import type { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
|
||||
import { isNormalizedOrphanVisit, isOrphanVisit } from '../types/helpers';
|
||||
import { extractDomain, parseUserAgent } from '../utils';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
|
||||
!isNil(visit) && hasValue(visit[propertyName]);
|
||||
|
||||
const optionalNumericToNumber = (numeric: string | number | null | undefined): number => {
|
||||
if (typeof numeric === 'number') {
|
||||
return numeric;
|
||||
}
|
||||
|
||||
return numeric ? parseFloat(numeric) : 0;
|
||||
};
|
||||
|
||||
const updateOsStatsForVisit = (osStats: Stats, { os }: NormalizedVisit) => {
|
||||
osStats[os] = (osStats[os] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateBrowsersStatsForVisit = (browsersStats: Stats, { browser }: NormalizedVisit) => {
|
||||
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateReferrersStatsForVisit = (referrersStats: Stats, { referer: domain }: NormalizedVisit) => {
|
||||
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateLocationsStatsForVisit = (propertyName: 'country' | 'city') => (stats: Stats, visit: NormalizedVisit) => {
|
||||
const hasLocationProperty = visitHasProperty(visit, propertyName);
|
||||
const value = hasLocationProperty ? visit[propertyName] : 'Unknown';
|
||||
|
||||
stats[value] = (stats[value] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('country');
|
||||
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('city');
|
||||
|
||||
const updateCitiesForMapForVisit = (citiesForMapStats: Record<string, CityStats>, visit: NormalizedVisit) => {
|
||||
if (!visitHasProperty(visit, 'city') || visit.city === 'Unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { city, latitude, longitude } = visit;
|
||||
const currentCity = citiesForMapStats[city] || {
|
||||
cityName: city,
|
||||
count: 0,
|
||||
latLong: [optionalNumericToNumber(latitude), optionalNumericToNumber(longitude)],
|
||||
};
|
||||
|
||||
currentCity.count += 1;
|
||||
|
||||
citiesForMapStats[city] = currentCity;
|
||||
};
|
||||
|
||||
const updateVisitedUrlsForVisit = (visitedUrlsStats: Stats, visit: NormalizedVisit) => {
|
||||
if (!isNormalizedOrphanVisit(visit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { visitedUrl } = visit;
|
||||
|
||||
visitedUrlsStats[visitedUrl] = (visitedUrlsStats[visitedUrl] || 0) + 1;
|
||||
};
|
||||
|
||||
export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.reduce(
|
||||
(stats: VisitsStats, visit: NormalizedVisit) => {
|
||||
// We mutate the original object because it has a big performance impact when large data sets are processed
|
||||
updateOsStatsForVisit(stats.os, visit);
|
||||
updateBrowsersStatsForVisit(stats.browsers, visit);
|
||||
updateReferrersStatsForVisit(stats.referrers, visit);
|
||||
updateCountriesStatsForVisit(stats.countries, visit);
|
||||
updateCitiesStatsForVisit(stats.cities, visit);
|
||||
updateCitiesForMapForVisit(stats.citiesForMap, visit);
|
||||
updateVisitedUrlsForVisit(stats.visitedUrls, visit);
|
||||
|
||||
return stats;
|
||||
},
|
||||
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {}, visitedUrls: {} },
|
||||
);
|
||||
|
||||
export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
|
||||
const { userAgent, date, referer, visitLocation, potentialBot } = visit;
|
||||
const common = {
|
||||
date,
|
||||
potentialBot,
|
||||
...parseUserAgent(userAgent),
|
||||
referer: extractDomain(referer),
|
||||
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
|
||||
city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
|
||||
latitude: visitLocation?.latitude,
|
||||
longitude: visitLocation?.longitude,
|
||||
};
|
||||
|
||||
if (!isOrphanVisit(visit)) {
|
||||
return common;
|
||||
}
|
||||
|
||||
return { ...common, type: visit.type, visitedUrl: visit.visitedUrl };
|
||||
});
|
||||
|
||||
export interface VisitsParser {
|
||||
processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats;
|
||||
normalizeVisits: (visits: Visit[]) => NormalizedVisit[];
|
||||
}
|
||||
93
shlink-web-component/visits/services/provideServices.ts
Normal file
93
shlink-web-component/visits/services/provideServices.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type Bottle from 'bottlejs';
|
||||
import { prop } from 'ramda';
|
||||
import type { ConnectDecorator } from '../../container';
|
||||
import { DomainVisits } from '../DomainVisits';
|
||||
import { MapModal } from '../helpers/MapModal';
|
||||
import { NonOrphanVisits } from '../NonOrphanVisits';
|
||||
import { OrphanVisits } from '../OrphanVisits';
|
||||
import { domainVisitsReducerCreator, getDomainVisits } from '../reducers/domainVisits';
|
||||
import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits';
|
||||
import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits';
|
||||
import { getShortUrlVisits, shortUrlVisitsReducerCreator } from '../reducers/shortUrlVisits';
|
||||
import { getTagVisits, tagVisitsReducerCreator } from '../reducers/tagVisits';
|
||||
import { createNewVisits } from '../reducers/visitCreation';
|
||||
import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview';
|
||||
import { ShortUrlVisits } from '../ShortUrlVisits';
|
||||
import { TagVisits } from '../TagVisits';
|
||||
import * as visitsParser from './VisitsParser';
|
||||
|
||||
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('MapModal', () => MapModal);
|
||||
|
||||
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter');
|
||||
bottle.decorator('ShortUrlVisits', connect(
|
||||
['shortUrlVisits', 'shortUrlDetail', 'mercureInfo'],
|
||||
['getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter');
|
||||
bottle.decorator('TagVisits', connect(
|
||||
['tagVisits', 'mercureInfo'],
|
||||
['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter');
|
||||
bottle.decorator('DomainVisits', connect(
|
||||
['domainVisits', 'mercureInfo'],
|
||||
['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter');
|
||||
bottle.decorator('OrphanVisits', connect(
|
||||
['orphanVisits', 'mercureInfo'],
|
||||
['getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
|
||||
));
|
||||
|
||||
bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter');
|
||||
bottle.decorator('NonOrphanVisits', connect(
|
||||
['nonOrphanVisits', 'mercureInfo'],
|
||||
['getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
|
||||
));
|
||||
|
||||
// Services
|
||||
bottle.serviceFactory('VisitsParser', () => visitsParser);
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'apiClient');
|
||||
bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('getTagVisits', getTagVisits, 'apiClient');
|
||||
bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('getDomainVisits', getDomainVisits, 'apiClient');
|
||||
bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'apiClient');
|
||||
bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'apiClient');
|
||||
bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('createNewVisits', () => createNewVisits);
|
||||
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'apiClient');
|
||||
|
||||
// Reducers
|
||||
bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview');
|
||||
bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator');
|
||||
|
||||
bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisits');
|
||||
bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisits');
|
||||
bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisits');
|
||||
bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisits');
|
||||
bottle.serviceFactory('shortUrlVisitsReducer', prop('reducer'), 'shortUrlVisitsReducerCreator');
|
||||
|
||||
bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisits');
|
||||
bottle.serviceFactory('tagVisitsReducer', prop('reducer'), 'tagVisitsReducerCreator');
|
||||
};
|
||||
37
shlink-web-component/visits/types/helpers.ts
Normal file
37
shlink-web-component/visits/types/helpers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { countBy, groupBy, pipe, prop } from 'ramda';
|
||||
import type { ShlinkVisitsParams } from '../../../api/types';
|
||||
import { formatIsoDate } from '../../../src/utils/helpers/date';
|
||||
import type { CreateVisit, NormalizedOrphanVisit, NormalizedVisit, OrphanVisit, Stats, Visit, VisitsParams } from './index';
|
||||
|
||||
export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => (visit as OrphanVisit).visitedUrl !== undefined;
|
||||
|
||||
export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is NormalizedOrphanVisit =>
|
||||
(visit as NormalizedOrphanVisit).visitedUrl !== undefined;
|
||||
|
||||
export interface GroupedNewVisits {
|
||||
orphanVisits: CreateVisit[];
|
||||
nonOrphanVisits: CreateVisit[];
|
||||
}
|
||||
|
||||
export const groupNewVisitsByType = pipe(
|
||||
groupBy((newVisit: CreateVisit) => (isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'nonOrphanVisits')),
|
||||
// @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props
|
||||
(result): GroupedNewVisits => ({ orphanVisits: [], nonOrphanVisits: [], ...result }),
|
||||
);
|
||||
|
||||
export type HighlightableProps<T extends NormalizedVisit> = T extends NormalizedOrphanVisit
|
||||
? ('referer' | 'country' | 'city' | 'visitedUrl')
|
||||
: ('referer' | 'country' | 'city');
|
||||
|
||||
export const highlightedVisitsToStats = <T extends NormalizedVisit>(
|
||||
highlightedVisits: T[],
|
||||
property: HighlightableProps<T>,
|
||||
): Stats => countBy(prop(property) as any, highlightedVisits);
|
||||
|
||||
export const toApiParams = ({ page, itemsPerPage, filter, dateRange }: VisitsParams): ShlinkVisitsParams => {
|
||||
const startDate = (dateRange?.startDate && formatIsoDate(dateRange?.startDate)) ?? undefined;
|
||||
const endDate = (dateRange?.endDate && formatIsoDate(dateRange?.endDate)) ?? undefined;
|
||||
const excludeBots = filter?.excludeBots || undefined;
|
||||
|
||||
return { page, itemsPerPage, startDate, endDate, excludeBots };
|
||||
};
|
||||
89
shlink-web-component/visits/types/index.ts
Normal file
89
shlink-web-component/visits/types/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { DateRange } from '../../../src/utils/helpers/dateIntervals';
|
||||
import type { ShortUrl } from '../../short-urls/data';
|
||||
|
||||
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
||||
|
||||
interface VisitLocation {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
regionName: string | null;
|
||||
cityName: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
timezone: string | null;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
export interface RegularVisit {
|
||||
referer: string;
|
||||
date: string;
|
||||
userAgent: string;
|
||||
visitLocation: VisitLocation | null;
|
||||
potentialBot: boolean;
|
||||
}
|
||||
|
||||
export interface OrphanVisit extends RegularVisit {
|
||||
visitedUrl: string;
|
||||
type: OrphanVisitType;
|
||||
}
|
||||
|
||||
export type Visit = RegularVisit | OrphanVisit;
|
||||
|
||||
export interface UserAgent {
|
||||
browser: string;
|
||||
os: string;
|
||||
}
|
||||
|
||||
export interface NormalizedRegularVisit extends UserAgent {
|
||||
date: string;
|
||||
referer: string;
|
||||
country: string;
|
||||
city: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
potentialBot: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedOrphanVisit extends NormalizedRegularVisit {
|
||||
visitedUrl: string;
|
||||
type: OrphanVisitType;
|
||||
}
|
||||
|
||||
export type NormalizedVisit = NormalizedRegularVisit | NormalizedOrphanVisit;
|
||||
|
||||
export interface CreateVisit {
|
||||
shortUrl?: ShortUrl;
|
||||
visit: Visit;
|
||||
}
|
||||
|
||||
export type Stats = Record<string, number>;
|
||||
|
||||
export type StatsRow = [string, number];
|
||||
|
||||
export interface CityStats {
|
||||
cityName: string;
|
||||
count: number;
|
||||
latLong: [number, number];
|
||||
}
|
||||
|
||||
export interface VisitsStats {
|
||||
os: Stats;
|
||||
browsers: Stats;
|
||||
referrers: Stats;
|
||||
countries: Stats;
|
||||
cities: Stats;
|
||||
citiesForMap: Record<string, CityStats>;
|
||||
visitedUrls: Stats;
|
||||
}
|
||||
|
||||
export interface VisitsFilter {
|
||||
orphanVisitsType?: OrphanVisitType | undefined;
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface VisitsParams {
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
dateRange?: DateRange;
|
||||
filter?: VisitsFilter;
|
||||
}
|
||||
41
shlink-web-component/visits/utils/index.ts
Normal file
41
shlink-web-component/visits/utils/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import bowser from 'bowser';
|
||||
import { zipObj } from 'ramda';
|
||||
import type { Empty } from '../../../src/utils/utils';
|
||||
import { hasValue } from '../../../src/utils/utils';
|
||||
import type { Stats, UserAgent } from '../types';
|
||||
|
||||
const DEFAULT = 'Others';
|
||||
const BROWSERS_WHITELIST = [
|
||||
'Android Browser',
|
||||
'Chrome',
|
||||
'Chromium',
|
||||
'Firefox',
|
||||
'Internet Explorer',
|
||||
'Microsoft Edge',
|
||||
'Opera',
|
||||
'Safari',
|
||||
'Samsung Internet for Android',
|
||||
'Vivaldi',
|
||||
'WeChat',
|
||||
];
|
||||
|
||||
export const parseUserAgent = (userAgent: string | Empty): UserAgent => {
|
||||
if (!hasValue(userAgent)) {
|
||||
return { browser: DEFAULT, os: DEFAULT };
|
||||
}
|
||||
|
||||
const { browser: { name: browser }, os: { name: os } } = bowser.parse(userAgent);
|
||||
|
||||
return { os: os ?? DEFAULT, browser: browser && BROWSERS_WHITELIST.includes(browser) ? browser : DEFAULT };
|
||||
};
|
||||
|
||||
export const extractDomain = (url: string | Empty): string => {
|
||||
if (!hasValue(url)) {
|
||||
return 'Direct';
|
||||
}
|
||||
|
||||
return url.split('/')[url.includes('://') ? 2 : 0]?.split(':')[0] ?? '';
|
||||
};
|
||||
|
||||
export const fillTheGaps = (stats: Stats, labels: string[]): number[] =>
|
||||
Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats });
|
||||
Reference in New Issue
Block a user