mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +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]
|
## [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*
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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‘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:">
|
<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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,12 +73,22 @@ 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">
|
||||||
|
<div className="d-md-flex">
|
||||||
|
<div className="flex-fill">
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={datesToDateRange(startDate, endDate)}
|
initialDateRange={datesToDateRange(startDate, endDate)}
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="col-6 col-lg-4 col-xl-6 mt-3">
|
||||||
<ExportShortUrlsBtn amount={shortUrlsAmount} />
|
<ExportShortUrlsBtn amount={shortUrlsAmount} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -80,3 +80,7 @@ export interface ExportableShortUrl {
|
||||||
tags: string;
|
tags: string;
|
||||||
visits: number;
|
visits: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlsFilter {
|
||||||
|
excludeBots?: boolean;
|
||||||
|
}
|
||||||
|
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
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 { 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>;
|
|
||||||
|
|
|
@ -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}`;
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 });
|
||||||
|
return renderWithEvents(
|
||||||
|
<MemoryRouter>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<ShortUrlsRow
|
<ShortUrlsRow
|
||||||
selectedServer={server}
|
selectedServer={server}
|
||||||
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
|
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
|
||||||
onTagClick={() => null}
|
onTagClick={() => null}
|
||||||
|
settings={Mock.of<Settings>(settings)}
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>,
|
</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 () => {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue