Skip to content

Commit

Permalink
Add component to collect elements for visits comparison
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Dec 16, 2023
1 parent 85eb4da commit 9229432
Show file tree
Hide file tree
Showing 19 changed files with 197 additions and 43 deletions.
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "^2.0.1",
"@shlinkio/shlink-frontend-kit": "^0.4.0",
"@shlinkio/shlink-frontend-kit": "^0.4.1",
"@shlinkio/shlink-js-sdk": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
6 changes: 6 additions & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@
.input-group-text.input-group-text {
border-color: var(--input-border-color);
}

.top-sticky {
position: sticky;
top: $headerHeight;
z-index: 10;
}
6 changes: 2 additions & 4 deletions src/short-urls/EditShortUrl.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Message, parseQuery, Result } from '@shlinkio/shlink-frontend-kit';
import { Message, Result, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useEffect, useMemo } from 'react';
import { ExternalLink } from 'react-external-link';
import { useLocation } from 'react-router-dom';
import { Button, Card } from 'reactstrap';
import type { ShlinkEditShortUrlData } from '../api-contract';
import { ShlinkApiError } from '../common/ShlinkApiError';
Expand Down Expand Up @@ -34,12 +33,11 @@ const EditShortUrl: FCWithDeps<EditShortUrlProps, EditShortUrlDeps> = (
{ shortUrlDetail, getShortUrlDetail, shortUrlEdition, editShortUrl },
) => {
const { ShortUrlForm } = useDependencies(EditShortUrl);
const { search } = useLocation();
const { domain } = useParsedQuery<{ domain?: string }>();
const shortCode = useDecodedShortCodeFromParams();
const goBack = useGoBack();
const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search);
const shortUrlCreationSettings = useSetting('shortUrlCreation');
const initialState = useMemo(
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
Expand Down
12 changes: 10 additions & 2 deletions src/short-urls/ShortUrlsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { Topics } from '../mercure/helpers/Topics';
import { useFeature } from '../utils/features';
import { useSettings } from '../utils/settings';
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { VisitsComparisonCollector } from '../visits/visits-comparison/VisitsComparisonCollector';
import {
useVisitsComparison,
VisitsComparisonProvider,
} from '../visits/visits-comparison/VisitsComparisonContext';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { useShortUrlsQuery } from './helpers/hooks';
import { Paginator } from './Paginator';
Expand Down Expand Up @@ -83,6 +88,7 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo

return { field, dir };
}, [doExcludeBots, supportsExcludingBots]);
const visitsComparisonValue = useVisitsComparison();

useEffect(() => {
listShortUrls({
Expand Down Expand Up @@ -111,13 +117,14 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
]);

return (
<>
<VisitsComparisonProvider value={visitsComparisonValue}>
<ShortUrlsFilteringBar
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
className="mb-3"
/>
<VisitsComparisonCollector type="short-urls" className="mb-3" />
<Card body className="pb-0">
<ShortUrlsTable
shortUrlsList={shortUrlsList}
Expand All @@ -127,7 +134,8 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
/>
<Paginator paginator={pagination} currentQueryString={location.search} />
</Card>
</>
</VisitsComparisonProvider>

);
}, () => [Topics.visits]);

Expand Down
19 changes: 19 additions & 0 deletions src/short-urls/helpers/ShortUrlsRowMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
faChartLine as lineChartIcon,
faChartPie as pieChartIcon,
faEdit as editIcon,
faMinusCircle as deleteIcon,
Expand All @@ -11,7 +12,9 @@ import { DropdownItem } from 'reactstrap';
import type { ShlinkShortUrl } from '../../api-contract';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import { useVisitsToCompare } from '../../visits/visits-comparison/VisitsComparisonContext';
import type { ShortUrlModalProps } from '../data';
import { shortUrlToQuery } from './index';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';

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

return (
<RowDropdownBtn minWidth={190}>
<DropdownItem tag={ShortUrlDetailLink} shortUrl={shortUrl} suffix="visits" asLink>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
{visitsComparison && (
<>
<DropdownItem
disabled={visitsComparison.visitsToCompare.some(({ name }) => name === shortUrl.shortUrl)}
onClick={() => visitsComparison.addVisitToCompare({
name: shortUrl.shortUrl,
query: shortUrlToQuery(shortUrl),
})}
>
<FontAwesomeIcon icon={lineChartIcon} fixedWidth /> Compare visits
</DropdownItem>

<DropdownItem divider tag="hr" />
</>
)}

<DropdownItem tag={ShortUrlDetailLink} shortUrl={shortUrl} suffix="edit" asLink>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
Expand Down
3 changes: 1 addition & 2 deletions src/short-urls/helpers/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { orderToString, stringifyQuery, stringToOrder } from '@shlinkio/shlink-frontend-kit';
import { orderToString, stringifyQuery, stringToOrder, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { useCallback, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { TagsFilteringMode } from '../../api-contract';
import type { BooleanString } from '../../utils/helpers';
import { parseOptionalBooleanToString } from '../../utils/helpers';
import { useParsedQuery } from '../../utils/helpers/hooks';
import { useRoutesPrefix } from '../../utils/routesPrefix';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
import { urlDecodeShortCode } from './index';
Expand Down
26 changes: 23 additions & 3 deletions src/short-urls/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ShlinkCreateShortUrlData, ShlinkShortUrl } from '../../api-contrac
import type { OptionalString } from '../../utils/helpers';
import type { ShortUrlCreationSettings } from '../../utils/settings';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { ShortUrlIdentifier } from '../data';

export const shortUrlMatches = (shortUrl: ShlinkShortUrl, shortCode: string, domain: OptionalString): boolean => {
if (domain === undefined || domain === null) {
Expand Down Expand Up @@ -49,8 +50,27 @@ export const shortUrlDataFromShortUrl = (
};
};

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

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

export const urlDecodeShortCode = (shortCode: string): string => shortCode.replaceAll(MULTI_SEGMENT_SEPARATOR, '/');
/**
* String representation of a short URL, so that it can be used as query param
*/
export const shortUrlToQuery = ({ domain, shortCode }: ShortUrlIdentifier): string =>
`${domain ?? DEFAULT_DOMAIN}__${shortCode}`;

/**
* String representation of a short URL, so that it can be used as query param
*/
export const queryToShortUrl = (shortUrlQuery: string): ShortUrlIdentifier => {
const [domain, shortCode] = shortUrlQuery.split('__');
return { domain: domain === DEFAULT_DOMAIN ? null : domain, shortCode };
};
6 changes: 2 additions & 4 deletions src/tags/TagsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { splitEvery } from '@shlinkio/data-manipulation';
import { parseQuery, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import { SimpleCard, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { SimplePaginator } from '../utils/components/SimplePaginator';
Expand All @@ -26,8 +25,7 @@ const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
const TagsTable: FCWithDeps<TagsTableProps, TagsTableDeps> = ({ sortedTags, orderByColumn, currentOrder }) => {
const { TagsTableRow } = useDependencies(TagsTable);
const isFirstLoad = useRef(true);
const { search } = useLocation();
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(search);
const { page: pageFromQuery = 1 } = useParsedQuery<{ page?: number | string }>();
const [page, setPage] = useQueryState<number>('page', Number(pageFromQuery));
const pages = splitEvery(sortedTags, TAGS_PER_PAGE);
const showPaginator = pages.length > 1;
Expand Down
3 changes: 2 additions & 1 deletion src/tags/helpers/Tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const Tag: FC<TagProps> = (props) => {
'tag--light-bg': isLightColor,
})}
onClick={props.onClose}
>&times;
>
&times;
</UnstyledButton>
)}
</Wrapper>
Expand Down
2 changes: 1 addition & 1 deletion src/utils/components/UnstyledButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const UnstyledButton: FC<Omit<HTMLProps<HTMLButtonElement>, 'type'>> = ({
<button
type="button"
className={clsx('border-0', className)}
style={{ backgroundColor: 'inherit', fontWeight: 'inherit', ...style }}
style={{ backgroundColor: 'inherit', fontWeight: 'inherit', color: 'inherit', ...style }}
{...rest}
/>
);
9 changes: 2 additions & 7 deletions src/utils/helpers/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { parseQuery, stringifyQuery } from '@shlinkio/shlink-frontend-kit';
import type { DependencyList, EffectCallback } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
import type { MediaMatcher } from '../types';

Expand Down Expand Up @@ -57,11 +57,6 @@ export const useGoBack = () => {
return useCallback(() => navigate(-1), [navigate]);
};

export const useParsedQuery = <T>(): T => {
const { search } = useLocation();
return useMemo(() => parseQuery<T>(search), [search]);
};

export const useMaxResolution = (maxResolution: number, matchMedia: MediaMatcher) => {
const matchResolution = useCallback(
() => matchMedia(`(max-width: ${maxResolution}px)`).matches,
Expand Down
6 changes: 2 additions & 4 deletions src/visits/ShortUrlVisits.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { parseQuery } from '@shlinkio/shlink-frontend-kit';
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { MercureBoundProps } from '../mercure/helpers/boundToMercureHub';
Expand Down Expand Up @@ -46,9 +45,8 @@ const ShortUrlVisits: FCWithDeps<MercureBoundProps & ShortUrlVisitsProps, ShortU
const supportsShortUrlVisitsDeletion = useFeature('shortUrlVisitsDeletion');
const { ReportExporter: reportExporter } = useDependencies(ShortUrlVisits);
const shortCode = useDecodedShortCodeFromParams();
const { search } = useLocation();
const goBack = useGoBack();
const { domain } = parseQuery<{ domain?: string }>(search);
const { domain } = useParsedQuery<{ domain?: string }>();
const loadVisits = useCallback((params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({
shortCode,
query: { ...toApiParams(params), domain },
Expand Down
2 changes: 1 addition & 1 deletion src/visits/VisitsStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const VisitsStats: FC<VisitsStatsProps> = (props) => {
const isFirstLoad = useRef(true);
const { search } = useLocation();

const buildSectionUrl = (subPath?: string) => (!subPath ? search : `${subPath}${search}`);
const buildSectionUrl = useCallback((subPath?: string) => (!subPath ? search : `${subPath}${search}`), [search]);
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
() => processStatsFromVisits(normalizedVisits),
Expand Down
3 changes: 1 addition & 2 deletions src/visits/helpers/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mergeDeepRight } from '@shlinkio/data-manipulation';
import { stringifyQuery } from '@shlinkio/shlink-frontend-kit';
import { stringifyQuery, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { ShlinkOrphanVisitType } from '../../api-contract';
Expand All @@ -8,7 +8,6 @@ import type { DateRange } from '../../utils/dates/helpers/dateIntervals';
import { datesToDateRange } from '../../utils/dates/helpers/dateIntervals';
import type { BooleanString } from '../../utils/helpers';
import { parseBooleanToString } from '../../utils/helpers';
import { useParsedQuery } from '../../utils/helpers/hooks';
import type { DeepPartial } from '../../utils/types';
import type { VisitsFilter } from '../types';

Expand Down
3 changes: 3 additions & 0 deletions src/visits/visits-comparison/VisitsComparison.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { FC } from 'react';

export const VisitsComparison: FC = () => null;
71 changes: 71 additions & 0 deletions src/visits/visits-comparison/VisitsComparisonCollector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { faChartLine as lineChartIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Button } from 'reactstrap';
import { UnstyledButton } from '../../utils/components/UnstyledButton';
import { useRoutesPrefix } from '../../utils/routesPrefix';
import { useVisitsToCompare } from './VisitsComparisonContext';

type VisitsComparisonCollectorProps = {
className?: string;
type: 'short-urls' | 'tags' | 'domains';
};

export const VisitsComparisonCollector: FC<VisitsComparisonCollectorProps> = ({ className, type }) => {
const routesPrefix = useRoutesPrefix();
const context = useVisitsToCompare();
const query = useMemo(
() => (!context ? '' : encodeURIComponent(context.visitsToCompare.map((visit) => visit.query).join(','))),
[context],
);

if (!context?.visitsToCompare.length) {
return null;
}

return (
<div className={clsx('top-sticky', className)}>
<SimpleCard bodyClassName="d-flex gap-3 align-items-center">
<div className="d-flex flex-wrap gap-1 flex-grow-1">
{context.visitsToCompare.map((item, index) => (
<span key={`${item.name}_${index}`} className="badge bg-secondary pe-1">
{item.name}
<UnstyledButton
aria-label={`Remove ${item.name}`}
className="fw-bold fs-6"
onClick={() => context.removeVisitToCompare(item)}
>
&times;
</UnstyledButton>
</span>
))}
</div>
<div>
<Button
outline
color="primary"
disabled={context.visitsToCompare.length < 2}
tag={Link}
to={`${routesPrefix}/${type}/compare-visits?${type}=${query}`}
>
<FontAwesomeIcon icon={lineChartIcon} fixedWidth className="me-1" />
Compare &raquo;
</Button>
<Button
aria-label="Close compare"
outline
color="secondary"
className="ms-2 fw-bold"
onClick={context.clearVisitsToCompare}
>
&times;
</Button>
</div>
</SimpleCard>
</div>
);
};
Loading

0 comments on commit 9229432

Please sign in to comment.