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]
### 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.
* [#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
* *Nothing*

View file

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

View file

@ -1,20 +1,39 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
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 {
settings: Settings;
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
}
const currentDefaultInterval = (settings: Settings): DateInterval => settings.visits?.defaultInterval ?? 'last30Days';
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<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:">
<DateIntervalSelector
allText="All visits"
active={settings.visits?.defaultInterval ?? 'last30Days'}
active={currentDefaultInterval(settings)}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/>
</LabeledFormGroup>

View file

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

View file

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

View file

@ -7,14 +7,15 @@ import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
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 { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableType } from './ShortUrlsTable';
import { Paginator } from './Paginator';
import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields } from './data';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
import { supportsExcludeBotsOnShortUrls } from '../utils/helpers/features';
interface ShortUrlsListProps {
selectedServer: SelectedServer;
@ -30,12 +31,13 @@ export const ShortUrlsList = (
const serverId = getServerId(selectedServer);
const { page } = useParams();
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(
// 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,
);
const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir });
@ -48,6 +50,13 @@ export const ShortUrlsList = (
(newTag: string) => [...new Set([...tags, newTag])],
(updatedTags) => toFirstPage({ tags: updatedTags }),
);
const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => {
if (supportsExcludeBotsOnShortUrls(selectedServer) && doExcludeBots && field === 'visits') {
return { field: 'nonBotVisits', dir };
}
return { field, dir };
};
useEffect(() => {
listShortUrls({
@ -56,10 +65,10 @@ export const ShortUrlsList = (
tags,
startDate,
endDate,
orderBy: actualOrderBy,
orderBy: parseOrderByForShlink(actualOrderBy),
tagsMode,
});
}, [page, search, tags, startDate, endDate, actualOrderBy, tagsMode]);
}, [page, search, tags, startDate, endDate, actualOrderBy.field, actualOrderBy.dir, tagsMode]);
return (
<>
@ -68,6 +77,7 @@ export const ShortUrlsList = (
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
settings={settings}
className="mb-3"
/>
<Card body className="pb-0">

View file

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

View file

@ -52,7 +52,7 @@ export const ExportShortUrlsBtn = (
longUrl: shortUrl.longUrl,
title: shortUrl.title ?? '',
tags: shortUrl.tags.join(','),
visits: shortUrl.visitsCount,
visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount,
})));
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 { ColorGenerator } from '../../utils/services/ColorGenerator';
import { TimeoutToggle } from '../../utils/helpers/hooks';
@ -6,10 +6,12 @@ import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { ShortUrl } from '../data';
import { Time } from '../../utils/dates/Time';
import { Settings } from '../../settings/reducers/settings';
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu';
import { Tags } from './Tags';
import { ShortUrlStatus } from './ShortUrlStatus';
import { useShortUrlsQuery } from './hooks';
import './ShortUrlsRow.scss';
interface ShortUrlsRowProps {
@ -18,19 +20,28 @@ interface ShortUrlsRowProps {
shortUrl: ShortUrl;
}
interface ShortUrlsRowConnectProps extends ShortUrlsRowProps {
settings: Settings;
}
export type ShortUrlsRowType = FC<ShortUrlsRowProps>;
export const ShortUrlsRow = (
ShortUrlsRowMenu: ShortUrlsRowMenuType,
colorGenerator: ColorGenerator,
useTimeoutToggle: TimeoutToggle,
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
) => ({ shortUrl, selectedServer, onTagClick, settings }: ShortUrlsRowConnectProps) => {
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
const [active, setActive] = useTimeoutToggle(false, 500);
const isFirstRun = useRef(true);
const [{ excludeBots }] = useShortUrlsQuery();
const { visits } = settings;
const doExcludeBots = excludeBots ?? visits?.excludeBots;
useEffect(() => {
!isFirstRun.current && setActive();
isFirstRun.current = false;
}, [shortUrl.visitsCount]);
}, [shortUrl.visitsSummary?.total, shortUrl.visitsSummary?.nonBots, shortUrl.visitsCount]);
return (
<tr className="responsive-table__row">
@ -64,7 +75,9 @@ export const ShortUrlsRow = (
</td>
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
<ShortUrlVisitsCount
visitsCount={shortUrl.visitsSummary?.total ?? shortUrl.visitsCount}
visitsCount={(
doExcludeBots ? shortUrl.visitsSummary?.nonBots : shortUrl.visitsSummary?.total
) ?? shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
active={active}
@ -79,5 +92,3 @@ export const ShortUrlsRow = (
</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 { orderToString, stringToOrder } from '../../utils/helpers/ordering';
import { TagsFilteringMode } from '../../api/types';
import { BooleanString, parseBooleanToString } from '../../utils/utils';
interface ShortUrlsQueryCommon {
search?: string;
@ -16,11 +17,13 @@ interface ShortUrlsQueryCommon {
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string;
tags?: string;
excludeBots?: BooleanString;
}
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder;
tags: string[];
excludeBots?: boolean;
}
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
@ -33,20 +36,26 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const filtering = useMemo(
pipe(
() => parseQuery<ShortUrlsQuery>(search),
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
({ orderBy, tags, excludeBots, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
const parsedTags = tags?.split(',') ?? [];
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
return {
...rest,
orderBy: parsedOrderBy,
tags: parsedTags,
excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined,
};
},
),
[search],
);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, tags, ...mergedFiltering } = { ...filtering, ...extra };
const { orderBy, tags, excludeBots, ...mergedFiltering } = { ...filtering, ...extra };
const query: ShortUrlsQuery = {
...mergedFiltering,
orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined,
excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
};
const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;

View file

@ -1,5 +1,5 @@
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 { createNewVisits } from '../../visits/reducers/visitCreation';
import { createAsyncThunk } from '../../utils/helpers/redux';
@ -101,18 +101,12 @@ export const shortUrlsListReducerCreator = (
(state, { payload }) => assocPath(
['shortUrls', 'data'],
state.shortUrls?.data?.map(
(currentShortUrl) => {
// Find the last of the new visit for this short URL, and pick the amount of visits from it
const lastVisit = last(
payload.createdVisits.filter(
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
),
);
return lastVisit?.shortUrl
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
: 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.
(currentShortUrl) => last(
payload.createdVisits.filter(
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
),
)?.shortUrl ?? currentShortUrl,
),
state,
),

View file

@ -28,7 +28,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
bottle.decorator('ShortUrlsRow', connect(['settings']));
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
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 supportsAllTagsFiltering = supportsNonOrphanVisits;
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 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],
);
const mapLocations = values(citiesForMap);
const resolvedFilter = useMemo(() => (
!isFirstLoad.current ? visitsFilter : {
...visitsFilter,
excludeBots: visitsFilter.excludeBots ?? settings.visits?.excludeBots,
}
), [visitsFilter]);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
selectedBar = undefined;
@ -115,7 +121,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
useEffect(() => cancelGetVisits, []);
useEffect(() => {
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;
}, [dateRange, visitsFilter]);
useEffect(() => {
@ -301,7 +307,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
className="ms-0 ms-md-2 mt-3 mt-md-0"
isOrphanVisits={isOrphanVisits}
botsSupported={botsSupported}
selected={visitsFilter}
selected={resolvedFilter}
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
/>
</div>

View file

@ -1,17 +1,18 @@
import { DeepPartial } from '@reduxjs/toolkit';
import { useLocation, useNavigate } from 'react-router-dom';
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 { OrphanVisitType, VisitsFilter } from '../types';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { formatIsoDate } from '../../utils/helpers/date';
import { BooleanString } from '../../utils/utils';
interface VisitsQuery {
startDate?: string;
endDate?: string;
orphanVisitsType?: OrphanVisitType;
excludeBots?: 'true';
excludeBots?: BooleanString;
domain?: string;
}
@ -38,7 +39,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
domain,
filtering: {
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 = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: visitsFilter.excludeBots ? 'true' : undefined,
excludeBots: visitsFilter.excludeBots ? 'true' : 'false',
orphanVisitsType: visitsFilter.orphanVisitsType,
domain: theDomain,
};

View file

@ -17,6 +17,7 @@ describe('<VisitsSettings />', () => {
expect(screen.getByRole('heading')).toHaveTextContent('Visits');
expect(screen.getByText('Default interval to load on visits sections:')).toBeInTheDocument();
expect(screen.getByText(/^Exclude bots wherever possible/)).toBeInTheDocument();
});
it.each([
@ -59,4 +60,36 @@ describe('<VisitsSettings />', () => {
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' });
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 { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings';
import { DateRange } from '../../src/utils/helpers/dateIntervals';
import { formatDate } from '../../src/utils/helpers/date';
import { renderWithEvents } from '../__helpers__/setUpTest';
@ -30,6 +31,7 @@ describe('<ShortUrlsFilteringBar />', () => {
selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
order={{}}
handleOrderBy={handleOrderBy}
settings={Mock.of<Settings>({ visits: {} })}
/>
</MemoryRouter>,
);
@ -114,6 +116,24 @@ describe('<ShortUrlsFilteringBar />', () => {
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 () => {
const { user } = setUp();
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 { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable';
import { renderWithEvents } from '../__helpers__/setUpTest';
import { SemVer } from '../../src/utils/helpers/version';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -35,14 +36,14 @@ describe('<ShortUrlsList />', () => {
},
});
const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar);
const setUp = (defaultOrdering: ShortUrlsOrder = {}) => renderWithEvents(
const setUp = (settings: Partial<Settings> = {}, version: SemVer = '3.0.0') => renderWithEvents(
<MemoryRouter>
<ShortUrlsList
{...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })}
listShortUrls={listShortUrlsMock}
shortUrlsList={shortUrlsList}
selectedServer={Mock.of<ReachableServer>({ id: '1' })}
settings={Mock.of<Settings>({ shortUrlsList: { defaultOrdering } })}
selectedServer={Mock.of<ReachableServer>({ id: '1', version })}
settings={Mock.of<Settings>(settings)}
/>
</MemoryRouter>,
);
@ -82,11 +83,39 @@ describe('<ShortUrlsList />', () => {
it.each([
[Mock.of<ShortUrlsOrder>({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'],
[Mock.of<ShortUrlsOrder>({ field: 'title', dir: 'DESC' }), 'title', 'DESC'],
[Mock.of<ShortUrlsOrder>(), undefined, undefined],
])('has expected initial ordering based on settings', (initialOrderBy, field, dir) => {
setUp(initialOrderBy);
[Mock.all<ShortUrlsOrder>(), undefined, undefined],
])('has expected initial ordering based on settings', (defaultOrdering, field, dir) => {
setUp({ shortUrlsList: { defaultOrdering } });
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({
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 { Mock } from 'ts-mockery';
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 { TimeoutToggle } from '../../../src/utils/helpers/hooks';
import { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
import { Settings } from '../../../src/settings/reducers/settings';
import { ReachableServer } from '../../../src/servers/data';
import { parseDate, now } from '../../../src/utils/helpers/date';
import { renderWithEvents } from '../../__helpers__/setUpTest';
import { OptionalString } from '../../../src/utils/utils';
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 />', () => {
const timeoutToggle = jest.fn(() => true);
const useTimeoutToggle = jest.fn(() => [false, timeoutToggle]) as TimeoutToggle;
@ -35,19 +49,24 @@ describe('<ShortUrlsRow />', () => {
},
};
const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle);
const setUp = (
{ title, tags = [], meta = {} }: { title?: OptionalString; tags?: string[]; meta?: ShortUrlMeta } = {},
) => renderWithEvents(
<table>
<tbody>
<ShortUrlsRow
selectedServer={server}
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
onTagClick={() => null}
/>
</tbody>
</table>,
);
const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => {
(useLocation as any).mockReturnValue({ search });
return renderWithEvents(
<MemoryRouter>
<table>
<tbody>
<ShortUrlsRow
selectedServer={server}
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
onTagClick={() => null}
settings={Mock.of<Settings>(settings)}
/>
</tbody>
</table>
</MemoryRouter>,
);
};
it.each([
[null, 7],
@ -97,9 +116,19 @@ describe('<ShortUrlsRow />', () => {
expectedContents.forEach((content) => expect(cell).toHaveTextContent(content));
});
it('renders visits count in fifth row', () => {
setUp();
expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${shortUrl.visitsCount}`);
it.each([
[{}, '', shortUrl.visitsSummary?.total],
[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 () => {

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('rangeOf', () => {
@ -49,4 +49,13 @@ describe('utils', () => {
expect(capitalize(value)).toEqual(expectedResult);
});
});
describe('parseBooleanToString', () => {
it.each([
[true, 'true'],
[false, 'false'],
])('parses value as expected', (value, expectedResult) => {
expect(parseBooleanToString(value)).toEqual(expectedResult);
});
});
});