Skip to content

Commit 27efd58

Browse files
committed
Add component to collect elements for visits comparison
1 parent 85eb4da commit 27efd58

23 files changed

+315
-86
lines changed

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"@fortawesome/free-solid-svg-icons": "^6.4.2",
4646
"@fortawesome/react-fontawesome": "^0.2.0",
4747
"@reduxjs/toolkit": "^2.0.1",
48-
"@shlinkio/shlink-frontend-kit": "^0.4.0",
48+
"@shlinkio/shlink-frontend-kit": "^0.4.1",
4949
"@shlinkio/shlink-js-sdk": "^0.2.0",
5050
"react": "^18.2.0",
5151
"react-dom": "^18.2.0",

src/index.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@
66
.input-group-text.input-group-text {
77
border-color: var(--input-border-color);
88
}
9+
10+
.top-sticky {
11+
position: sticky;
12+
top: $headerHeight;
13+
z-index: 10;
14+
}

src/short-urls/EditShortUrl.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
22
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3-
import { Message, parseQuery, Result } from '@shlinkio/shlink-frontend-kit';
3+
import { Message, Result, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
44
import type { FC } from 'react';
55
import { useEffect, useMemo } from 'react';
66
import { ExternalLink } from 'react-external-link';
7-
import { useLocation } from 'react-router-dom';
87
import { Button, Card } from 'reactstrap';
98
import type { ShlinkEditShortUrlData } from '../api-contract';
109
import { ShlinkApiError } from '../common/ShlinkApiError';
@@ -34,12 +33,11 @@ const EditShortUrl: FCWithDeps<EditShortUrlProps, EditShortUrlDeps> = (
3433
{ shortUrlDetail, getShortUrlDetail, shortUrlEdition, editShortUrl },
3534
) => {
3635
const { ShortUrlForm } = useDependencies(EditShortUrl);
37-
const { search } = useLocation();
36+
const { domain } = useParsedQuery<{ domain?: string }>();
3837
const shortCode = useDecodedShortCodeFromParams();
3938
const goBack = useGoBack();
4039
const { loading, error, errorData, shortUrl } = shortUrlDetail;
4140
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
42-
const { domain } = parseQuery<{ domain?: string }>(search);
4341
const shortUrlCreationSettings = useSetting('shortUrlCreation');
4442
const initialState = useMemo(
4543
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),

src/short-urls/ShortUrlsList.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import { Topics } from '../mercure/helpers/Topics';
1313
import { useFeature } from '../utils/features';
1414
import { useSettings } from '../utils/settings';
1515
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
16+
import { VisitsComparisonCollector } from '../visits/visits-comparison/VisitsComparisonCollector';
17+
import {
18+
useVisitsComparison,
19+
VisitsComparisonProvider,
20+
} from '../visits/visits-comparison/VisitsComparisonContext';
1621
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
1722
import { useShortUrlsQuery } from './helpers/hooks';
1823
import { Paginator } from './Paginator';
@@ -83,6 +88,7 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
8388

8489
return { field, dir };
8590
}, [doExcludeBots, supportsExcludingBots]);
91+
const visitsComparisonValue = useVisitsComparison();
8692

8793
useEffect(() => {
8894
listShortUrls({
@@ -111,13 +117,14 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
111117
]);
112118

113119
return (
114-
<>
120+
<VisitsComparisonProvider value={visitsComparisonValue}>
115121
<ShortUrlsFilteringBar
116122
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
117123
order={actualOrderBy}
118124
handleOrderBy={handleOrderBy}
119125
className="mb-3"
120126
/>
127+
<VisitsComparisonCollector type="short-urls" className="mb-3" />
121128
<Card body className="pb-0">
122129
<ShortUrlsTable
123130
shortUrlsList={shortUrlsList}
@@ -127,7 +134,8 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
127134
/>
128135
<Paginator paginator={pagination} currentQueryString={location.search} />
129136
</Card>
130-
</>
137+
</VisitsComparisonProvider>
138+
131139
);
132140
}, () => [Topics.visits]);
133141

src/short-urls/helpers/ShortUrlsRowMenu.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
faChartLine as lineChartIcon,
23
faChartPie as pieChartIcon,
34
faEdit as editIcon,
45
faMinusCircle as deleteIcon,
@@ -11,7 +12,9 @@ import { DropdownItem } from 'reactstrap';
1112
import type { ShlinkShortUrl } from '../../api-contract';
1213
import type { FCWithDeps } from '../../container/utils';
1314
import { componentFactory, useDependencies } from '../../container/utils';
15+
import { useVisitsToCompare } from '../../visits/visits-comparison/VisitsComparisonContext';
1416
import type { ShortUrlModalProps } from '../data';
17+
import { shortUrlToQuery } from './index';
1518
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
1619

1720
type ShortUrlsRowMenuProps = {
@@ -29,12 +32,29 @@ const ShortUrlsRowMenu: FCWithDeps<ShortUrlsRowMenuProps, ShortUrlsRowMenuDeps>
2932
const { DeleteShortUrlModal, QrCodeModal } = useDependencies(ShortUrlsRowMenu);
3033
const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle();
3134
const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle();
35+
const visitsComparison = useVisitsToCompare();
3236

3337
return (
3438
<RowDropdownBtn minWidth={190}>
3539
<DropdownItem tag={ShortUrlDetailLink} shortUrl={shortUrl} suffix="visits" asLink>
3640
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
3741
</DropdownItem>
42+
{visitsComparison && (
43+
<>
44+
<DropdownItem
45+
disabled={visitsComparison.visitsToCompare.some(({ name }) => name === shortUrl.shortUrl)}
46+
onClick={() => visitsComparison.addVisitToCompare({
47+
name: shortUrl.shortUrl,
48+
query: shortUrlToQuery(shortUrl),
49+
})}
50+
data-testid="add-visit-to-compare-button"
51+
>
52+
<FontAwesomeIcon icon={lineChartIcon} fixedWidth /> Compare visits
53+
</DropdownItem>
54+
55+
<DropdownItem divider tag="hr" />
56+
</>
57+
)}
3858

3959
<DropdownItem tag={ShortUrlDetailLink} shortUrl={shortUrl} suffix="edit" asLink>
4060
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL

src/short-urls/helpers/hooks.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { orderToString, stringifyQuery, stringToOrder } from '@shlinkio/shlink-frontend-kit';
1+
import { orderToString, stringifyQuery, stringToOrder, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
22
import { useCallback, useMemo } from 'react';
33
import { useNavigate, useParams } from 'react-router-dom';
44
import type { TagsFilteringMode } from '../../api-contract';
55
import type { BooleanString } from '../../utils/helpers';
66
import { parseOptionalBooleanToString } from '../../utils/helpers';
7-
import { useParsedQuery } from '../../utils/helpers/hooks';
87
import { useRoutesPrefix } from '../../utils/routesPrefix';
98
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
109
import { urlDecodeShortCode } from './index';

src/short-urls/helpers/index.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ShlinkCreateShortUrlData, ShlinkShortUrl } from '../../api-contrac
22
import type { OptionalString } from '../../utils/helpers';
33
import type { ShortUrlCreationSettings } from '../../utils/settings';
44
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
5+
import type { ShortUrlIdentifier } from '../data';
56

67
export const shortUrlMatches = (shortUrl: ShlinkShortUrl, shortCode: string, domain: OptionalString): boolean => {
78
if (domain === undefined || domain === null) {
@@ -49,8 +50,31 @@ export const shortUrlDataFromShortUrl = (
4950
};
5051
};
5152

52-
const MULTI_SEGMENT_SEPARATOR = '__';
53+
/**
54+
* Converts a short code into a valid URL param, replacing bars from multi-segment slugs with double underscore
55+
*/
56+
export const urlEncodeShortCode = (shortCode: string): string => shortCode.replaceAll('/', '__');
5357

54-
export const urlEncodeShortCode = (shortCode: string): string => shortCode.replaceAll('/', MULTI_SEGMENT_SEPARATOR);
58+
/**
59+
* Converts a URL param representing a short code into its corresponding short code, by replacing double underscores
60+
* (if any) with bars, which means it's a multi-segment slug
61+
*/
62+
export const urlDecodeShortCode = (shortCode: string): string => shortCode.replaceAll('__', '/');
5563

56-
export const urlDecodeShortCode = (shortCode: string): string => shortCode.replaceAll(MULTI_SEGMENT_SEPARATOR, '/');
64+
/**
65+
* String representation of a short URL, so that it can be used as query param
66+
*/
67+
export const shortUrlToQuery = ({ domain, shortCode }: ShortUrlIdentifier): string =>
68+
`${domain ?? DEFAULT_DOMAIN}__${shortCode}`;
69+
70+
/**
71+
* String representation of a short URL, so that it can be used as query param
72+
*/
73+
export const queryToShortUrl = (shortUrlQuery: string): ShortUrlIdentifier => {
74+
const [domain, shortCode] = shortUrlQuery.split('__');
75+
if (!shortCode) {
76+
throw new Error(`It was not possible to parse domain and short code from "${shortUrlQuery}"`);
77+
}
78+
79+
return { domain: domain === DEFAULT_DOMAIN ? null : domain, shortCode };
80+
};

src/tags/TagsTable.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { splitEvery } from '@shlinkio/data-manipulation';
2-
import { parseQuery, SimpleCard } from '@shlinkio/shlink-frontend-kit';
2+
import { SimpleCard, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
33
import type { FC } from 'react';
44
import { useEffect, useRef } from 'react';
5-
import { useLocation } from 'react-router-dom';
65
import type { FCWithDeps } from '../container/utils';
76
import { componentFactory, useDependencies } from '../container/utils';
87
import { SimplePaginator } from '../utils/components/SimplePaginator';
@@ -26,8 +25,7 @@ const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
2625
const TagsTable: FCWithDeps<TagsTableProps, TagsTableDeps> = ({ sortedTags, orderByColumn, currentOrder }) => {
2726
const { TagsTableRow } = useDependencies(TagsTable);
2827
const isFirstLoad = useRef(true);
29-
const { search } = useLocation();
30-
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(search);
28+
const { page: pageFromQuery = 1 } = useParsedQuery<{ page?: number | string }>();
3129
const [page, setPage] = useQueryState<number>('page', Number(pageFromQuery));
3230
const pages = splitEvery(sortedTags, TAGS_PER_PAGE);
3331
const showPaginator = pages.length > 1;

src/tags/helpers/Tag.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export const Tag: FC<TagProps> = (props) => {
4141
'tag--light-bg': isLightColor,
4242
})}
4343
onClick={props.onClose}
44-
>&times;
44+
>
45+
&times;
4546
</UnstyledButton>
4647
)}
4748
</Wrapper>

src/utils/components/UnstyledButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const UnstyledButton: FC<Omit<HTMLProps<HTMLButtonElement>, 'type'>> = ({
55
<button
66
type="button"
77
className={clsx('border-0', className)}
8-
style={{ backgroundColor: 'inherit', fontWeight: 'inherit', ...style }}
8+
style={{ backgroundColor: 'inherit', fontWeight: 'inherit', color: 'inherit', ...style }}
99
{...rest}
1010
/>
1111
);

src/utils/helpers/hooks.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { parseQuery, stringifyQuery } from '@shlinkio/shlink-frontend-kit';
22
import type { DependencyList, EffectCallback } from 'react';
3-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4-
import { useLocation, useNavigate } from 'react-router-dom';
3+
import { useCallback, useEffect, useRef, useState } from 'react';
4+
import { useNavigate } from 'react-router-dom';
55
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
66
import type { MediaMatcher } from '../types';
77

@@ -57,11 +57,6 @@ export const useGoBack = () => {
5757
return useCallback(() => navigate(-1), [navigate]);
5858
};
5959

60-
export const useParsedQuery = <T>(): T => {
61-
const { search } = useLocation();
62-
return useMemo(() => parseQuery<T>(search), [search]);
63-
};
64-
6560
export const useMaxResolution = (maxResolution: number, matchMedia: MediaMatcher) => {
6661
const matchResolution = useCallback(
6762
() => matchMedia(`(max-width: ${maxResolution}px)`).matches,

src/visits/ShortUrlVisits.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { parseQuery } from '@shlinkio/shlink-frontend-kit';
1+
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
22
import { useCallback, useEffect, useMemo } from 'react';
3-
import { useLocation } from 'react-router-dom';
43
import type { FCWithDeps } from '../container/utils';
54
import { componentFactory, useDependencies } from '../container/utils';
65
import type { MercureBoundProps } from '../mercure/helpers/boundToMercureHub';
@@ -46,9 +45,8 @@ const ShortUrlVisits: FCWithDeps<MercureBoundProps & ShortUrlVisitsProps, ShortU
4645
const supportsShortUrlVisitsDeletion = useFeature('shortUrlVisitsDeletion');
4746
const { ReportExporter: reportExporter } = useDependencies(ShortUrlVisits);
4847
const shortCode = useDecodedShortCodeFromParams();
49-
const { search } = useLocation();
5048
const goBack = useGoBack();
51-
const { domain } = parseQuery<{ domain?: string }>(search);
49+
const { domain } = useParsedQuery<{ domain?: string }>();
5250
const loadVisits = useCallback((params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({
5351
shortCode,
5452
query: { ...toApiParams(params), domain },

src/visits/VisitsStats.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const VisitsStats: FC<VisitsStatsProps> = (props) => {
9595
const isFirstLoad = useRef(true);
9696
const { search } = useLocation();
9797

98-
const buildSectionUrl = (subPath?: string) => (!subPath ? search : `${subPath}${search}`);
98+
const buildSectionUrl = useCallback((subPath?: string) => (!subPath ? search : `${subPath}${search}`), [search]);
9999
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
100100
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
101101
() => processStatsFromVisits(normalizedVisits),

src/visits/helpers/hooks.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { mergeDeepRight } from '@shlinkio/data-manipulation';
2-
import { stringifyQuery } from '@shlinkio/shlink-frontend-kit';
2+
import { stringifyQuery, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
33
import { useCallback, useMemo } from 'react';
44
import { useNavigate } from 'react-router-dom';
55
import type { ShlinkOrphanVisitType } from '../../api-contract';
@@ -8,7 +8,6 @@ import type { DateRange } from '../../utils/dates/helpers/dateIntervals';
88
import { datesToDateRange } from '../../utils/dates/helpers/dateIntervals';
99
import type { BooleanString } from '../../utils/helpers';
1010
import { parseBooleanToString } from '../../utils/helpers';
11-
import { useParsedQuery } from '../../utils/helpers/hooks';
1211
import type { DeepPartial } from '../../utils/types';
1312
import type { VisitsFilter } from '../types';
1413

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import type { FC } from 'react';
2+
3+
export const VisitsComparison: FC = () => null;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { faChartLine as lineChartIcon } from '@fortawesome/free-solid-svg-icons';
2+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3+
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
4+
import { clsx } from 'clsx';
5+
import type { FC } from 'react';
6+
import { useMemo } from 'react';
7+
import { Link } from 'react-router-dom';
8+
import { Button } from 'reactstrap';
9+
import { UnstyledButton } from '../../utils/components/UnstyledButton';
10+
import { useRoutesPrefix } from '../../utils/routesPrefix';
11+
import { useVisitsToCompare } from './VisitsComparisonContext';
12+
13+
type VisitsComparisonCollectorProps = {
14+
className?: string;
15+
type: 'short-urls' | 'tags' | 'domains';
16+
};
17+
18+
export const VisitsComparisonCollector: FC<VisitsComparisonCollectorProps> = ({ className, type }) => {
19+
const routesPrefix = useRoutesPrefix();
20+
const context = useVisitsToCompare();
21+
const query = useMemo(
22+
() => (!context ? '' : encodeURIComponent(context.visitsToCompare.map((visit) => visit.query).join(','))),
23+
[context],
24+
);
25+
26+
if (!context?.visitsToCompare.length) {
27+
return null;
28+
}
29+
30+
return (
31+
<div className={clsx('top-sticky', className)}>
32+
<SimpleCard bodyClassName="d-flex gap-3 align-items-center">
33+
<div className="d-flex flex-wrap gap-1 flex-grow-1">
34+
{context.visitsToCompare.map((item, index) => (
35+
<span key={`${item.name}_${index}`} className="badge bg-secondary pe-1">
36+
{item.name}
37+
<UnstyledButton
38+
aria-label={`Remove ${item.name}`}
39+
className="fw-bold fs-6"
40+
onClick={() => context.removeVisitToCompare(item)}
41+
>
42+
&times;
43+
</UnstyledButton>
44+
</span>
45+
))}
46+
</div>
47+
<div>
48+
<Button
49+
outline
50+
color="primary"
51+
disabled={context.visitsToCompare.length < 2}
52+
tag={Link}
53+
to={`${routesPrefix}/${type}/compare-visits?${type}=${query}`}
54+
>
55+
<FontAwesomeIcon icon={lineChartIcon} fixedWidth className="me-1" />
56+
Compare &raquo;
57+
</Button>
58+
<Button
59+
aria-label="Close compare"
60+
outline
61+
color="secondary"
62+
className="ms-2 fw-bold"
63+
onClick={context.clearVisitsToCompare}
64+
>
65+
&times;
66+
</Button>
67+
</div>
68+
</SimpleCard>
69+
</div>
70+
);
71+
};

0 commit comments

Comments
 (0)