Merge pull request #569 from acelaya-forks/feature/tags-mode

Added support for tag mode on short URLs list
This commit is contained in:
Alejandro Celaya 2022-02-26 12:06:57 +01:00 committed by GitHub
commit a4f36f8620
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 147 additions and 9 deletions

View file

@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added ### 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. * [#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. * [#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 ### Changed
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section. * [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.

View file

@ -86,6 +86,8 @@ export interface ShlinkDomainsResponse {
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10 defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
} }
export type TagsFilteringMode = 'all' | 'any';
export interface ShlinkShortUrlsListParams { export interface ShlinkShortUrlsListParams {
page?: string; page?: string;
itemsPerPage?: number; itemsPerPage?: number;
@ -94,6 +96,7 @@ export interface ShlinkShortUrlsListParams {
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
orderBy?: ShortUrlsOrder; orderBy?: ShortUrlsOrder;
tagsMode?: TagsFilteringMode;
} }
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> { export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {

View file

@ -8,13 +8,20 @@ import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import ColorGenerator from '../utils/services/ColorGenerator'; import ColorGenerator from '../utils/services/ColorGenerator';
import { DateRange } from '../utils/dates/types'; 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 { useShortUrlsQuery } from './helpers/hooks';
import './ShortUrlsFilteringBar.scss'; import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
}
const dateOrNull = (date?: string) => date ? parseISO(date) : null; const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => () => { const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedServer }: ShortUrlsFilteringProps) => {
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(); const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery();
const selectedTags = tags?.split(',') ?? []; const selectedTags = tags?.split(',') ?? [];
const setDates = pipe( const setDates = pipe(
({ startDate, endDate }: DateRange) => ({ ({ startDate, endDate }: DateRange) => ({
@ -32,6 +39,11 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => () => {
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','), (tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
(tags) => toFirstPage({ tags }), (tags) => toFirstPage({ tags }),
); );
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const toggleTagsMode = pipe(
() => tagsMode === 'any' ? 'all' : 'any',
(tagsMode) => toFirstPage({ tagsMode }),
);
return ( return (
<div className="short-urls-filtering-bar-container"> <div className="short-urls-filtering-bar-container">
@ -53,9 +65,19 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => () => {
</div> </div>
{selectedTags.length > 0 && ( {selectedTags.length > 0 && (
<h4 className="short-urls-filtering-bar__selected-tag mt-3"> <h4 className="mt-3">
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon" /> {canChangeTagsMode && selectedTags.length > 1 && (
&nbsp; <div className="float-right ml-2 mt-1">
<TooltipToggleSwitch
checked={tagsMode === 'all'}
tooltip={{ placement: 'left' }}
onChange={toggleTagsMode}
>
{tagsMode === 'all' ? 'Short URLs including all tags.' : 'Short URLs including any tag.'}
</TooltipToggleSwitch>
</div>
)}
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon mr-1" />
{selectedTags.map((tag) => {selectedTags.map((tag) =>
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)} <Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
</h4> </h4>

View file

@ -32,7 +32,7 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteri
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const { page } = useParams(); const { page } = useParams();
const location = useLocation(); const location = useLocation();
const [{ tags, search, startDate, endDate, orderBy }, toFirstPage ] = useShortUrlsQuery(); const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage ] = useShortUrlsQuery();
const [ actualOrderBy, setActualOrderBy ] = useState( const [ actualOrderBy, setActualOrderBy ] = useState(
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded // 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, orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
@ -60,8 +60,9 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteri
startDate, startDate,
endDate, endDate,
orderBy: actualOrderBy, orderBy: actualOrderBy,
tagsMode,
}); });
}, [ page, search, selectedTags, startDate, endDate, actualOrderBy ]); }, [ page, search, selectedTags, startDate, endDate, actualOrderBy, tagsMode ]);
return ( return (
<> <>

View file

@ -4,6 +4,7 @@ import { isEmpty, pipe } from 'ramda';
import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data'; import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
import { orderToString, stringToOrder } from '../../utils/helpers/ordering'; import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
import { TagsFilteringMode } from '../../api/types';
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void; type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
@ -17,6 +18,7 @@ interface ShortUrlsQueryCommon {
search?: string; search?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
tagsMode?: TagsFilteringMode;
} }
interface ShortUrlsQuery extends ShortUrlsQueryCommon { interface ShortUrlsQuery extends ShortUrlsQueryCommon {

View file

@ -50,6 +50,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator'); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator');
bottle.decorator('ShortUrlsFilteringBar', connect([ 'selectedServer' ]));
// Actions // Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');

View file

@ -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<UncontrolledTooltipProps, 'target'> };
export const TooltipToggleSwitch: FC<TooltipToggleSwitchProps> = ({ children, tooltip = {}, ...rest }) => {
const ref = useRef<HTMLSpanElement>();
return (
<>
<span
ref={(el) => {
ref.current = el ?? undefined;
}}
>
<ToggleSwitch {...rest} />
</span>
<UncontrolledTooltip target={(() => ref.current) as any} {...tooltip}>{children}</UncontrolledTooltip>
</>
);
};

View file

@ -27,3 +27,5 @@ export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0'
export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' }); export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' });
export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' }); export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' });
export const supportsAllTagsFiltering = supportsNonOrphanVisits;

View file

@ -7,6 +7,8 @@ import SearchField from '../../src/utils/SearchField';
import Tag from '../../src/tags/helpers/Tag'; import Tag from '../../src/tags/helpers/Tag';
import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
import ColorGenerator from '../../src/utils/services/ColorGenerator'; 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.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@ -20,11 +22,11 @@ describe('<ShortUrlsFilteringBar />', () => {
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>()); const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>());
const navigate = jest.fn(); const navigate = jest.fn();
const now = new Date(); const now = new Date();
const createWrapper = (search = '') => { const createWrapper = (search = '', selectedServer?: SelectedServer) => {
(useLocation as any).mockReturnValue({ search }); (useLocation as any).mockReturnValue({ search });
(useNavigate as any).mockReturnValue(navigate); (useNavigate as any).mockReturnValue(navigate);
wrapper = shallow(<ShortUrlsFilteringBar />); wrapper = shallow(<ShortUrlsFilteringBar selectedServer={selectedServer ?? Mock.all<SelectedServer>()} />);
return wrapper; return wrapper;
}; };
@ -83,4 +85,46 @@ describe('<ShortUrlsFilteringBar />', () => {
dateRange.simulate('datesChange', dates); dateRange.simulate('datesChange', dates);
expect(navigate).toHaveBeenCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`); expect(navigate).toHaveBeenCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`);
}); });
it.each([
[ 'tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '3.0.0' }), 1 ],
[ 'tags=foo,bar', Mock.of<ReachableServer>({ version: '3.1.0' }), 1 ],
[ 'tags=foo', Mock.of<ReachableServer>({ version: '3.0.0' }), 0 ],
[ '', Mock.of<ReachableServer>({ version: '3.0.0' }), 0 ],
[ 'tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '2.10.0' }), 0 ],
[ '', Mock.of<ReachableServer>({ 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<ReachableServer>({ 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<ReachableServer>({ version: '3.0.0' }));
const toggle = wrapper.find(TooltipToggleSwitch);
expect(navigate).not.toHaveBeenCalled();
toggle.simulate('change');
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
});
}); });

View file

@ -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('<TooltipToggleSwitch />', () => {
let wrapper: ShallowWrapper;
const createWrapper = (props: PropsWithChildren<TooltipToggleSwitchProps> = {}) => {
wrapper = shallow(<TooltipToggleSwitch {...props} />);
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));
});
});