Skip to content

Commit f6342db

Browse files
authored
Merge pull request #847 from acelaya-forks/exclude-tags
Allow short URLs to be filtered by excluded tags
2 parents 0a59043 + 54cd7f5 commit f6342db

5 files changed

Lines changed: 151 additions & 23 deletions

File tree

src/short-urls/ShortUrlsFilteringBar.tsx

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,19 @@ const ShortUrlsFilteringBar: FCWithDeps<ShortUrlsFilteringConnectProps, ShortUrl
4646
const [{
4747
search,
4848
tags,
49+
tagsMode = 'any',
50+
excludeTags,
51+
excludeTagsMode = 'any',
4952
startDate,
5053
endDate,
5154
excludeBots,
5255
excludeMaxVisitsReached,
5356
excludePastValidUntil,
5457
domain,
55-
tagsMode = 'any',
5658
}, toFirstPage] = useShortUrlsQuery();
5759
const visitsSettings = useSetting('visits');
5860
const supportsFilterByDomain = useFeature('filterShortUrlsByDomain');
61+
const supportsFilterByExcludedTags = useFeature('filterShortUrlsByExcludedTags');
5962

6063
const [activeInterval, setActiveInterval] = useState<DateInterval>();
6164
const setDates = useCallback(
@@ -75,13 +78,26 @@ const ShortUrlsFilteringBar: FCWithDeps<ShortUrlsFilteringConnectProps, ShortUrl
7578
const setDomain = useCallback((domain?: string) => toFirstPage({ domain }), [toFirstPage]);
7679
const changeTagSelection = useCallback((newTags: string[]) => toFirstPage({ tags: newTags }), [toFirstPage]);
7780
const changeTagsMode = useCallback((tagsMode: TagsFilteringMode) => toFirstPage({ tagsMode }), [toFirstPage]);
81+
const changeExcludeTagSelection = useCallback(
82+
(newTags: string[]) => toFirstPage({ excludeTags: newTags }),
83+
[toFirstPage],
84+
);
85+
const changeExcludeTagsMode = useCallback(
86+
(excludeTagsMode: TagsFilteringMode) => toFirstPage({ excludeTagsMode }),
87+
[toFirstPage],
88+
);
7889

7990
return (
8091
<div className={clsx('flex flex-col gap-y-4', className)}>
8192
<SearchInput defaultValue={search} onChange={setSearch} />
8293

8394
<div className="flex flex-col xl:flex-row-reverse justify-between gap-y-4">
84-
<div className="min-w-2/3 inline-flex flex-col lg:flex-row gap-x-2 gap-y-4">
95+
<div
96+
className={clsx(
97+
'flex flex-col lg:flex-row gap-x-2 gap-y-4',
98+
{ 'min-w-3/4': supportsFilterByExcludedTags, 'min-w-2/3': !supportsFilterByExcludedTags },
99+
)}
100+
>
85101
<div className="flex flex-col md:flex-row gap-x-2 gap-y-4 grow">
86102
<div className="grow">
87103
<DateRangeSelector
@@ -90,16 +106,30 @@ const ShortUrlsFilteringBar: FCWithDeps<ShortUrlsFilteringConnectProps, ShortUrl
90106
onDatesChange={setDates}
91107
/>
92108
</div>
93-
<TagsSearchDropdown
94-
title="Filter by tag"
95-
prefix="With"
96-
tags={tagsList.tags}
97-
selectedTags={tags}
98-
onTagsChange={changeTagSelection}
99-
mode={tagsMode}
100-
onModeChange={changeTagsMode}
101-
buttonClassName="w-full"
102-
/>
109+
<div className={clsx('grid lg:flex gap-x-2 gap-y-4', { 'grid-cols-2': supportsFilterByExcludedTags })}>
110+
<TagsSearchDropdown
111+
title="Filter by tag"
112+
prefix="With"
113+
tags={tagsList.tags}
114+
selectedTags={tags}
115+
onTagsChange={changeTagSelection}
116+
mode={tagsMode}
117+
onModeChange={changeTagsMode}
118+
buttonClassName="w-full"
119+
/>
120+
{supportsFilterByExcludedTags && (
121+
<TagsSearchDropdown
122+
title="Filter by excluded tag"
123+
prefix="Without"
124+
tags={tagsList.tags}
125+
selectedTags={excludeTags}
126+
onTagsChange={changeExcludeTagSelection}
127+
mode={excludeTagsMode}
128+
onModeChange={changeExcludeTagsMode}
129+
buttonClassName="w-full"
130+
/>
131+
)}
132+
</div>
103133
</div>
104134
<div className={clsx('grid lg:flex gap-x-2 gap-y-4', { 'grid-cols-2': supportsFilterByDomain })}>
105135
{supportsFilterByDomain && (

src/short-urls/ShortUrlsList.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
4747
const location = useLocation();
4848
const [{
4949
tags,
50+
tagsMode,
51+
excludeTags,
52+
excludeTagsMode,
5053
search,
5154
startDate,
5255
endDate,
5356
orderBy,
54-
tagsMode,
5557
excludeBots,
5658
excludePastValidUntil,
5759
excludeMaxVisitsReached,
@@ -95,10 +97,12 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
9597
page,
9698
searchTerm: search,
9799
tags,
100+
tagsMode,
101+
excludeTags,
102+
excludeTagsMode,
98103
startDate,
99104
endDate,
100105
orderBy: parseOrderByForShlink(actualOrderBy),
101-
tagsMode,
102106
excludePastValidUntil,
103107
excludeMaxVisitsReached,
104108
domain,
@@ -116,6 +120,8 @@ const ShortUrlsList: FCWithDeps<ShortUrlsListProps, ShortUrlsListDeps> = boundTo
116120
excludePastValidUntil,
117121
excludeMaxVisitsReached,
118122
domain,
123+
excludeTags,
124+
excludeTagsMode,
119125
]);
120126

121127
return (

src/short-urls/helpers/hooks.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ type ShortUrlsQueryCommon = {
1313
startDate?: string;
1414
endDate?: string;
1515
tagsMode?: TagsFilteringMode;
16+
excludeTagsMode?: TagsFilteringMode;
1617
};
1718

1819
type ShortUrlsRawQuery = Record<string, unknown> & ShortUrlsQueryCommon & {
1920
orderBy?: string;
2021
tags?: string;
22+
excludeTags?: string;
2123
excludeBots?: BooleanString;
2224
excludeMaxVisitsReached?: BooleanString;
2325
excludePastValidUntil?: BooleanString;
@@ -27,6 +29,7 @@ type ShortUrlsRawQuery = Record<string, unknown> & ShortUrlsQueryCommon & {
2729
type ShortUrlsQuery = ShortUrlsQueryCommon & {
2830
orderBy?: ShortUrlsOrder;
2931
tags: string[];
32+
excludeTags: string[];
3033
excludeBots?: boolean;
3134
excludeMaxVisitsReached?: boolean;
3235
excludePastValidUntil?: boolean;
@@ -42,13 +45,22 @@ export const useShortUrlsQuery = (): [ShortUrlsQuery, ToFirstPage] => {
4245

4346
const filtering = useMemo(
4447
(): ShortUrlsQuery => {
45-
const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...rest } = query;
48+
const {
49+
orderBy,
50+
tags,
51+
excludeTags,
52+
excludeBots,
53+
excludeMaxVisitsReached,
54+
excludePastValidUntil,
55+
...rest
56+
} = query;
4657
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
4758
const parsedTags = tags?.split(',') ?? [];
4859
return {
4960
...rest,
5061
orderBy: parsedOrderBy,
5162
tags: parsedTags,
63+
excludeTags: excludeTags?.split(',') ?? [],
5264
excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined,
5365
excludeMaxVisitsReached: excludeMaxVisitsReached !== undefined ? excludeMaxVisitsReached === 'true' : undefined,
5466
excludePastValidUntil: excludePastValidUntil !== undefined ? excludePastValidUntil === 'true' : undefined,
@@ -58,11 +70,20 @@ export const useShortUrlsQuery = (): [ShortUrlsQuery, ToFirstPage] => {
5870
);
5971
const toFirstPageWithExtra = useCallback((extra: Partial<ShortUrlsQuery>) => {
6072
const merged = { ...filtering, ...extra };
61-
const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged;
73+
const {
74+
orderBy,
75+
tags,
76+
excludeTags,
77+
excludeBots,
78+
excludeMaxVisitsReached,
79+
excludePastValidUntil,
80+
...mergedFiltering
81+
} = merged;
6282
const newQuery: ShortUrlsRawQuery = {
6383
...mergedFiltering,
6484
orderBy: orderBy && orderToString(orderBy),
6585
tags: tags.length > 0 ? tags.join(',') : undefined,
86+
excludeTags: excludeTags.length > 0 ? excludeTags.join(',') : undefined,
6687
excludeBots: parseOptionalBooleanToString(excludeBots),
6788
excludeMaxVisitsReached: parseOptionalBooleanToString(excludeMaxVisitsReached),
6889
excludePastValidUntil: parseOptionalBooleanToString(excludePastValidUntil),

src/utils/features.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const supportedFeatures = {
88
filterShortUrlsByDomain: { minVersion: '4.3.0' },
99
advancedQueryRedirectConditions: { minVersion: '4.5.0' },
1010
desktopDeviceTypes: { minVersion: '4.5.0' },
11+
filterShortUrlsByExcludedTags: { minVersion: '4.6.0' },
1112
} as const satisfies Record<string, Versions>;
1213

1314
Object.freeze(supportedFeatures);
@@ -25,6 +26,7 @@ const getFeaturesForVersion = (serverVersion: SemVerOrLatest): Record<Feature, b
2526
filterShortUrlsByDomain: isFeatureEnabledForVersion('filterShortUrlsByDomain', serverVersion),
2627
advancedQueryRedirectConditions: isFeatureEnabledForVersion('advancedQueryRedirectConditions', serverVersion),
2728
desktopDeviceTypes: isFeatureEnabledForVersion('advancedQueryRedirectConditions', serverVersion),
29+
filterShortUrlsByExcludedTags: isFeatureEnabledForVersion('filterShortUrlsByExcludedTags', serverVersion),
2830
});
2931

3032
const FeaturesContext = createContext(getFeaturesForVersion('0.0.0'));

test/short-urls/ShortUrlsFilteringBar.test.tsx

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,49 @@ import { Router } from 'react-router';
88
import { DEFAULT_DOMAIN } from '../../src/domains/data';
99
import { SettingsProvider } from '../../src/settings';
1010
import { ShortUrlsFilteringBarFactory } from '../../src/short-urls/ShortUrlsFilteringBar';
11+
import { TagsSearchDropdownFactory } from '../../src/tags/helpers/TagsSearchDropdown';
1112
import { FeaturesProvider } from '../../src/utils/features';
1213
import { RoutesPrefixProvider } from '../../src/utils/routesPrefix';
1314
import { checkAccessibility } from '../__helpers__/accessibility';
1415
import { renderWithEvents } from '../__helpers__/setUpTest';
16+
import { colorGeneratorMock } from '../utils/services/__mocks__/ColorGenerator.mock';
1517

1618
type SetUpOptions = {
1719
search?: string;
1820
routesPrefix?: string;
1921
filterByDomainSupported?: boolean;
22+
filterByExcludedTagSupported?: boolean;
2023
};
2124

2225
describe('<ShortUrlsFilteringBar />', () => {
2326
const ShortUrlsFilteringBar = ShortUrlsFilteringBarFactory(fromPartial({
2427
ExportShortUrlsBtn: () => <>ExportShortUrlsBtn</>,
25-
TagsSearchDropdown: () => <>TagsSearchDropdown</>,
28+
TagsSearchDropdown: TagsSearchDropdownFactory(fromPartial({ ColorGenerator: colorGeneratorMock })),
2629
}));
2730
const handleOrderBy = vi.fn();
2831
let history: MemoryHistory;
2932

30-
const setUp = ({ search, routesPrefix = '', filterByDomainSupported = false }: SetUpOptions = {}) => {
33+
const setUp = ({
34+
search,
35+
routesPrefix = '',
36+
filterByDomainSupported = false,
37+
filterByExcludedTagSupported = false,
38+
}: SetUpOptions = {}) => {
3139
history = createMemoryHistory({ initialEntries: search ? [{ search }] : undefined });
3240
return renderWithEvents(
3341
<Router location={history.location} navigator={history}>
3442
<SettingsProvider value={fromPartial({ visits: {} })}>
3543
<RoutesPrefixProvider value={routesPrefix}>
36-
<FeaturesProvider value={fromPartial({ filterShortUrlsByDomain: filterByDomainSupported })}>
44+
<FeaturesProvider
45+
value={fromPartial({
46+
filterShortUrlsByDomain: filterByDomainSupported,
47+
filterShortUrlsByExcludedTags: filterByExcludedTagSupported,
48+
})}
49+
>
3750
<ShortUrlsFilteringBar
3851
order={{}}
3952
handleOrderBy={handleOrderBy}
40-
tagsList={fromPartial({ tags: [] })}
53+
tagsList={fromPartial({ tags: ['foo', 'bar', 'baz'] })}
4154
domainsList={fromPartial({
4255
domains: [
4356
{ isDefault: true, domain: 'example.com' },
@@ -60,9 +73,7 @@ describe('<ShortUrlsFilteringBar />', () => {
6073

6174
it('renders expected children components', () => {
6275
setUp();
63-
6476
expect(screen.getByText('ExportShortUrlsBtn')).toBeInTheDocument();
65-
expect(screen.getByText('TagsSearchDropdown')).toBeInTheDocument();
6677
});
6778

6879
it('redirects to first page when search field changes', async () => {
@@ -152,7 +163,7 @@ describe('<ShortUrlsFilteringBar />', () => {
152163
it.each([
153164
{ domain: /^example.com/, expectedQueryDomain: DEFAULT_DOMAIN },
154165
{ domain: 's.test', expectedQueryDomain: 's.test' },
155-
])('redirects to first page when selected domain changes', async ({ domain, expectedQueryDomain }) => {
166+
])('updates query params when selected domain changes', async ({ domain, expectedQueryDomain }) => {
156167
const { user } = setUp({ filterByDomainSupported: true });
157168

158169
await user.click(screen.getByRole('button', { name: 'All domains' }));
@@ -161,4 +172,62 @@ describe('<ShortUrlsFilteringBar />', () => {
161172
await user.click(screen.getByRole('menuitem', { name: domain }));
162173
await waitFor(() => expect(paramFromCurrentQuery('domain')).toEqual(expectedQueryDomain));
163174
});
175+
176+
it('updates query params when tags change', async () => {
177+
const { user } = setUp();
178+
179+
await user.click(screen.getByRole('button', { name: 'With tags...' }));
180+
const menu = await screen.findByRole('menu');
181+
182+
await user.type(menu.querySelector('[placeholder="Search..."]')!, 'f');
183+
await user.click(await screen.findByRole('option', { name: 'foo' }));
184+
185+
await waitFor(() => expect(paramFromCurrentQuery('tags')).toEqual('foo'));
186+
});
187+
188+
it('updates query params when tags mode changes', async () => {
189+
const { user } = setUp();
190+
191+
await user.click(screen.getByRole('button', { name: 'With tags...' }));
192+
193+
await user.click(await screen.findByRole('button', { name: 'Any' }));
194+
await waitFor(() => expect(paramFromCurrentQuery('tagsMode')).toEqual('any'));
195+
196+
await user.click(await screen.findByRole('button', { name: 'All' }));
197+
await waitFor(() => expect(paramFromCurrentQuery('tagsMode')).toEqual('all'));
198+
});
199+
200+
it.each([true, false])('shows exclude tags dropdown if supported', (filterByExcludedTagSupported) => {
201+
setUp({ filterByExcludedTagSupported });
202+
203+
if (filterByExcludedTagSupported) {
204+
expect(screen.getByRole('button', { name: 'Without tags...' })).toBeInTheDocument();
205+
} else {
206+
expect(screen.queryByRole('button', { name: 'Without tags...' })).not.toBeInTheDocument();
207+
}
208+
});
209+
210+
it('updates query params when excluded tags change', async () => {
211+
const { user } = setUp({ filterByExcludedTagSupported: true });
212+
213+
await user.click(screen.getByRole('button', { name: 'Without tags...' }));
214+
const menu = await screen.findByRole('menu');
215+
216+
await user.type(menu.querySelector('[placeholder="Search..."]')!, 'ba');
217+
await user.click(await screen.findByRole('option', { name: 'bar' }));
218+
219+
await waitFor(() => expect(paramFromCurrentQuery('excludeTags')).toEqual('bar'));
220+
});
221+
222+
it('updates query params when excluded tags mode changes', async () => {
223+
const { user } = setUp({ filterByExcludedTagSupported: true });
224+
225+
await user.click(screen.getByRole('button', { name: 'Without tags...' }));
226+
227+
await user.click(await screen.findByRole('button', { name: 'Any' }));
228+
await waitFor(() => expect(paramFromCurrentQuery('excludeTagsMode')).toEqual('any'));
229+
230+
await user.click(await screen.findByRole('button', { name: 'All' }));
231+
await waitFor(() => expect(paramFromCurrentQuery('excludeTagsMode')).toEqual('all'));
232+
});
164233
});

0 commit comments

Comments
 (0)