diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0d574b..e152ad87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added * [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility. * [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together. +* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0. ### Changed * [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section. diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 4dabd92b..ce94d65d 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -86,6 +86,8 @@ export interface ShlinkDomainsResponse { defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10 } +export type TagsFilteringMode = 'all' | 'any'; + export interface ShlinkShortUrlsListParams { page?: string; itemsPerPage?: number; @@ -94,6 +96,7 @@ export interface ShlinkShortUrlsListParams { startDate?: string; endDate?: string; orderBy?: ShortUrlsOrder; + tagsMode?: TagsFilteringMode; } export interface ShlinkShortUrlsListNormalizedParams extends Omit { diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 73f6133f..a5380be0 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -8,13 +8,20 @@ import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { formatIsoDate } from '../utils/helpers/date'; import ColorGenerator from '../utils/services/ColorGenerator'; import { DateRange } from '../utils/dates/types'; +import { supportsAllTagsFiltering } from '../utils/helpers/features'; +import { SelectedServer } from '../servers/data'; +import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch'; import { useShortUrlsQuery } from './helpers/hooks'; import './ShortUrlsFilteringBar.scss'; +interface ShortUrlsFilteringProps { + selectedServer: SelectedServer; +} + const dateOrNull = (date?: string) => date ? parseISO(date) : null; -const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => () => { - const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(); +const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedServer }: ShortUrlsFilteringProps) => { + const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery(); const selectedTags = tags?.split(',') ?? []; const setDates = pipe( ({ startDate, endDate }: DateRange) => ({ @@ -32,6 +39,11 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => () => { (tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','), (tags) => toFirstPage({ tags }), ); + const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); + const toggleTagsMode = pipe( + () => tagsMode === 'any' ? 'all' : 'any', + (tagsMode) => toFirstPage({ tagsMode }), + ); return (
@@ -53,9 +65,19 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => () => {
{selectedTags.length > 0 && ( -

- -   +

+ {canChangeTagsMode && selectedTags.length > 1 && ( +
+ + {tagsMode === 'all' ? 'Short URLs including all tags.' : 'Short URLs including any tag.'} + +
+ )} + {selectedTags.map((tag) => removeTag(tag)} />)}

diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index f2bc1952..4e966653 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -32,7 +32,7 @@ const ShortUrlsList = (ShortUrlsTable: FC, ShortUrlsFilteri const serverId = getServerId(selectedServer); const { page } = useParams(); const location = useLocation(); - const [{ tags, search, startDate, endDate, orderBy }, toFirstPage ] = useShortUrlsQuery(); + const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery(); 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, @@ -60,8 +60,9 @@ const ShortUrlsList = (ShortUrlsTable: FC, ShortUrlsFilteri startDate, endDate, orderBy: actualOrderBy, + tagsMode, }); - }, [ page, search, selectedTags, startDate, endDate, actualOrderBy ]); + }, [ page, search, selectedTags, startDate, endDate, actualOrderBy, tagsMode ]); return ( <> diff --git a/src/short-urls/helpers/hooks.ts b/src/short-urls/helpers/hooks.ts index c7962806..9e55e026 100644 --- a/src/short-urls/helpers/hooks.ts +++ b/src/short-urls/helpers/hooks.ts @@ -4,6 +4,7 @@ import { isEmpty, pipe } from 'ramda'; import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data'; import { orderToString, stringToOrder } from '../../utils/helpers/ordering'; +import { TagsFilteringMode } from '../../api/types'; type ToFirstPage = (extra: Partial) => void; @@ -17,6 +18,7 @@ interface ShortUrlsQueryCommon { search?: string; startDate?: string; endDate?: string; + tagsMode?: TagsFilteringMode; } interface ShortUrlsQuery extends ShortUrlsQueryCommon { diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 425a04a8..82e40f4e 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -50,6 +50,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator'); + bottle.decorator('ShortUrlsFilteringBar', connect([ 'selectedServer' ])); // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); diff --git a/src/utils/TooltipToggleSwitch.tsx b/src/utils/TooltipToggleSwitch.tsx new file mode 100644 index 00000000..0c708bcc --- /dev/null +++ b/src/utils/TooltipToggleSwitch.tsx @@ -0,0 +1,24 @@ +import { FC, useRef } from 'react'; +import { UncontrolledTooltip } from 'reactstrap'; +import { UncontrolledTooltipProps } from 'reactstrap/lib/Tooltip'; +import { BooleanControlProps } from './BooleanControl'; +import ToggleSwitch from './ToggleSwitch'; + +export type TooltipToggleSwitchProps = BooleanControlProps & { tooltip?: Omit }; + +export const TooltipToggleSwitch: FC = ({ children, tooltip = {}, ...rest }) => { + const ref = useRef(); + + return ( + <> + { + ref.current = el ?? undefined; + }} + > + + + ref.current) as any} {...tooltip}>{children} + + ); +}; diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 151fa409..1bf01b02 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -27,3 +27,5 @@ export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' }); export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' }); + +export const supportsAllTagsFiltering = supportsNonOrphanVisits; diff --git a/test/short-urls/SearchBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx similarity index 56% rename from test/short-urls/SearchBar.test.tsx rename to test/short-urls/ShortUrlsFilteringBar.test.tsx index 2567b8e1..e8718216 100644 --- a/test/short-urls/SearchBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -7,6 +7,8 @@ import SearchField from '../../src/utils/SearchField'; import Tag from '../../src/tags/helpers/Tag'; import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector'; import ColorGenerator from '../../src/utils/services/ColorGenerator'; +import { ReachableServer, SelectedServer } from '../../src/servers/data'; +import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -20,11 +22,11 @@ describe('', () => { const ShortUrlsFilteringBar = filteringBarCreator(Mock.all()); const navigate = jest.fn(); const now = new Date(); - const createWrapper = (search = '') => { + const createWrapper = (search = '', selectedServer?: SelectedServer) => { (useLocation as any).mockReturnValue({ search }); (useNavigate as any).mockReturnValue(navigate); - wrapper = shallow(); + wrapper = shallow(()} />); return wrapper; }; @@ -83,4 +85,46 @@ describe('', () => { dateRange.simulate('datesChange', dates); expect(navigate).toHaveBeenCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`); }); + + it.each([ + [ 'tags=foo,bar,baz', Mock.of({ version: '3.0.0' }), 1 ], + [ 'tags=foo,bar', Mock.of({ version: '3.1.0' }), 1 ], + [ 'tags=foo', Mock.of({ version: '3.0.0' }), 0 ], + [ '', Mock.of({ version: '3.0.0' }), 0 ], + [ 'tags=foo,bar,baz', Mock.of({ version: '2.10.0' }), 0 ], + [ '', Mock.of({ version: '2.10.0' }), 0 ], + ])( + 'renders tags mode toggle if the server supports it and there is more than one tag selected', + (search, selectedServer, expectedTagToggleComponents) => { + const wrapper = createWrapper(search, selectedServer); + const toggle = wrapper.find(TooltipToggleSwitch); + + expect(toggle).toHaveLength(expectedTagToggleComponents); + }, + ); + + it.each([ + [ '', 'Short URLs including any tag.', false ], + [ '&tagsMode=all', 'Short URLs including all tags.', true ], + [ '&tagsMode=any', 'Short URLs including any tag.', false ], + ])('expected tags mode tooltip title', (initialTagsMode, expectedToggleText, expectedChecked) => { + const wrapper = createWrapper(`tags=foo,bar${initialTagsMode}`, Mock.of({ version: '3.0.0' })); + const toggle = wrapper.find(TooltipToggleSwitch); + + expect(toggle.prop('children')).toEqual(expectedToggleText); + expect(toggle.prop('checked')).toEqual(expectedChecked); + }); + + it.each([ + [ '', 'tagsMode=all' ], + [ '&tagsMode=all', 'tagsMode=any' ], + [ '&tagsMode=any', 'tagsMode=all' ], + ])('redirects to first page when tags mode changes', (initialTagsMode, expectedRedirectTagsMode) => { + const wrapper = createWrapper(`tags=foo,bar${initialTagsMode}`, Mock.of({ version: '3.0.0' })); + const toggle = wrapper.find(TooltipToggleSwitch); + + expect(navigate).not.toHaveBeenCalled(); + toggle.simulate('change'); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); + }); }); diff --git a/test/utils/TooltipToggleSwitch.test.tsx b/test/utils/TooltipToggleSwitch.test.tsx new file mode 100644 index 00000000..d35906d5 --- /dev/null +++ b/test/utils/TooltipToggleSwitch.test.tsx @@ -0,0 +1,38 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { PropsWithChildren } from 'react'; +import { UncontrolledTooltip } from 'reactstrap'; +import { TooltipToggleSwitch, TooltipToggleSwitchProps } from '../../src/utils/TooltipToggleSwitch'; +import ToggleSwitch from '../../src/utils/ToggleSwitch'; + +describe('', () => { + let wrapper: ShallowWrapper; + const createWrapper = (props: PropsWithChildren = {}) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'baz' ], + ])('shows children inside tooltip', (children) => { + const wrapper = createWrapper({ children }); + const tooltip = wrapper.find(UncontrolledTooltip); + + expect(tooltip.prop('children')).toEqual(children); + }); + + it('properly propagates corresponding props to every component', () => { + const expectedTooltipProps = { placement: 'left', delay: 30 }; + const expectedToggleProps = { checked: true, className: 'foo' }; + const wrapper = createWrapper({ tooltip: expectedTooltipProps, ...expectedToggleProps }); + const tooltip = wrapper.find(UncontrolledTooltip); + const toggle = wrapper.find(ToggleSwitch); + + expect(tooltip.props()).toEqual(expect.objectContaining(expectedTooltipProps)); + expect(toggle.props()).toEqual(expect.objectContaining(expectedToggleProps)); + }); +});