mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 01:20:24 +03:00
Merge pull request #772 from acelaya-forks/feature/exclude-bots
Feature/exclude bots
This commit is contained in:
commit
bfcdf703e8
22 changed files with 311 additions and 68 deletions
|
@ -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*
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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‘s effect might depend on Shlink server‘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>
|
||||
|
|
|
@ -34,6 +34,7 @@ export interface UiSettings {
|
|||
|
||||
export interface VisitsSettings {
|
||||
defaultInterval: DateInterval;
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface TagsSettings {
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -80,3 +80,7 @@ export interface ExportableShortUrl {
|
|||
tags: string;
|
||||
visits: number;
|
||||
}
|
||||
|
||||
export interface ShortUrlsFilter {
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
38
src/short-urls/helpers/ShortUrlsFilterDropdown.tsx
Normal file
38
src/short-urls/helpers/ShortUrlsFilterDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>;
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue