Merge pull request #819 from acelaya-forks/feature/tags-non-bot-visits

Feature/tags non bot visits
This commit is contained in:
Alejandro Celaya 2023-03-19 11:54:54 +01:00 committed by GitHub
commit f6334c3618
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 177 additions and 35 deletions

View file

@ -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.

View file

@ -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,
}, },
}, },

View file

@ -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",

View file

@ -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;
} }

View file

@ -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(() => {

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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;

View file

@ -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;
}, {}); }, {});

View file

@ -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');

View file

@ -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();
});
}); });

View file

@ -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}

View file

@ -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', () => {