Merge pull request #772 from acelaya-forks/feature/exclude-bots

Feature/exclude bots
This commit is contained in:
Alejandro Celaya 2022-12-23 20:29:24 +01:00 committed by GitHub
commit bfcdf703e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 311 additions and 68 deletions

View file

@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased] ## [Unreleased]
### Added ### Added
* [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc. * [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc.
* [#764](https://github.com/shlinkio/shlink-web-client/issues/764) Added support to exclude visits from visits on short URLs list when consuming Shlink 3.4.0.
This feature also comes with a new setting to disable visits from bots by default, both on short URLs lists and visits sections.
### Changed ### Changed
* *Nothing* * *Nothing*

View file

@ -1,6 +1,7 @@
import { Visit } from '../../visits/types'; import { Visit } from '../../visits/types';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data'; import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
import { Order } from '../../utils/helpers/ordering';
export interface ShlinkShortUrlsResponse { export interface ShlinkShortUrlsResponse {
data: ShortUrl[]; data: ShortUrl[];
@ -88,6 +89,10 @@ export interface ShlinkDomainsResponse {
export type TagsFilteringMode = 'all' | 'any'; export type TagsFilteringMode = 'all' | 'any';
type ShlinkShortUrlsOrderableFields = 'dateCreated' | 'shortCode' | 'longUrl' | 'title' | 'visits' | 'nonBotVisits';
export type ShlinkShortUrlsOrder = Order<ShlinkShortUrlsOrderableFields>;
export interface ShlinkShortUrlsListParams { export interface ShlinkShortUrlsListParams {
page?: string; page?: string;
itemsPerPage?: number; itemsPerPage?: number;
@ -95,7 +100,7 @@ export interface ShlinkShortUrlsListParams {
searchTerm?: string; searchTerm?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
orderBy?: ShortUrlsOrder; orderBy?: ShlinkShortUrlsOrder;
tagsMode?: TagsFilteringMode; tagsMode?: TagsFilteringMode;
} }

View file

@ -1,20 +1,39 @@
import { FC } from 'react'; import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector'; import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings'; import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
import { ToggleSwitch } from '../utils/ToggleSwitch';
import { FormText } from '../utils/forms/FormText';
import { DateInterval } from '../utils/helpers/dateIntervals';
interface VisitsProps { interface VisitsProps {
settings: Settings; settings: Settings;
setVisitsSettings: (settings: VisitsSettingsConfig) => void; setVisitsSettings: (settings: VisitsSettingsConfig) => void;
} }
const currentDefaultInterval = (settings: Settings): DateInterval => settings.visits?.defaultInterval ?? 'last30Days';
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => ( export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<SimpleCard title="Visits" className="h-100"> <SimpleCard title="Visits" className="h-100">
<FormGroup>
<ToggleSwitch
checked={!!settings.visits?.excludeBots}
onChange={(excludeBots) => setVisitsSettings(
{ defaultInterval: currentDefaultInterval(settings), excludeBots },
)}
>
Exclude bots wherever possible (this option&lsquo;s effect might depend on Shlink server&lsquo;s version).
<FormText>
The visits coming from potential bots will be <b>{settings.visits?.excludeBots ? 'excluded' : 'included'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup noMargin label="Default interval to load on visits sections:"> <LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<DateIntervalSelector <DateIntervalSelector
allText="All visits" allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'} active={currentDefaultInterval(settings)}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })} onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/> />
</LabeledFormGroup> </LabeledFormGroup>

View file

@ -34,6 +34,7 @@ export interface UiSettings {
export interface VisitsSettings { export interface VisitsSettings {
defaultInterval: DateInterval; defaultInterval: DateInterval;
excludeBots?: boolean;
} }
export interface TagsSettings { export interface TagsSettings {

View file

@ -8,7 +8,7 @@ import { SearchField } from '../utils/SearchField';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals'; import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals';
import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { supportsAllTagsFiltering, supportsBotVisits } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { OrderDir } from '../utils/helpers/ordering'; import { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown'; import { OrderingDropdown } from '../utils/OrderingDropdown';
@ -16,11 +16,14 @@ import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
import { Settings } from '../settings/reducers/settings';
import './ShortUrlsFilteringBar.scss'; import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps { interface ShortUrlsFilteringProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
order: ShortUrlsOrder; order: ShortUrlsOrder;
settings: Settings;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string; className?: string;
shortUrlsAmount?: number; shortUrlsAmount?: number;
@ -29,8 +32,8 @@ interface ShortUrlsFilteringProps {
export const ShortUrlsFilteringBar = ( export const ShortUrlsFilteringBar = (
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>, ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => { ): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery(); const [{ search, tags, startDate, endDate, excludeBots, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery();
const setDates = pipe( const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
startDate: formatIsoDate(theStartDate) ?? undefined, startDate: formatIsoDate(theStartDate) ?? undefined,
@ -44,6 +47,7 @@ export const ShortUrlsFilteringBar = (
); );
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const botsSupported = supportsBotVisits(selectedServer);
const toggleTagsMode = pipe( const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'), () => (tagsMode === 'any' ? 'all' : 'any'),
(mode) => toFirstPage({ tagsMode: mode }), (mode) => toFirstPage({ tagsMode: mode }),
@ -69,11 +73,21 @@ export const ShortUrlsFilteringBar = (
<Row className="flex-lg-row-reverse"> <Row className="flex-lg-row-reverse">
<div className="col-lg-8 col-xl-6 mt-3"> <div className="col-lg-8 col-xl-6 mt-3">
<DateRangeSelector <div className="d-md-flex">
defaultText="All short URLs" <div className="flex-fill">
initialDateRange={datesToDateRange(startDate, endDate)} <DateRangeSelector
onDatesChange={setDates} defaultText="All short URLs"
/> initialDateRange={datesToDateRange(startDate, endDate)}
onDatesChange={setDates}
/>
</div>
<ShortUrlsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
botsSupported={botsSupported}
selected={{ excludeBots: excludeBots ?? settings.visits?.excludeBots }}
onChange={toFirstPage}
/>
</div>
</div> </div>
<div className="col-6 col-lg-4 col-xl-6 mt-3"> <div className="col-6 col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} /> <ExportShortUrlsBtn amount={shortUrlsAmount} />

View file

@ -7,14 +7,15 @@ import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { ShlinkShortUrlsListParams } from '../api/types'; import { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../api/types';
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings'; import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableType } from './ShortUrlsTable'; import { ShortUrlsTableType } from './ShortUrlsTable';
import { Paginator } from './Paginator'; import { Paginator } from './Paginator';
import { useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields } from './data'; import { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar'; import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
import { supportsExcludeBotsOnShortUrls } from '../utils/helpers/features';
interface ShortUrlsListProps { interface ShortUrlsListProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
@ -30,12 +31,13 @@ export const ShortUrlsList = (
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const { page } = useParams(); const { page } = useParams();
const location = useLocation(); const location = useLocation();
const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage] = useShortUrlsQuery(); const [{ tags, search, startDate, endDate, orderBy, tagsMode, excludeBots }, toFirstPage] = useShortUrlsQuery();
const [actualOrderBy, setActualOrderBy] = useState( const [actualOrderBy, setActualOrderBy] = useState(
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded // This separated state handling is needed to be able to fall back to settings value, but only once when loaded
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
); );
const { pagination } = shortUrlsList?.shortUrls ?? {}; const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } }); toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir }); setActualOrderBy({ field, dir });
@ -48,6 +50,13 @@ export const ShortUrlsList = (
(newTag: string) => [...new Set([...tags, newTag])], (newTag: string) => [...new Set([...tags, newTag])],
(updatedTags) => toFirstPage({ tags: updatedTags }), (updatedTags) => toFirstPage({ tags: updatedTags }),
); );
const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => {
if (supportsExcludeBotsOnShortUrls(selectedServer) && doExcludeBots && field === 'visits') {
return { field: 'nonBotVisits', dir };
}
return { field, dir };
};
useEffect(() => { useEffect(() => {
listShortUrls({ listShortUrls({
@ -56,10 +65,10 @@ export const ShortUrlsList = (
tags, tags,
startDate, startDate,
endDate, endDate,
orderBy: actualOrderBy, orderBy: parseOrderByForShlink(actualOrderBy),
tagsMode, tagsMode,
}); });
}, [page, search, tags, startDate, endDate, actualOrderBy, tagsMode]); }, [page, search, tags, startDate, endDate, actualOrderBy.field, actualOrderBy.dir, tagsMode]);
return ( return (
<> <>
@ -68,6 +77,7 @@ export const ShortUrlsList = (
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems} shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy} order={actualOrderBy}
handleOrderBy={handleOrderBy} handleOrderBy={handleOrderBy}
settings={settings}
className="mb-3" className="mb-3"
/> />
<Card body className="pb-0"> <Card body className="pb-0">

View file

@ -80,3 +80,7 @@ export interface ExportableShortUrl {
tags: string; tags: string;
visits: number; visits: number;
} }
export interface ShortUrlsFilter {
excludeBots?: boolean;
}

View file

@ -52,7 +52,7 @@ export const ExportShortUrlsBtn = (
longUrl: shortUrl.longUrl, longUrl: shortUrl.longUrl,
title: shortUrl.title ?? '', title: shortUrl.title ?? '',
tags: shortUrl.tags.join(','), tags: shortUrl.tags.join(','),
visits: shortUrl.visitsCount, visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount,
}))); })));
stopLoading(); stopLoading();
}; };

View file

@ -0,0 +1,38 @@
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../utils/DropdownBtn';
import { hasValue } from '../../utils/utils';
import { ShortUrlsFilter } from '../data';
interface ShortUrlsFilterDropdownProps {
onChange: (filters: ShortUrlsFilter) => void;
selected?: ShortUrlsFilter;
className?: string;
botsSupported: boolean;
}
export const ShortUrlsFilterDropdown = (
{ onChange, selected = {}, className, botsSupported }: ShortUrlsFilterDropdownProps,
) => {
if (!botsSupported) {
return null;
}
const { excludeBots = false } = selected;
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
return (
<DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}>
{botsSupported && (
<>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude bots visits</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem disabled={!hasValue(selected)} onClick={() => onChange({ excludeBots: false })}>
<i>Clear filters</i>
</DropdownItem>
</DropdownBtn>
);
};

View file

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { FC, useEffect, useRef } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { ColorGenerator } from '../../utils/services/ColorGenerator'; import { ColorGenerator } from '../../utils/services/ColorGenerator';
import { TimeoutToggle } from '../../utils/helpers/hooks'; import { TimeoutToggle } from '../../utils/helpers/hooks';
@ -6,10 +6,12 @@ import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { ShortUrl } from '../data'; import { ShortUrl } from '../data';
import { Time } from '../../utils/dates/Time'; import { Time } from '../../utils/dates/Time';
import { Settings } from '../../settings/reducers/settings';
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount'; import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu'; import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu';
import { Tags } from './Tags'; import { Tags } from './Tags';
import { ShortUrlStatus } from './ShortUrlStatus'; import { ShortUrlStatus } from './ShortUrlStatus';
import { useShortUrlsQuery } from './hooks';
import './ShortUrlsRow.scss'; import './ShortUrlsRow.scss';
interface ShortUrlsRowProps { interface ShortUrlsRowProps {
@ -18,19 +20,28 @@ interface ShortUrlsRowProps {
shortUrl: ShortUrl; shortUrl: ShortUrl;
} }
interface ShortUrlsRowConnectProps extends ShortUrlsRowProps {
settings: Settings;
}
export type ShortUrlsRowType = FC<ShortUrlsRowProps>;
export const ShortUrlsRow = ( export const ShortUrlsRow = (
ShortUrlsRowMenu: ShortUrlsRowMenuType, ShortUrlsRowMenu: ShortUrlsRowMenuType,
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
useTimeoutToggle: TimeoutToggle, useTimeoutToggle: TimeoutToggle,
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => { ) => ({ shortUrl, selectedServer, onTagClick, settings }: ShortUrlsRowConnectProps) => {
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle(); const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
const [active, setActive] = useTimeoutToggle(false, 500); const [active, setActive] = useTimeoutToggle(false, 500);
const isFirstRun = useRef(true); const isFirstRun = useRef(true);
const [{ excludeBots }] = useShortUrlsQuery();
const { visits } = settings;
const doExcludeBots = excludeBots ?? visits?.excludeBots;
useEffect(() => { useEffect(() => {
!isFirstRun.current && setActive(); !isFirstRun.current && setActive();
isFirstRun.current = false; isFirstRun.current = false;
}, [shortUrl.visitsCount]); }, [shortUrl.visitsSummary?.total, shortUrl.visitsSummary?.nonBots, shortUrl.visitsCount]);
return ( return (
<tr className="responsive-table__row"> <tr className="responsive-table__row">
@ -64,7 +75,9 @@ export const ShortUrlsRow = (
</td> </td>
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits"> <td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
<ShortUrlVisitsCount <ShortUrlVisitsCount
visitsCount={shortUrl.visitsSummary?.total ?? shortUrl.visitsCount} visitsCount={(
doExcludeBots ? shortUrl.visitsSummary?.nonBots : shortUrl.visitsSummary?.total
) ?? shortUrl.visitsCount}
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
active={active} active={active}
@ -79,5 +92,3 @@ export const ShortUrlsRow = (
</tr> </tr>
); );
}; };
export type ShortUrlsRowType = ReturnType<typeof ShortUrlsRow>;

View file

@ -5,6 +5,7 @@ import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data'; import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
import { orderToString, stringToOrder } from '../../utils/helpers/ordering'; import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
import { TagsFilteringMode } from '../../api/types'; import { TagsFilteringMode } from '../../api/types';
import { BooleanString, parseBooleanToString } from '../../utils/utils';
interface ShortUrlsQueryCommon { interface ShortUrlsQueryCommon {
search?: string; search?: string;
@ -16,11 +17,13 @@ interface ShortUrlsQueryCommon {
interface ShortUrlsQuery extends ShortUrlsQueryCommon { interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string; orderBy?: string;
tags?: string; tags?: string;
excludeBots?: BooleanString;
} }
interface ShortUrlsFiltering extends ShortUrlsQueryCommon { interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder; orderBy?: ShortUrlsOrder;
tags: string[]; tags: string[];
excludeBots?: boolean;
} }
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void; type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
@ -33,20 +36,26 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const filtering = useMemo( const filtering = useMemo(
pipe( pipe(
() => parseQuery<ShortUrlsQuery>(search), () => parseQuery<ShortUrlsQuery>(search),
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { ({ orderBy, tags, excludeBots, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined; const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
const parsedTags = tags?.split(',') ?? []; const parsedTags = tags?.split(',') ?? [];
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags }; return {
...rest,
orderBy: parsedOrderBy,
tags: parsedTags,
excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined,
};
}, },
), ),
[search], [search],
); );
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => { const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, tags, ...mergedFiltering } = { ...filtering, ...extra }; const { orderBy, tags, excludeBots, ...mergedFiltering } = { ...filtering, ...extra };
const query: ShortUrlsQuery = { const query: ShortUrlsQuery = {
...mergedFiltering, ...mergedFiltering,
orderBy: orderBy && orderToString(orderBy), orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined, tags: tags.length > 0 ? tags.join(',') : undefined,
excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
}; };
const stringifiedQuery = stringifyQuery(query); const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`; const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;

View file

@ -1,5 +1,5 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { assoc, assocPath, last, pipe, reject } from 'ramda'; import { assocPath, last, pipe, reject } from 'ramda';
import { shortUrlMatches } from '../helpers'; import { shortUrlMatches } from '../helpers';
import { createNewVisits } from '../../visits/reducers/visitCreation'; import { createNewVisits } from '../../visits/reducers/visitCreation';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
@ -101,18 +101,12 @@ export const shortUrlsListReducerCreator = (
(state, { payload }) => assocPath( (state, { payload }) => assocPath(
['shortUrls', 'data'], ['shortUrls', 'data'],
state.shortUrls?.data?.map( state.shortUrls?.data?.map(
(currentShortUrl) => { // Find the last of the new visit for this short URL, and pick its short URL. It will have an up-to-date amount of visits.
// Find the last of the new visit for this short URL, and pick the amount of visits from it (currentShortUrl) => last(
const lastVisit = last( payload.createdVisits.filter(
payload.createdVisits.filter( ({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain), ),
), )?.shortUrl ?? currentShortUrl,
);
return lastVisit?.shortUrl
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
: currentShortUrl;
},
), ),
state, state,
), ),

View file

@ -28,7 +28,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
)); ));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
bottle.decorator('ShortUrlsRow', connect(['settings']));
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal'); bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector'); bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector');

View file

@ -14,3 +14,4 @@ export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.
export const supportsNonOrphanVisits = serverMatchesMinVersion('3.0.0'); export const supportsNonOrphanVisits = serverMatchesMinVersion('3.0.0');
export const supportsAllTagsFiltering = supportsNonOrphanVisits; export const supportsAllTagsFiltering = supportsNonOrphanVisits;
export const supportsDomainVisits = serverMatchesMinVersion('3.1.0'); export const supportsDomainVisits = serverMatchesMinVersion('3.1.0');
export const supportsExcludeBotsOnShortUrls = serverMatchesMinVersion('3.4.0');

View file

@ -26,3 +26,7 @@ export const nonEmptyValueOrNull = <T>(value: T): T | null => (isEmpty(value) ?
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`; export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
export const equals = (value: any) => (otherValue: any) => value === otherValue; export const equals = (value: any) => (otherValue: any) => value === otherValue;
export type BooleanString = 'true' | 'false';
export const parseBooleanToString = (value: boolean): BooleanString => (value ? 'true' : 'false');

View file

@ -93,6 +93,12 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
[normalizedVisits], [normalizedVisits],
); );
const mapLocations = values(citiesForMap); const mapLocations = values(citiesForMap);
const resolvedFilter = useMemo(() => (
!isFirstLoad.current ? visitsFilter : {
...visitsFilter,
excludeBots: visitsFilter.excludeBots ?? settings.visits?.excludeBots,
}
), [visitsFilter]);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => { const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
selectedBar = undefined; selectedBar = undefined;
@ -115,7 +121,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
useEffect(() => cancelGetVisits, []); useEffect(() => cancelGetVisits, []);
useEffect(() => { useEffect(() => {
const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current)); const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current));
getVisits({ dateRange: resolvedDateRange, filter: visitsFilter }, isFirstLoad.current); getVisits({ dateRange: resolvedDateRange, filter: resolvedFilter }, isFirstLoad.current);
isFirstLoad.current = false; isFirstLoad.current = false;
}, [dateRange, visitsFilter]); }, [dateRange, visitsFilter]);
useEffect(() => { useEffect(() => {
@ -301,7 +307,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
className="ms-0 ms-md-2 mt-3 mt-md-0" className="ms-0 ms-md-2 mt-3 mt-md-0"
isOrphanVisits={isOrphanVisits} isOrphanVisits={isOrphanVisits}
botsSupported={botsSupported} botsSupported={botsSupported}
selected={visitsFilter} selected={resolvedFilter}
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })} onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
/> />
</div> </div>

View file

@ -1,17 +1,18 @@
import { DeepPartial } from '@reduxjs/toolkit'; import { DeepPartial } from '@reduxjs/toolkit';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isEmpty, mergeDeepRight, pipe } from 'ramda'; import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda';
import { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals'; import { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals';
import { OrphanVisitType, VisitsFilter } from '../types'; import { OrphanVisitType, VisitsFilter } from '../types';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { formatIsoDate } from '../../utils/helpers/date'; import { formatIsoDate } from '../../utils/helpers/date';
import { BooleanString } from '../../utils/utils';
interface VisitsQuery { interface VisitsQuery {
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
orphanVisitsType?: OrphanVisitType; orphanVisitsType?: OrphanVisitType;
excludeBots?: 'true'; excludeBots?: BooleanString;
domain?: string; domain?: string;
} }
@ -38,7 +39,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
domain, domain,
filtering: { filtering: {
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined, dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined,
visitsFilter: { orphanVisitsType, excludeBots: excludeBots === 'true' }, visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined },
}, },
}), }),
), ),
@ -49,7 +50,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
const query: VisitsQuery = { const query: VisitsQuery = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '', startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '', endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: visitsFilter.excludeBots ? 'true' : undefined, excludeBots: visitsFilter.excludeBots ? 'true' : 'false',
orphanVisitsType: visitsFilter.orphanVisitsType, orphanVisitsType: visitsFilter.orphanVisitsType,
domain: theDomain, domain: theDomain,
}; };

View file

@ -17,6 +17,7 @@ describe('<VisitsSettings />', () => {
expect(screen.getByRole('heading')).toHaveTextContent('Visits'); expect(screen.getByRole('heading')).toHaveTextContent('Visits');
expect(screen.getByText('Default interval to load on visits sections:')).toBeInTheDocument(); expect(screen.getByText('Default interval to load on visits sections:')).toBeInTheDocument();
expect(screen.getByText(/^Exclude bots wherever possible/)).toBeInTheDocument();
}); });
it.each([ it.each([
@ -59,4 +60,36 @@ describe('<VisitsSettings />', () => {
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' });
expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' });
}); });
it.each([
[
Mock.all<Settings>(),
/The visits coming from potential bots will be included.$/,
/The visits coming from potential bots will be excluded.$/,
],
[
Mock.of<Settings>({ visits: { excludeBots: false } }),
/The visits coming from potential bots will be included.$/,
/The visits coming from potential bots will be excluded.$/,
],
[
Mock.of<Settings>({ visits: { excludeBots: true } }),
/The visits coming from potential bots will be excluded.$/,
/The visits coming from potential bots will be included.$/,
],
])('displays expected helper text for exclude bots control', (settings, expectedText, notExpectedText) => {
setUp(settings);
const visitsComponent = screen.getByText(/^Exclude bots wherever possible/);
expect(visitsComponent).toHaveTextContent(expectedText);
expect(visitsComponent).not.toHaveTextContent(notExpectedText);
});
it('invokes setVisitsSettings when bot exclusion is toggled', async () => {
const { user } = setUp();
await user.click(screen.getByText(/^Exclude bots wherever possible/));
expect(setVisitsSettings).toHaveBeenCalledWith(expect.objectContaining({ excludeBots: true }));
});
}); });

View file

@ -4,6 +4,7 @@ import { endOfDay, formatISO, startOfDay } from 'date-fns';
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar'; import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings';
import { DateRange } from '../../src/utils/helpers/dateIntervals'; import { DateRange } from '../../src/utils/helpers/dateIntervals';
import { formatDate } from '../../src/utils/helpers/date'; import { formatDate } from '../../src/utils/helpers/date';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
@ -30,6 +31,7 @@ describe('<ShortUrlsFilteringBar />', () => {
selectedServer={selectedServer ?? Mock.all<SelectedServer>()} selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
order={{}} order={{}}
handleOrderBy={handleOrderBy} handleOrderBy={handleOrderBy}
settings={Mock.of<Settings>({ visits: {} })}
/> />
</MemoryRouter>, </MemoryRouter>,
); );
@ -114,6 +116,24 @@ describe('<ShortUrlsFilteringBar />', () => {
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
}); });
it.each([
['', 'excludeBots=true'],
['excludeBots=false', 'excludeBots=true'],
['excludeBots=true', 'excludeBots=false'],
])('allows to toggle excluding bots through filtering dropdown', async (search, expectedQuery) => {
const { user } = setUp(
search,
Mock.of<ReachableServer>({ version: '3.4.0' }),
);
const toggleBots = async (name = 'Exclude bots visits') => {
await user.click(screen.getByRole('button', { name: 'Filters' }));
await user.click(await screen.findByRole('menuitem', { name }));
};
await toggleBots();
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedQuery));
});
it('handles order through dropdown', async () => { it('handles order through dropdown', async () => {
const { user } = setUp(); const { user } = setUp();
const clickMenuItem = async (name: string | RegExp) => { const clickMenuItem = async (name: string | RegExp) => {

View file

@ -9,6 +9,7 @@ import { ReachableServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable'; import { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
import { SemVer } from '../../src/utils/helpers/version';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@ -35,14 +36,14 @@ describe('<ShortUrlsList />', () => {
}, },
}); });
const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar); const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar);
const setUp = (defaultOrdering: ShortUrlsOrder = {}) => renderWithEvents( const setUp = (settings: Partial<Settings> = {}, version: SemVer = '3.0.0') => renderWithEvents(
<MemoryRouter> <MemoryRouter>
<ShortUrlsList <ShortUrlsList
{...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })} {...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })}
listShortUrls={listShortUrlsMock} listShortUrls={listShortUrlsMock}
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
selectedServer={Mock.of<ReachableServer>({ id: '1' })} selectedServer={Mock.of<ReachableServer>({ id: '1', version })}
settings={Mock.of<Settings>({ shortUrlsList: { defaultOrdering } })} settings={Mock.of<Settings>(settings)}
/> />
</MemoryRouter>, </MemoryRouter>,
); );
@ -82,11 +83,39 @@ describe('<ShortUrlsList />', () => {
it.each([ it.each([
[Mock.of<ShortUrlsOrder>({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'], [Mock.of<ShortUrlsOrder>({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'],
[Mock.of<ShortUrlsOrder>({ field: 'title', dir: 'DESC' }), 'title', 'DESC'], [Mock.of<ShortUrlsOrder>({ field: 'title', dir: 'DESC' }), 'title', 'DESC'],
[Mock.of<ShortUrlsOrder>(), undefined, undefined], [Mock.all<ShortUrlsOrder>(), undefined, undefined],
])('has expected initial ordering based on settings', (initialOrderBy, field, dir) => { ])('has expected initial ordering based on settings', (defaultOrdering, field, dir) => {
setUp(initialOrderBy); setUp({ shortUrlsList: { defaultOrdering } });
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({
orderBy: { field, dir }, orderBy: { field, dir },
})); }));
}); });
it.each([
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
}), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
visits: { excludeBots: true },
}), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
}), '3.4.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[Mock.of<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
visits: { excludeBots: true },
}), '3.4.0' as SemVer, { field: 'nonBotVisits', dir: 'ASC' }],
])('parses order by based on server version and config', (settings, serverVersion, expectedOrderBy) => {
setUp(settings, serverVersion);
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ orderBy: expectedOrderBy }));
});
}); });

View file

@ -2,15 +2,29 @@ import { screen } from '@testing-library/react';
import { last } from 'ramda'; import { last } from 'ramda';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, formatISO, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow'; import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow';
import { TimeoutToggle } from '../../../src/utils/helpers/hooks'; import { TimeoutToggle } from '../../../src/utils/helpers/hooks';
import { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data'; import { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
import { Settings } from '../../../src/settings/reducers/settings';
import { ReachableServer } from '../../../src/servers/data'; import { ReachableServer } from '../../../src/servers/data';
import { parseDate, now } from '../../../src/utils/helpers/date'; import { parseDate, now } from '../../../src/utils/helpers/date';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithEvents } from '../../__helpers__/setUpTest';
import { OptionalString } from '../../../src/utils/utils'; import { OptionalString } from '../../../src/utils/utils';
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
interface SetUpOptions {
title?: OptionalString;
tags?: string[];
meta?: ShortUrlMeta;
settings?: Partial<Settings>;
}
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockReturnValue({}),
}));
describe('<ShortUrlsRow />', () => { describe('<ShortUrlsRow />', () => {
const timeoutToggle = jest.fn(() => true); const timeoutToggle = jest.fn(() => true);
const useTimeoutToggle = jest.fn(() => [false, timeoutToggle]) as TimeoutToggle; const useTimeoutToggle = jest.fn(() => [false, timeoutToggle]) as TimeoutToggle;
@ -35,19 +49,24 @@ describe('<ShortUrlsRow />', () => {
}, },
}; };
const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle); const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle);
const setUp = (
{ title, tags = [], meta = {} }: { title?: OptionalString; tags?: string[]; meta?: ShortUrlMeta } = {}, const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => {
) => renderWithEvents( (useLocation as any).mockReturnValue({ search });
<table> return renderWithEvents(
<tbody> <MemoryRouter>
<ShortUrlsRow <table>
selectedServer={server} <tbody>
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }} <ShortUrlsRow
onTagClick={() => null} selectedServer={server}
/> shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
</tbody> onTagClick={() => null}
</table>, settings={Mock.of<Settings>(settings)}
); />
</tbody>
</table>
</MemoryRouter>,
);
};
it.each([ it.each([
[null, 7], [null, 7],
@ -97,9 +116,19 @@ describe('<ShortUrlsRow />', () => {
expectedContents.forEach((content) => expect(cell).toHaveTextContent(content)); expectedContents.forEach((content) => expect(cell).toHaveTextContent(content));
}); });
it('renders visits count in fifth row', () => { it.each([
setUp(); [{}, '', shortUrl.visitsSummary?.total],
expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${shortUrl.visitsCount}`); [Mock.of<Settings>({ visits: { excludeBots: false } }), '', shortUrl.visitsSummary?.total],
[Mock.of<Settings>({ visits: { excludeBots: true } }), '', shortUrl.visitsSummary?.nonBots],
[Mock.of<Settings>({ visits: { excludeBots: false } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[Mock.of<Settings>({ visits: { excludeBots: true } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[{}, 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[Mock.of<Settings>({ visits: { excludeBots: true } }), 'excludeBots=false', shortUrl.visitsSummary?.total],
[Mock.of<Settings>({ visits: { excludeBots: false } }), 'excludeBots=false', shortUrl.visitsSummary?.total],
[{}, 'excludeBots=false', shortUrl.visitsSummary?.total],
])('renders visits count in fifth row', (settings, search, expectedAmount) => {
setUp({ settings }, search);
expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${expectedAmount}`);
}); });
it('updates state when copied to clipboard', async () => { it('updates state when copied to clipboard', async () => {

View file

@ -1,4 +1,4 @@
import { capitalize, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; import { capitalize, nonEmptyValueOrNull, parseBooleanToString, rangeOf } from '../../src/utils/utils';
describe('utils', () => { describe('utils', () => {
describe('rangeOf', () => { describe('rangeOf', () => {
@ -49,4 +49,13 @@ describe('utils', () => {
expect(capitalize(value)).toEqual(expectedResult); expect(capitalize(value)).toEqual(expectedResult);
}); });
}); });
describe('parseBooleanToString', () => {
it.each([
[true, 'true'],
[false, 'false'],
])('parses value as expected', (value, expectedResult) => {
expect(parseBooleanToString(value)).toEqual(expectedResult);
});
});
}); });