diff --git a/CHANGELOG.md b/CHANGELOG.md index 97339b27..6af3aaa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), This feature also comes with a new setting to disable visits from bots by default, both on short URLs lists and visits sections. +* [#760](https://github.com/shlinkio/shlink-web-client/issues/760) Added support to exclude short URLs which have reached the maximum amount of visits, or are valid until a date in the past. + ### Changed * [#753](https://github.com/shlinkio/shlink-web-client/issues/753) Migrated from react-scripts/webpack to vite. * [#770](https://github.com/shlinkio/shlink-web-client/issues/770) Updated to latest dependencies. diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 8617a30a..d3f1626b 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -24,9 +24,14 @@ import { HttpClient } from '../../common/services/HttpClient'; const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`; const rejectNilProps = reject(isNil); -const normalizeOrderByInParams = ( - { orderBy = {}, ...rest }: ShlinkShortUrlsListParams, -): ShlinkShortUrlsListNormalizedParams => ({ ...rest, orderBy: orderToString(orderBy) }); +const normalizeListParams = ( + { orderBy = {}, excludeMaxVisitsReached, excludePastValidUntil, ...rest }: ShlinkShortUrlsListParams, +): ShlinkShortUrlsListNormalizedParams => ({ + ...rest, + excludeMaxVisitsReached: excludeMaxVisitsReached === true ? 'true' : undefined, + excludePastValidUntil: excludePastValidUntil === true ? 'true' : undefined, + orderBy: orderToString(orderBy), +}); export class ShlinkApiClient { private apiVersion: 2 | 3; @@ -40,7 +45,7 @@ export class ShlinkApiClient { } public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => - this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params)) + this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeListParams(params)) .then(({ shortUrls }) => shortUrls); public readonly createShortUrl = async (options: ShortUrlData): Promise => { diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 4acda7c0..ef51e9b2 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -96,14 +96,19 @@ export type ShlinkShortUrlsOrder = Order; export interface ShlinkShortUrlsListParams { page?: string; itemsPerPage?: number; - tags?: string[]; searchTerm?: string; + tags?: string[]; + tagsMode?: TagsFilteringMode; + orderBy?: ShlinkShortUrlsOrder; startDate?: string; endDate?: string; - orderBy?: ShlinkShortUrlsOrder; - tagsMode?: TagsFilteringMode; + excludeMaxVisitsReached?: boolean; + excludePastValidUntil?: boolean; } -export interface ShlinkShortUrlsListNormalizedParams extends Omit { +export interface ShlinkShortUrlsListNormalizedParams extends + Omit { orderBy?: string; + excludeMaxVisitsReached?: 'true'; + excludePastValidUntil?: 'true'; } diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 85c70891..b1cdb5a8 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -8,7 +8,7 @@ import { SearchField } from '../utils/SearchField'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { formatIsoDate } from '../utils/helpers/date'; import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals'; -import { supportsAllTagsFiltering } from '../utils/helpers/features'; +import { supportsAllTagsFiltering, supportsFilterDisabledUrls } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; import { OrderDir } from '../utils/helpers/ordering'; import { OrderingDropdown } from '../utils/OrderingDropdown'; @@ -33,7 +33,19 @@ export const ShortUrlsFilteringBar = ( ExportShortUrlsBtn: FC, TagsSelector: FC, ): FC => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => { - const [{ search, tags, startDate, endDate, excludeBots, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery(); + const [filter, toFirstPage] = useShortUrlsQuery(); + const { + search, + tags, + startDate, + endDate, + excludeBots, + excludeMaxVisitsReached, + excludePastValidUntil, + tagsMode = 'any', + } = filter; + const supportsDisabledFiltering = supportsFilterDisabledUrls(selectedServer); + const setDates = pipe( ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ startDate: formatIsoDate(theStartDate) ?? undefined, @@ -82,8 +94,13 @@ export const ShortUrlsFilteringBar = ( diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index a124368c..b29dc925 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -31,7 +31,18 @@ export const ShortUrlsList = ( const serverId = getServerId(selectedServer); const { page } = useParams(); const location = useLocation(); - const [{ tags, search, startDate, endDate, orderBy, tagsMode, excludeBots }, toFirstPage] = useShortUrlsQuery(); + const [filter, toFirstPage] = useShortUrlsQuery(); + const { + tags, + search, + startDate, + endDate, + orderBy, + tagsMode, + excludeBots, + excludePastValidUntil, + excludeMaxVisitsReached, + } = filter; const [actualOrderBy, setActualOrderBy] = useState( // This separated state handling is needed to be able to fall back to settings value, but only once when loaded orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, @@ -67,8 +78,21 @@ export const ShortUrlsList = ( endDate, orderBy: parseOrderByForShlink(actualOrderBy), tagsMode, + excludePastValidUntil, + excludeMaxVisitsReached, }); - }, [page, search, tags, startDate, endDate, actualOrderBy.field, actualOrderBy.dir, tagsMode]); + }, [ + page, + search, + tags, + startDate, + endDate, + actualOrderBy.field, + actualOrderBy.dir, + tagsMode, + excludePastValidUntil, + excludeMaxVisitsReached, + ]); return ( <> diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 343f385a..c0ed41e2 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -83,4 +83,6 @@ export interface ExportableShortUrl { export interface ShortUrlsFilter { excludeBots?: boolean; + excludeMaxVisitsReached?: boolean; + excludePastValidUntil?: boolean; } diff --git a/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx b/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx index 4b36d502..5d774e1a 100644 --- a/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx +++ b/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx @@ -5,23 +5,40 @@ import { ShortUrlsFilter } from '../data'; interface ShortUrlsFilterDropdownProps { onChange: (filters: ShortUrlsFilter) => void; + supportsDisabledFiltering: boolean; selected?: ShortUrlsFilter; className?: string; } export const ShortUrlsFilterDropdown = ( - { onChange, selected = {}, className }: ShortUrlsFilterDropdownProps, + { onChange, selected = {}, className, supportsDisabledFiltering }: ShortUrlsFilterDropdownProps, ) => { - const { excludeBots = false } = selected; - const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); + const { excludeBots = false, excludeMaxVisitsReached = false, excludePastValidUntil = false } = selected; + const onFilterClick = (key: keyof ShortUrlsFilter) => () => onChange({ ...selected, [key]: !selected?.[key] }); return ( - Bots: - Exclude bots visits + Visits: + Ignore visits from bots + + {supportsDisabledFiltering && ( + <> + + Short URLs: + + Exclude with visits reached + + + Exclude enabled in the past + + + )} - onChange({ excludeBots: false })}> + onChange({ excludeBots: false, excludeMaxVisitsReached: false, excludePastValidUntil: false })} + > Clear filters diff --git a/src/short-urls/helpers/hooks.ts b/src/short-urls/helpers/hooks.ts index e9bd91f2..95cc5a43 100644 --- a/src/short-urls/helpers/hooks.ts +++ b/src/short-urls/helpers/hooks.ts @@ -5,7 +5,7 @@ import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data'; import { orderToString, stringToOrder } from '../../utils/helpers/ordering'; import { TagsFilteringMode } from '../../api/types'; -import { BooleanString, parseBooleanToString } from '../../utils/utils'; +import { BooleanString, parseOptionalBooleanToString } from '../../utils/utils'; interface ShortUrlsQueryCommon { search?: string; @@ -18,12 +18,16 @@ interface ShortUrlsQuery extends ShortUrlsQueryCommon { orderBy?: string; tags?: string; excludeBots?: BooleanString; + excludeMaxVisitsReached?: BooleanString; + excludePastValidUntil?: BooleanString; } interface ShortUrlsFiltering extends ShortUrlsQueryCommon { orderBy?: ShortUrlsOrder; tags: string[]; excludeBots?: boolean; + excludeMaxVisitsReached?: boolean; + excludePastValidUntil?: boolean; } type ToFirstPage = (extra: Partial) => void; @@ -36,7 +40,7 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { const filtering = useMemo( pipe( () => parseQuery(search), - ({ orderBy, tags, excludeBots, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { + ({ orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...rest }): ShortUrlsFiltering => { const parsedOrderBy = orderBy ? stringToOrder(orderBy) : undefined; const parsedTags = tags?.split(',') ?? []; return { @@ -44,18 +48,23 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { orderBy: parsedOrderBy, tags: parsedTags, excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined, + excludeMaxVisitsReached: excludeMaxVisitsReached !== undefined ? excludeMaxVisitsReached === 'true' : undefined, + excludePastValidUntil: excludePastValidUntil !== undefined ? excludePastValidUntil === 'true' : undefined, }; }, ), [search], ); const toFirstPageWithExtra = (extra: Partial) => { - const { orderBy, tags, excludeBots, ...mergedFiltering } = { ...filtering, ...extra }; + const merged = { ...filtering, ...extra }; + const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged; const query: ShortUrlsQuery = { ...mergedFiltering, orderBy: orderBy && orderToString(orderBy), tags: tags.length > 0 ? tags.join(',') : undefined, - excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots), + excludeBots: parseOptionalBooleanToString(excludeBots), + excludeMaxVisitsReached: parseOptionalBooleanToString(excludeMaxVisitsReached), + excludePastValidUntil: parseOptionalBooleanToString(excludePastValidUntil), }; const stringifiedQuery = stringifyQuery(query); const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`; diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index bfb88215..b85551cc 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -11,3 +11,4 @@ export const supportsNonOrphanVisits = serverMatchesMinVersion('3.0.0'); export const supportsAllTagsFiltering = supportsNonOrphanVisits; export const supportsDomainVisits = serverMatchesMinVersion('3.1.0'); export const supportsExcludeBotsOnShortUrls = serverMatchesMinVersion('3.4.0'); +export const supportsFilterDisabledUrls = supportsExcludeBotsOnShortUrls; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 504d450e..4ddef89d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -30,3 +30,7 @@ export const equals = (value: any) => (otherValue: any) => value === otherValue; export type BooleanString = 'true' | 'false'; export const parseBooleanToString = (value: boolean): BooleanString => (value ? 'true' : 'false'); + +export const parseOptionalBooleanToString = (value?: boolean): BooleanString | undefined => ( + value === undefined ? undefined : parseBooleanToString(value) +); diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 0b15299e..802e0678 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -46,6 +46,28 @@ describe('ShlinkApiClient', () => { expect.anything(), ); }); + + it.each([ + [{}, ''], + [{ excludeMaxVisitsReached: false }, ''], + [{ excludeMaxVisitsReached: true }, '?excludeMaxVisitsReached=true'], + [{ excludePastValidUntil: false }, ''], + [{ excludePastValidUntil: true }, '?excludePastValidUntil=true'], + [ + { excludePastValidUntil: true, excludeMaxVisitsReached: true }, + '?excludeMaxVisitsReached=true&excludePastValidUntil=true', + ], + ])('parses disabled URLs params', async (params, expectedQuery) => { + fetchJson.mockResolvedValue({ data: expectedList }); + const { listShortUrls } = buildApiClient(); + + await listShortUrls(params); + + expect(fetchJson).toHaveBeenCalledWith( + expect.stringContaining(`/short-urls${expectedQuery}`), + expect.anything(), + ); + }); }); describe('createShortUrl', () => { diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx index ad366d98..5c89c865 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -117,20 +117,24 @@ describe('', () => { }); 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({ version: '3.4.0' }), - ); - const toggleBots = async (name = 'Exclude bots visits') => { + ['', /Ignore visits from bots/, 'excludeBots=true'], + ['excludeBots=false', /Ignore visits from bots/, 'excludeBots=true'], + ['excludeBots=true', /Ignore visits from bots/, 'excludeBots=false'], + ['', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'], + ['excludeMaxVisitsReached=false', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'], + ['excludeMaxVisitsReached=true', /Exclude with visits reached/, 'excludeMaxVisitsReached=false'], + ['', /Exclude enabled in the past/, 'excludePastValidUntil=true'], + ['excludePastValidUntil=false', /Exclude enabled in the past/, 'excludePastValidUntil=true'], + ['excludePastValidUntil=true', /Exclude enabled in the past/, 'excludePastValidUntil=false'], + ])('allows to toggle filters through filtering dropdown', async (search, menuItemName, expectedQuery) => { + const { user } = setUp(search, Mock.of({ version: '3.4.0' })); + const toggleFilter = async (name: RegExp) => { await user.click(screen.getByRole('button', { name: 'Filters' })); - await user.click(await screen.findByRole('menuitem', { name })); + await waitFor(() => screen.findByRole('menu')); + await user.click(screen.getByRole('menuitem', { name })); }; - await toggleBots(); + await toggleFilter(menuItemName); expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedQuery)); }); diff --git a/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx b/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx new file mode 100644 index 00000000..b1168b27 --- /dev/null +++ b/test/short-urls/helpers/ShortUrlsFilterDropdown.test.tsx @@ -0,0 +1,21 @@ +import { screen, waitFor } from '@testing-library/react'; +import { ShortUrlsFilterDropdown } from '../../../src/short-urls/helpers/ShortUrlsFilterDropdown'; +import { renderWithEvents } from '../../__helpers__/setUpTest'; + +describe('', () => { + const setUp = (supportsDisabledFiltering: boolean) => renderWithEvents( + , + ); + + it.each([ + [true, 3], + [false, 1], + ])('displays proper amount of menu items', async (supportsDisabledFiltering, expectedItems) => { + const { user } = setUp(supportsDisabledFiltering); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + + expect(screen.getAllByRole('menuitem')).toHaveLength(expectedItems); + }); +}); diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts index d08a5fee..aa2d25c3 100644 --- a/test/utils/utils.test.ts +++ b/test/utils/utils.test.ts @@ -1,4 +1,10 @@ -import { capitalize, nonEmptyValueOrNull, parseBooleanToString, rangeOf } from '../../src/utils/utils'; +import { + capitalize, + nonEmptyValueOrNull, + parseBooleanToString, + parseOptionalBooleanToString, + rangeOf, +} from '../../src/utils/utils'; describe('utils', () => { describe('rangeOf', () => { @@ -58,4 +64,14 @@ describe('utils', () => { expect(parseBooleanToString(value)).toEqual(expectedResult); }); }); + + describe('parseOptionalBooleanToString', () => { + it.each([ + [undefined, undefined], + [true, 'true'], + [false, 'false'], + ])('parses value as expected', (value, expectedResult) => { + expect(parseOptionalBooleanToString(value)).toEqual(expectedResult); + }); + }); });