Improved tags filtering for short URLs, allowing to select from any existing tag

This commit is contained in:
Alejandro Celaya 2022-05-14 12:53:02 +02:00
parent e387706a7b
commit 4b97abaf72
6 changed files with 39 additions and 42 deletions

View file

@ -1,5 +1,11 @@
@import '../utils/base'; @import '../utils/base';
.input-group > .react-tags {
flex: 1 1 auto;
width: 1%;
min-width: 0;
}
.react-tags { .react-tags {
position: relative; position: relative;
padding: 5px 0 0 6px; padding: 5px 0 0 6px;

View file

@ -1,3 +1,4 @@
.short-urls-filtering-bar__tags-icon { .short-urls-filtering-bar__tags-icon {
vertical-align: bottom; vertical-align: bottom;
font-size: 1.6rem;
} }

View file

@ -1,24 +1,22 @@
import { FC } from 'react'; import { FC } from 'react';
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { Row } from 'reactstrap'; import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
import classNames from 'classnames'; import classNames from 'classnames';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; 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 { DateRange } from '../utils/dates/types'; import { DateRange } from '../utils/dates/types';
import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { supportsAllTagsFiltering } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch';
import { OrderDir } from '../utils/helpers/ordering'; import { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown'; import { OrderingDropdown } from '../utils/OrderingDropdown';
import { useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import './ShortUrlsFilteringBar.scss'; import './ShortUrlsFilteringBar.scss';
export interface ShortUrlsFilteringProps { export interface ShortUrlsFilteringProps {
@ -32,8 +30,8 @@ export interface ShortUrlsFilteringProps {
const dateOrNull = (date?: string) => (date ? parseISO(date) : null); const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
const ShortUrlsFilteringBar = ( const ShortUrlsFilteringBar = (
colorGenerator: ColorGenerator,
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>, ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => { ): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery(); const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery();
const setDates = pipe( const setDates = pipe(
@ -47,10 +45,7 @@ const ShortUrlsFilteringBar = (
(searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm), (searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm),
(searchTerm) => toFirstPage({ search: searchTerm }), (searchTerm) => toFirstPage({ search: searchTerm }),
); );
const removeTag = pipe( const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
(updateTags) => toFirstPage({ tags: updateTags }),
);
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const toggleTagsMode = pipe( const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'), () => (tagsMode === 'any' ? 'all' : 'any'),
@ -61,13 +56,21 @@ const ShortUrlsFilteringBar = (
<div className={classNames('short-urls-filtering-bar-container', className)}> <div className={classNames('short-urls-filtering-bar-container', className)}>
<SearchField initialValue={search} onChange={setSearch} /> <SearchField initialValue={search} onChange={setSearch} />
<Row className="flex-column-reverse flex-lg-row"> <InputGroup className="mt-3">
<div className="col-lg-4 col-xl-6 mt-3"> <TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
<ExportShortUrlsBtn amount={shortUrlsAmount} /> {canChangeTagsMode && tags.length > 1 && (
</div> <>
<div className="col-12 d-block d-lg-none mt-3"> <Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} /> <FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
</div> </Button>
<UncontrolledTooltip target="tagsModeBtn" placement="left">
{tagsMode === 'all' ? 'With all the tags.' : 'With any of the tags.'}
</UncontrolledTooltip>
</>
)}
</InputGroup>
<Row className="flex-lg-row-reverse">
<div className="col-lg-8 col-xl-6 mt-3"> <div className="col-lg-8 col-xl-6 mt-3">
<DateRangeSelector <DateRangeSelector
defaultText="All short URLs" defaultText="All short URLs"
@ -78,26 +81,13 @@ const ShortUrlsFilteringBar = (
onDatesChange={setDates} onDatesChange={setDates}
/> />
</div> </div>
<div className="col-6 col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} />
</div>
<div className="col-6 d-lg-none mt-3">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} />
</div>
</Row> </Row>
{tags.length > 0 && (
<h4 className="mt-3">
{canChangeTagsMode && tags.length > 1 && (
<div className="float-end ms-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 me-1" />
{tags.map((tag) =>
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
</h4>
)}
</div> </div>
); );
}; };

View file

@ -50,7 +50,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader'); bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
bottle.decorator('QrCodeModal', connect(['selectedServer'])); bottle.decorator('QrCodeModal', connect(['selectedServer']));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn'); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter'); bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer'])); bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));

View file

@ -10,6 +10,7 @@ export interface TagsSelectorProps {
selectedTags: string[]; selectedTags: string[];
onChange: (tags: string[]) => void; onChange: (tags: string[]) => void;
placeholder?: string; placeholder?: string;
allowNew?: boolean;
} }
interface TagsSelectorConnectProps extends TagsSelectorProps { interface TagsSelectorConnectProps extends TagsSelectorProps {
@ -21,7 +22,7 @@ interface TagsSelectorConnectProps extends TagsSelectorProps {
const toComponentTag = (tag: string) => ({ id: tag, name: tag }); const toComponentTag = (tag: string) => ({ id: tag, name: tag });
const TagsSelector = (colorGenerator: ColorGenerator) => ( const TagsSelector = (colorGenerator: ColorGenerator) => (
{ selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps, { selectedTags, onChange, placeholder, listTags, tagsList, settings, allowNew = true }: TagsSelectorConnectProps,
) => { ) => {
useEffect(() => { useEffect(() => {
listTags(); listTags();
@ -43,7 +44,7 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
tagComponent={ReactTagsTag} tagComponent={ReactTagsTag}
suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)} suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)}
suggestionComponent={ReactTagsSuggestion} suggestionComponent={ReactTagsSuggestion}
allowNew allowNew={allowNew}
addOnBlur addOnBlur
placeholderText={placeholder ?? 'Add tags to the URL'} placeholderText={placeholder ?? 'Add tags to the URL'}
minQueryLength={1} minQueryLength={1}

View file

@ -6,7 +6,6 @@ import filteringBarCreator from '../../src/short-urls/ShortUrlsFilteringBar';
import SearchField from '../../src/utils/SearchField'; 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 { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch'; import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
@ -21,7 +20,7 @@ jest.mock('react-router-dom', () => ({
describe('<ShortUrlsFilteringBar />', () => { describe('<ShortUrlsFilteringBar />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const ExportShortUrlsBtn = () => null; const ExportShortUrlsBtn = () => null;
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>(), ExportShortUrlsBtn); const ShortUrlsFilteringBar = filteringBarCreator(ExportShortUrlsBtn, () => null);
const navigate = jest.fn(); const navigate = jest.fn();
const handleOrderBy = jest.fn(); const handleOrderBy = jest.fn();
const now = new Date(); const now = new Date();