mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 01:20:24 +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
|
||||
* [#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.
|
||||
|
|
|
@ -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<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||
|
|
|
@ -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 (
|
||||
<div className="short-urls-filtering-bar-container">
|
||||
|
@ -53,9 +65,19 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => () => {
|
|||
</div>
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<h4 className="short-urls-filtering-bar__selected-tag mt-3">
|
||||
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon" />
|
||||
|
||||
<h4 className="mt-3">
|
||||
{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) =>
|
||||
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||
</h4>
|
||||
|
|
|
@ -32,7 +32,7 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, 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<ShortUrlsTableProps>, ShortUrlsFilteri
|
|||
startDate,
|
||||
endDate,
|
||||
orderBy: actualOrderBy,
|
||||
tagsMode,
|
||||
});
|
||||
}, [ page, search, selectedTags, startDate, endDate, actualOrderBy ]);
|
||||
}, [ page, search, selectedTags, startDate, endDate, actualOrderBy, tagsMode ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -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<ShortUrlsFiltering>) => void;
|
||||
|
||||
|
@ -17,6 +18,7 @@ interface ShortUrlsQueryCommon {
|
|||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
tagsMode?: TagsFilteringMode;
|
||||
}
|
||||
|
||||
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
||||
|
|
|
@ -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');
|
||||
|
|
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 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 { 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('<ShortUrlsFilteringBar />', () => {
|
|||
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>());
|
||||
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(<ShortUrlsFilteringBar />);
|
||||
wrapper = shallow(<ShortUrlsFilteringBar selectedServer={selectedServer ?? Mock.all<SelectedServer>()} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
@ -83,4 +85,46 @@ describe('<ShortUrlsFilteringBar />', () => {
|
|||
dateRange.simulate('datesChange', dates);
|
||||
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