mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #569 from acelaya-forks/feature/tags-mode
Added support for tag mode on short URLs list
This commit is contained in:
commit
a4f36f8620
10 changed files with 147 additions and 9 deletions
|
@ -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.
|
||||||
|
|
|
@ -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'> {
|
||||||
|
|
|
@ -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 && (
|
||||||
|
<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>
|
||||||
|
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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');
|
||||||
|
|
24
src/utils/TooltipToggleSwitch.tsx
Normal file
24
src/utils/TooltipToggleSwitch.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
|
|
@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
38
test/utils/TooltipToggleSwitch.test.tsx
Normal file
38
test/utils/TooltipToggleSwitch.test.tsx
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue