diff --git a/CHANGELOG.md b/CHANGELOG.md index d2844e1a..4e47fbd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#808](https://github.com/shlinkio/shlink-web-client/issues/808) Respect settings on excluding bots in the overview section, for visits cards. ### Changed -* Update to Vite 4.1 -* Update to coding standard v2.1.0 * [#798](https://github.com/shlinkio/shlink-web-client/issues/798) Remove stryker and mutation testing. +* [#800](https://github.com/shlinkio/shlink-web-client/issues/800) Use `/tags/stats` endpoint to load tags stats, when the server supports it. +* Update to Vite 4.2 +* Update to TypeScript 5 +* Update to coding standard v2.1.0 +* Decouple tests from RTK internals. ### Deprecated * *Nothing* diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 56f1f34b..97b6774a 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -16,6 +16,7 @@ import type { ShlinkShortUrlsResponse, ShlinkTags, ShlinkTagsResponse, + ShlinkTagsStatsResponse, ShlinkVisits, ShlinkVisitsOverview, ShlinkVisitsParams, @@ -90,6 +91,11 @@ export class ShlinkApiClient { .then(({ tags }) => tags) .then(({ data, stats }) => ({ tags: data, stats })); + public readonly tagsStats = async (): Promise => + this.performRequest<{ tags: ShlinkTagsStatsResponse }>('/tags/stats', 'GET') + .then(({ tags }) => tags) + .then(({ data }) => ({ tags: data.map(({ tag }) => tag), stats: data })); + public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> => this.performEmptyRequest('/tags', 'DELETE', { tags }).then(() => ({ tags })); diff --git a/src/api/types/index.ts b/src/api/types/index.ts index a06515f4..59c4e05b 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -31,9 +31,14 @@ export interface ShlinkTags { export interface ShlinkTagsResponse { data: string[]; + /** @deprecated Present only when withStats=true is provided, which is deprecated */ stats: ShlinkTagsStats[]; } +export interface ShlinkTagsStatsResponse { + data: ShlinkTagsStats[]; +} + export interface ShlinkPaginator { currentPage: number; pagesCount: number; diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index d6726817..e96c8a23 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -5,6 +5,7 @@ import type { ShlinkTags } from '../../api/types'; import type { ProblemDetailsError } from '../../api/types/errors'; import { parseApiError } from '../../api/utils'; import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation'; +import { supportedFeatures } from '../../utils/helpers/features'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { createNewVisits } from '../../visits/reducers/visitCreation'; import type { CreateVisit, Stats } from '../../visits/types'; @@ -70,14 +71,16 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk( `${REDUCER_PREFIX}/listTags`, async (_: void, { getState }): Promise => { - const { tagsList } = getState(); + const { tagsList, selectedServer } = getState(); if (!force && !isEmpty(tagsList.tags)) { return tagsList; } - const { listTags: shlinkListTags } = buildShlinkApiClient(getState); - const { tags, stats = [] }: ShlinkTags = await shlinkListTags(); + const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState); + const { tags, stats = [] }: ShlinkTags = await ( + supportedFeatures.tagsStats(selectedServer) ? tagsStats() : shlinkListTags() + ); const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => { acc[tag] = { shortUrlsCount, visitsCount }; diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 388c85e0..ca1010ec 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -14,11 +14,12 @@ export const supportedFeatures = { defaultDomainRedirectsEdition: matchesMinVersion('2.10.0'), nonOrphanVisits: matchesMinVersion('3.0.0'), allTagsFiltering: matchesMinVersion('3.0.0'), + tagsStats: matchesMinVersion('3.0.0'), domainVisits: matchesMinVersion('3.1.0'), excludeBotsOnShortUrls: matchesMinVersion('3.4.0'), filterDisabledUrls: matchesMinVersion('3.4.0'), deviceLongUrls: matchesMinVersion('3.5.0'), -} as const; +} as const satisfies Record>; Object.freeze(supportedFeatures); diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 4215e088..408f6903 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -212,6 +212,28 @@ describe('ShlinkApiClient', () => { }); }); + describe('tagsStats', () => { + it('can use /tags/stats endpoint', async () => { + const expectedTags = ['foo', 'bar']; + const expectedStats = expectedTags.map((tag) => ({ tag, shortUrlsCount: 10, visitsCount: 10 })); + + fetchJson.mockResolvedValue({ + tags: { + data: expectedStats, + }, + }); + const { tagsStats } = buildApiClient(); + + const result = await tagsStats(); + + expect({ tags: expectedTags, stats: expectedStats }).toEqual(result); + expect(fetchJson).toHaveBeenCalledWith( + expect.stringContaining('/tags/stats'), + expect.objectContaining({ method: 'GET' }), + ); + }); + }); + describe('deleteTags', () => { it('properly deletes provided tags', async () => { const tags = ['foo', 'bar'];