mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #819 from acelaya-forks/feature/tags-non-bot-visits
Feature/tags non bot visits
This commit is contained in:
commit
f6334c3618
13 changed files with 177 additions and 35 deletions
|
@ -4,10 +4,11 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
## [Unreleased]
|
## [3.10.0] - 2023-03-19
|
||||||
### Added
|
### Added
|
||||||
* [#807](https://github.com/shlinkio/shlink-web-client/issues/807) Add support for device-specific long-URLs when creating or editing short URLs.
|
* [#807](https://github.com/shlinkio/shlink-web-client/issues/807) Add support for device-specific long-URLs when creating or editing short URLs.
|
||||||
* [#808](https://github.com/shlinkio/shlink-web-client/issues/808) Respect settings on excluding bots in the overview section, for visits cards.
|
* [#808](https://github.com/shlinkio/shlink-web-client/issues/808) Respect settings on excluding bots in the overview section, for visits cards.
|
||||||
|
* [#809](https://github.com/shlinkio/shlink-web-client/issues/809) Respect settings on excluding bots in the tags list.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#798](https://github.com/shlinkio/shlink-web-client/issues/798) Remove stryker and mutation testing.
|
* [#798](https://github.com/shlinkio/shlink-web-client/issues/798) Remove stryker and mutation testing.
|
||||||
|
|
|
@ -10,8 +10,8 @@ module.exports = {
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
statements: 90,
|
statements: 90,
|
||||||
branches: 80,
|
branches: 85,
|
||||||
functions: 85,
|
functions: 90,
|
||||||
lines: 90,
|
lines: 90,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,9 +12,10 @@
|
||||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||||
"lint:css:fix": "npm run lint:css -- --fix",
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
|
"types": "tsc",
|
||||||
"start": "vite serve --host=0.0.0.0",
|
"start": "vite serve --host=0.0.0.0",
|
||||||
"preview": "vite preview --host=0.0.0.0",
|
"preview": "vite preview --host=0.0.0.0",
|
||||||
"build": "tsc --noEmit && vite build && node scripts/replace-version.mjs",
|
"build": "npm run types && vite build && node scripts/replace-version.mjs",
|
||||||
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
|
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
|
||||||
"test": "jest --env=jsdom --colors",
|
"test": "jest --env=jsdom --colors",
|
||||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||||
|
|
|
@ -18,9 +18,12 @@ export interface ShlinkHealth {
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShlinkTagsStats {
|
export interface ShlinkTagsStats {
|
||||||
tag: string;
|
tag: string;
|
||||||
shortUrlsCount: number;
|
shortUrlsCount: number;
|
||||||
|
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.5.0
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
visitsCount: number;
|
visitsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { Message } from '../utils/Message';
|
||||||
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { SearchField } from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import type { NormalizedTag } from './data';
|
import type { SimplifiedTag } from './data';
|
||||||
import type { TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps';
|
import type { TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps';
|
||||||
import { TAGS_ORDERABLE_FIELDS } from './data/TagsListChildrenProps';
|
import { TAGS_ORDERABLE_FIELDS } from './data/TagsListChildrenProps';
|
||||||
import type { TagsList as TagsListState } from './reducers/tagsList';
|
import type { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
|
@ -31,12 +31,19 @@ export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||||
) => {
|
) => {
|
||||||
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
|
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
|
||||||
const resolveSortedTags = pipe(
|
const resolveSortedTags = pipe(
|
||||||
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
() => tagsList.filteredTags.map((tag): SimplifiedTag => {
|
||||||
tag,
|
const theTag = tagsList.stats[tag];
|
||||||
shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0,
|
const visits = (
|
||||||
visits: tagsList.stats[tag]?.visitsCount ?? 0,
|
settings.visits?.excludeBots ? theTag?.visitsSummary?.nonBots : theTag?.visitsSummary?.total
|
||||||
})),
|
) ?? theTag?.visitsCount ?? 0;
|
||||||
(normalizedTags) => sortList<NormalizedTag>(normalizedTags, order),
|
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
visits,
|
||||||
|
shortUrls: theTag?.shortUrlsCount ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
(simplifiedTags) => sortList<SimplifiedTag>(simplifiedTags, order),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -9,11 +9,11 @@ import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import type { ColorGenerator } from '../utils/services/ColorGenerator';
|
import type { ColorGenerator } from '../utils/services/ColorGenerator';
|
||||||
import type { NormalizedTag, TagModalProps } from './data';
|
import type { SimplifiedTag, TagModalProps } from './data';
|
||||||
import { TagBullet } from './helpers/TagBullet';
|
import { TagBullet } from './helpers/TagBullet';
|
||||||
|
|
||||||
export interface TagsTableRowProps {
|
export interface TagsTableRowProps {
|
||||||
tag: NormalizedTag;
|
tag: SimplifiedTag;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { SelectedServer } from '../../servers/data';
|
import type { SelectedServer } from '../../servers/data';
|
||||||
import type { Order } from '../../utils/helpers/ordering';
|
import type { Order } from '../../utils/helpers/ordering';
|
||||||
import type { NormalizedTag } from './index';
|
import type { SimplifiedTag } from './index';
|
||||||
|
|
||||||
export const TAGS_ORDERABLE_FIELDS = {
|
export const TAGS_ORDERABLE_FIELDS = {
|
||||||
tag: 'Tag',
|
tag: 'Tag',
|
||||||
|
@ -13,6 +13,6 @@ export type TagsOrderableFields = keyof typeof TAGS_ORDERABLE_FIELDS;
|
||||||
export type TagsOrder = Order<TagsOrderableFields>;
|
export type TagsOrder = Order<TagsOrderableFields>;
|
||||||
|
|
||||||
export interface TagsListChildrenProps {
|
export interface TagsListChildrenProps {
|
||||||
sortedTags: NormalizedTag[];
|
sortedTags: SimplifiedTag[];
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
export interface TagStats {
|
import type { ShlinkTagsStats } from '../../api/types';
|
||||||
shortUrlsCount: number;
|
|
||||||
visitsCount: number;
|
export type TagStats = Omit<ShlinkTagsStats, 'tag'>;
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagModalProps {
|
export interface TagModalProps {
|
||||||
tag: string;
|
tag: string;
|
||||||
|
@ -9,7 +8,7 @@ export interface TagModalProps {
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedTag {
|
export interface SimplifiedTag {
|
||||||
tag: string;
|
tag: string;
|
||||||
shortUrls: number;
|
shortUrls: number;
|
||||||
visits: number;
|
visits: number;
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation'
|
||||||
import { supportedFeatures } from '../../utils/helpers/features';
|
import { supportedFeatures } from '../../utils/helpers/features';
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||||
import type { CreateVisit, Stats } from '../../visits/types';
|
import type { CreateVisit } from '../../visits/types';
|
||||||
import type { TagStats } from '../data';
|
import type { TagStats } from '../data';
|
||||||
import { tagDeleted } from './tagDelete';
|
import { tagDeleted } from './tagDelete';
|
||||||
import { tagEdited } from './tagEdit';
|
import { tagEdited } from './tagEdit';
|
||||||
|
@ -39,7 +39,8 @@ const initialState: TagsList = {
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type TagIncrease = [string, number];
|
type TagIncreaseRecord = Record<string, { bots: number; nonBots: number }>;
|
||||||
|
type TagIncrease = [string, { bots: number; nonBots: number }];
|
||||||
|
|
||||||
const renameTag = (oldName: string, newName: string) => (tag: string) => (tag === oldName ? newName : tag);
|
const renameTag = (oldName: string, newName: string) => (tag: string) => (tag === oldName ? newName : tag);
|
||||||
const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags);
|
const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags);
|
||||||
|
@ -48,20 +49,34 @@ const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags
|
||||||
return theStats;
|
return theStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { bots, nonBots } = increase;
|
||||||
const tagStats = theStats[tag];
|
const tagStats = theStats[tag];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...theStats,
|
...theStats,
|
||||||
[tag]: {
|
[tag]: {
|
||||||
...tagStats,
|
...tagStats,
|
||||||
visitsCount: tagStats.visitsCount + increase,
|
visitsSummary: tagStats.visitsSummary && {
|
||||||
|
total: tagStats.visitsSummary.total + bots + nonBots,
|
||||||
|
bots: tagStats.visitsSummary.bots + bots,
|
||||||
|
nonBots: tagStats.visitsSummary.nonBots + nonBots,
|
||||||
|
},
|
||||||
|
visitsCount: tagStats.visitsCount + bots + nonBots,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, { ...stats });
|
}, { ...stats });
|
||||||
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
||||||
createdVisits.reduce<Stats>((acc, { shortUrl }) => {
|
createdVisits.reduce<TagIncreaseRecord>((acc, { shortUrl, visit }) => {
|
||||||
shortUrl?.tags.forEach((tag) => {
|
shortUrl?.tags.forEach((tag) => {
|
||||||
acc[tag] = (acc[tag] || 0) + 1;
|
if (!acc[tag]) {
|
||||||
|
acc[tag] = { bots: 0, nonBots: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visit.potentialBot) {
|
||||||
|
acc[tag].bots += 1;
|
||||||
|
} else {
|
||||||
|
acc[tag].nonBots += 1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
|
@ -78,12 +93,11 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
||||||
}
|
}
|
||||||
|
|
||||||
const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState);
|
const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState);
|
||||||
const { tags, stats = [] }: ShlinkTags = await (
|
const { tags, stats }: ShlinkTags = await (
|
||||||
supportedFeatures.tagsStats(selectedServer) ? tagsStats() : shlinkListTags()
|
supportedFeatures.tagsStats(selectedServer) ? tagsStats() : shlinkListTags()
|
||||||
);
|
);
|
||||||
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => {
|
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, ...rest }) => {
|
||||||
acc[tag] = { shortUrlsCount, visitsCount };
|
acc[tag] = rest;
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited']));
|
bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited']));
|
||||||
|
|
||||||
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||||
|
bottle.decorator('TagsTableRow', connect(['settings']));
|
||||||
|
|
||||||
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
|
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
|
||||||
|
|
||||||
|
|
|
@ -10,15 +10,15 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<TagsList />', () => {
|
describe('<TagsList />', () => {
|
||||||
const filterTags = jest.fn();
|
const filterTags = jest.fn();
|
||||||
const TagsListComp = createTagsList(() => <>TagsTable</>);
|
const TagsListComp = createTagsList(({ sortedTags }) => <>TagsTable ({sortedTags.map((t) => t.visits).join(',')})</>);
|
||||||
const setUp = (tagsList: Partial<TagsList>) => renderWithEvents(
|
const setUp = (tagsList: Partial<TagsList>, excludeBots = false) => renderWithEvents(
|
||||||
<TagsListComp
|
<TagsListComp
|
||||||
{...Mock.all<TagsListProps>()}
|
{...Mock.all<TagsListProps>()}
|
||||||
{...Mock.of<MercureBoundProps>({ mercureInfo: {} })}
|
{...Mock.of<MercureBoundProps>({ mercureInfo: {} })}
|
||||||
forceListTags={identity}
|
forceListTags={identity}
|
||||||
filterTags={filterTags}
|
filterTags={filterTags}
|
||||||
tagsList={Mock.of<TagsList>(tagsList)}
|
tagsList={Mock.of<TagsList>(tagsList)}
|
||||||
settings={Mock.all<Settings>()}
|
settings={Mock.of<Settings>({ visits: { excludeBots } })}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -53,4 +53,49 @@ describe('<TagsList />', () => {
|
||||||
await user.type(screen.getByPlaceholderText('Search...'), 'Hello');
|
await user.type(screen.getByPlaceholderText('Search...'), 'Hello');
|
||||||
await waitFor(() => expect(filterTags).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(filterTags).toHaveBeenCalledTimes(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, undefined, '25,25,25'],
|
||||||
|
[true, undefined, '25,25,25'],
|
||||||
|
[
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
total: 20,
|
||||||
|
nonBots: 15,
|
||||||
|
bots: 5,
|
||||||
|
},
|
||||||
|
'20,20,20',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
total: 20,
|
||||||
|
nonBots: 15,
|
||||||
|
bots: 5,
|
||||||
|
},
|
||||||
|
'15,15,15',
|
||||||
|
],
|
||||||
|
])('displays proper amount of visits', (excludeBots, visitsSummary, expectedAmounts) => {
|
||||||
|
setUp({
|
||||||
|
filteredTags: ['foo', 'bar', 'baz'],
|
||||||
|
stats: {
|
||||||
|
foo: {
|
||||||
|
visitsSummary,
|
||||||
|
visitsCount: 25,
|
||||||
|
shortUrlsCount: 1,
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
visitsSummary,
|
||||||
|
visitsCount: 25,
|
||||||
|
shortUrlsCount: 1,
|
||||||
|
},
|
||||||
|
baz: {
|
||||||
|
visitsSummary,
|
||||||
|
visitsCount: 25,
|
||||||
|
shortUrlsCount: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, excludeBots);
|
||||||
|
expect(screen.getByText(`TagsTable (${expectedAmounts})`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import type { SelectedServer } from '../../src/servers/data';
|
import type { SelectedServer } from '../../src/servers/data';
|
||||||
import type { NormalizedTag } from '../../src/tags/data';
|
import type { SimplifiedTag } from '../../src/tags/data';
|
||||||
import { TagsTable as createTagsTable } from '../../src/tags/TagsTable';
|
import { TagsTable as createTagsTable } from '../../src/tags/TagsTable';
|
||||||
import { rangeOf } from '../../src/utils/utils';
|
import { rangeOf } from '../../src/utils/utils';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
@ -17,7 +17,7 @@ describe('<TagsTable />', () => {
|
||||||
(useLocation as any).mockReturnValue({ search });
|
(useLocation as any).mockReturnValue({ search });
|
||||||
return renderWithEvents(
|
return renderWithEvents(
|
||||||
<TagsTable
|
<TagsTable
|
||||||
sortedTags={sortedTags.map((tag) => Mock.of<NormalizedTag>({ tag }))}
|
sortedTags={sortedTags.map((tag) => Mock.of<SimplifiedTag>({ tag }))}
|
||||||
selectedServer={Mock.all<SelectedServer>()}
|
selectedServer={Mock.all<SelectedServer>()}
|
||||||
currentOrder={{}}
|
currentOrder={{}}
|
||||||
orderByColumn={() => orderByColumn}
|
orderByColumn={() => orderByColumn}
|
||||||
|
|
|
@ -11,6 +11,8 @@ import {
|
||||||
listTags as listTagsCreator,
|
listTags as listTagsCreator,
|
||||||
tagsListReducerCreator,
|
tagsListReducerCreator,
|
||||||
} from '../../../src/tags/reducers/tagsList';
|
} from '../../../src/tags/reducers/tagsList';
|
||||||
|
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||||
|
import type { CreateVisit, Visit } from '../../../src/visits/types';
|
||||||
|
|
||||||
describe('tagsListReducer', () => {
|
describe('tagsListReducer', () => {
|
||||||
const state = (props: Partial<TagsList>) => Mock.of<TagsList>(props);
|
const state = (props: Partial<TagsList>) => Mock.of<TagsList>(props);
|
||||||
|
@ -118,6 +120,75 @@ describe('tagsListReducer', () => {
|
||||||
tags: expectedTags,
|
tags: expectedTags,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('increases amounts when visits are created', () => {
|
||||||
|
const createdVisits = [
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
shortUrl: Mock.of<ShortUrl>({ tags: ['foo', 'bar'] }),
|
||||||
|
visit: Mock.of<Visit>({ potentialBot: true }),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
shortUrl: Mock.of<ShortUrl>({ tags: ['foo', 'bar'] }),
|
||||||
|
visit: Mock.all<Visit>(),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
shortUrl: Mock.of<ShortUrl>({ tags: ['bar'] }),
|
||||||
|
visit: Mock.all<Visit>(),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
shortUrl: Mock.of<ShortUrl>({ tags: ['baz'] }),
|
||||||
|
visit: Mock.of<Visit>({ potentialBot: true }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const tagStats = (total: number) => ({
|
||||||
|
shortUrlsCount: 1,
|
||||||
|
visitsCount: total,
|
||||||
|
visitsSummary: {
|
||||||
|
total,
|
||||||
|
nonBots: total - 10,
|
||||||
|
bots: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stateBefore = state({
|
||||||
|
stats: {
|
||||||
|
foo: tagStats(100),
|
||||||
|
bar: tagStats(200),
|
||||||
|
baz: tagStats(150),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reducer(stateBefore, createNewVisits(createdVisits))).toEqual(expect.objectContaining({
|
||||||
|
stats: {
|
||||||
|
foo: {
|
||||||
|
shortUrlsCount: 1,
|
||||||
|
visitsCount: 100 + 2,
|
||||||
|
visitsSummary: {
|
||||||
|
total: 100 + 2,
|
||||||
|
nonBots: 90 + 1,
|
||||||
|
bots: 10 + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
shortUrlsCount: 1,
|
||||||
|
visitsCount: 200 + 3,
|
||||||
|
visitsSummary: {
|
||||||
|
total: 200 + 3,
|
||||||
|
nonBots: 190 + 2,
|
||||||
|
bots: 10 + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baz: {
|
||||||
|
shortUrlsCount: 1,
|
||||||
|
visitsCount: 150 + 1,
|
||||||
|
visitsSummary: {
|
||||||
|
total: 150 + 1,
|
||||||
|
nonBots: 140,
|
||||||
|
bots: 10 + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterTags', () => {
|
describe('filterTags', () => {
|
||||||
|
|
Loading…
Reference in a new issue