Merge pull request #646 from acelaya-forks/feature/short-url-filtering

Feature/short url filtering
This commit is contained in:
Alejandro Celaya 2022-05-14 16:48:18 +02:00 committed by GitHub
commit 6f2639fd1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 314 additions and 364 deletions

View file

@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
### Added
* [#622](https://github.com/shlinkio/shlink-web-client/pull/622) Added support to load domain visits when consuming Shlink 3.1.0 or newer.
* [#582](https://github.com/shlinkio/shlink-web-client/pull/582) Improved filtering short URLs by tag.
Now, a new full tags selector component is available, which allows selecting any of the existing tags and also composes a toggle to filter by "any" tag or "all" tags.
### Changed
* [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18.

18
package-lock.json generated
View file

@ -32,7 +32,7 @@
"react-copy-to-clipboard": "^5.0.4",
"react-datepicker": "^4.7.0",
"react-dom": "^18.1.0",
"react-external-link": "^1.2.2",
"react-external-link": "^2.0.0",
"react-leaflet": "^4.0.0",
"react-redux": "^8.0.0",
"react-router-dom": "^6.3.0",
@ -19960,12 +19960,12 @@
"dev": true
},
"node_modules/react-external-link": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.2.tgz",
"integrity": "sha512-CbJidnDmhcKlH5gVyt2dbmylcwayMY1wuRW8J1V1o7ZPMHdoUrDDmh/GvAMe847eI3sQBg7PLwSLAl5GiyuI+g==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-2.0.0.tgz",
"integrity": "sha512-Q/Lso75l6OHOTvmhJ2YhnfN2f/0RJw76C4rEFkiiivNApNvCtyAFythdW4SpXHMPK6bbE8kk4j23+Zx+r1ImbA==",
"peerDependencies": {
"react": "^17.0",
"react-dom": "^17.0"
"react": "^17.0 || ^18.0",
"react-dom": "^17.0 || ^18.0"
}
},
"node_modules/react-fast-compare": {
@ -41730,9 +41730,9 @@
"dev": true
},
"react-external-link": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.2.tgz",
"integrity": "sha512-CbJidnDmhcKlH5gVyt2dbmylcwayMY1wuRW8J1V1o7ZPMHdoUrDDmh/GvAMe847eI3sQBg7PLwSLAl5GiyuI+g==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-2.0.0.tgz",
"integrity": "sha512-Q/Lso75l6OHOTvmhJ2YhnfN2f/0RJw76C4rEFkiiivNApNvCtyAFythdW4SpXHMPK6bbE8kk4j23+Zx+r1ImbA==",
"requires": {}
},
"react-fast-compare": {

View file

@ -48,7 +48,7 @@
"react-copy-to-clipboard": "^5.0.4",
"react-datepicker": "^4.7.0",
"react-dom": "^18.1.0",
"react-external-link": "^1.2.2",
"react-external-link": "^2.0.0",
"react-leaflet": "^4.0.0",
"react-redux": "^8.0.0",
"react-router-dom": "^6.3.0",

View file

@ -4,7 +4,7 @@
position: relative;
padding: 5px 0 0 6px;
border-radius: .3rem;
background-color: var(--input-color);
background-color: var(--primary-color);
border: 1px solid var(--input-border-color);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
@ -16,6 +16,16 @@
cursor: text;
}
.input-group > .react-tags {
flex: 1 1 auto;
width: 1%;
min-width: 0;
}
.card .react-tags {
background-color: var(--input-color);
}
.react-tags.is-focused {
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
}
@ -76,7 +86,7 @@
font-size: 1.25rem;
line-height: inherit;
color: var(--input-text-color);
background-color: var(--input-color);
background-color: inherit;
/* prevent autoresize overflowing the container */
max-width: 100%;
@ -88,6 +98,10 @@
outline: none;
}
.react-tags__search-input::placeholder {
color: #6c757d;
}
.react-tags__search-input::-ms-clear {
display: none;
}

View file

@ -3,7 +3,7 @@ import Message from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField';
import { SearchField } from '../utils/SearchField';
import { ShlinkDomainRedirects } from '../api/types';
import { SelectedServer } from '../servers/data';
import { DomainsList } from './reducers/domainsList';

View file

@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField';
import { SearchField } from '../utils/SearchField';
import { Result } from '../utils/Result';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';

View file

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

View file

@ -1,24 +1,22 @@
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 { 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 SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import { SearchField } from '../utils/SearchField';
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 { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import './ShortUrlsFilteringBar.scss';
export interface ShortUrlsFilteringProps {
@ -31,9 +29,9 @@ export interface ShortUrlsFilteringProps {
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
const ShortUrlsFilteringBar = (
colorGenerator: ColorGenerator,
export const ShortUrlsFilteringBar = (
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery();
const setDates = pipe(
@ -47,10 +45,7 @@ const ShortUrlsFilteringBar = (
(searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm),
(searchTerm) => toFirstPage({ search: searchTerm }),
);
const removeTag = pipe(
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
(updateTags) => toFirstPage({ tags: updateTags }),
);
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'),
@ -61,13 +56,21 @@ const ShortUrlsFilteringBar = (
<div className={classNames('short-urls-filtering-bar-container', className)}>
<SearchField initialValue={search} onChange={setSearch} />
<Row className="flex-column-reverse flex-lg-row">
<div className="col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} />
</div>
<div className="col-12 d-block d-lg-none mt-3">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} />
</div>
<InputGroup className="mt-3">
<TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
{canChangeTagsMode && tags.length > 1 && (
<>
<Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode">
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
</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">
<DateRangeSelector
defaultText="All short URLs"
@ -78,28 +81,18 @@ const ShortUrlsFilteringBar = (
onDatesChange={setDates}
/>
</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
prefixed={false}
items={SHORT_URLS_ORDERABLE_FIELDS}
order={order}
onChange={handleOrderBy}
/>
</div>
</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>
);
};
export default ShortUrlsFilteringBar;

View file

@ -1,5 +1,5 @@
import Bottle from 'bottlejs';
import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar';
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
import ShortUrlsList from '../ShortUrlsList';
import ShortUrlsRow from '../helpers/ShortUrlsRow';
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
@ -50,7 +50,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
bottle.decorator('QrCodeModal', connect(['selectedServer']));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn');
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));

View file

@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react';
import { Row } from 'reactstrap';
import { pipe } from 'ramda';
import Message from '../utils/Message';
import SearchField from '../utils/SearchField';
import { SearchField } from '../utils/SearchField';
import { SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Result } from '../utils/Result';

View file

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

View file

@ -1,7 +1,7 @@
import { FC } from 'react';
import { Button, ButtonProps } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileDownload } from '@fortawesome/free-solid-svg-icons';
import { faFileCsv } from '@fortawesome/free-solid-svg-icons';
import { prettify } from './helpers/numbers';
interface ExportBtnProps extends Omit<ButtonProps, 'outline' | 'color' | 'disabled'> {
@ -11,6 +11,6 @@ interface ExportBtnProps extends Omit<ButtonProps, 'outline' | 'color' | 'disabl
export const ExportBtn: FC<ExportBtnProps> = ({ amount = 0, loading = false, ...rest }) => (
<Button {...rest} outline color="primary" disabled={loading}>
<FontAwesomeIcon icon={faFileDownload} /> {loading ? 'Exporting...' : <>Export ({prettify(amount)})</>}
<FontAwesomeIcon icon={faFileCsv} /> {loading ? 'Exporting...' : <>Export ({prettify(amount)})</>}
</Button>
);

View file

@ -12,14 +12,14 @@ export interface OrderingDropdownProps<T extends string = string> {
onChange: (orderField?: T, orderDir?: OrderDir) => void;
isButton?: boolean;
right?: boolean;
prefixed?: boolean;
}
export function OrderingDropdown<T extends string = string>(
{ items, order, onChange, isButton = true, right = false }: OrderingDropdownProps<T>,
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,
) {
const handleItemClick = (fieldKey: T) => () => {
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
};
@ -28,11 +28,14 @@ export function OrderingDropdown<T extends string = string>(
<DropdownToggle
caret
color={isButton ? 'primary' : 'link'}
className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })}
className={classNames({
'dropdown-btn__toggle btn-block pe-4 overflow-hidden': isButton,
'btn-sm p-0': !isButton,
})}
>
{!isButton && <>Order by</>}
{isButton && !order.field && <>Order by...</>}
{isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`}
{isButton && !order.field && <i>Order by...</i>}
{isButton && order.field && <>{prefixed && 'Order by: '}{items[order.field]} - <small>{order.dir ?? 'DESC'}</small></>}
</DropdownToggle>
<DropdownMenu
end={right}

View file

@ -22,7 +22,7 @@
@include vertical-align();
left: 15px;
color: #707581;
color: #6c757d;
}
.search-field__close {

View file

@ -15,7 +15,7 @@ interface SearchFieldProps {
initialValue?: string;
}
const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
const [searchTerm, setSearchTerm] = useState(initialValue);
const resetTimer = () => {
@ -55,5 +55,3 @@ const SearchField = ({ onChange, className, large = true, noBorder = false, init
</div>
);
};
export default SearchField;

View file

@ -1,25 +0,0 @@
import { FC, PropsWithChildren, useRef } from 'react';
import { UncontrolledTooltip, UncontrolledTooltipProps } from 'reactstrap';
import { BooleanControlProps } from './BooleanControl';
import ToggleSwitch from './ToggleSwitch';
export type TooltipToggleSwitchProps = BooleanControlProps & PropsWithChildren<{
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

@ -1,3 +1,4 @@
import { endOfDay } from 'date-fns';
import DateInput from '../DateInput';
import { DateRange } from './types';
@ -29,7 +30,7 @@ const DateRangeRow = (
isClearable
minDate={startDate ?? undefined}
disabled={disabled}
onChange={onEndDateChange}
onChange={(date) => onEndDateChange(date && endOfDay(date))}
/>
</div>
</div>

View file

@ -5,7 +5,7 @@ import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-soli
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { UncontrolledTooltip } from 'reactstrap';
import { SimplePaginator } from '../common/SimplePaginator';
import SearchField from '../utils/SearchField';
import { SearchField } from '../utils/SearchField';
import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
import { prettify } from '../utils/helpers/numbers';
import { supportsBotVisits } from '../utils/helpers/features';

View file

@ -4,7 +4,7 @@ import { Button } from 'reactstrap';
import ServersExporter from '../../src/servers/services/ServersExporter';
import { ManageServers as createManageServers } from '../../src/servers/ManageServers';
import { ServersMap, ServerWithId } from '../../src/servers/data';
import SearchField from '../../src/utils/SearchField';
import { SearchField } from '../../src/utils/SearchField';
import { Result } from '../../src/utils/Result';
describe('<ManageServers />', () => {

View file

@ -1,156 +1,136 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Mock } from 'ts-mockery';
import { formatISO } from 'date-fns';
import { useLocation, useNavigate } from 'react-router-dom';
import filteringBarCreator from '../../src/short-urls/ShortUrlsFilteringBar';
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 { endOfDay, formatISO, startOfDay } from 'date-fns';
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
import { DateRange } from '../../src/utils/dates/types';
import { formatDate } from '../../src/utils/helpers/date';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
useParams: jest.fn().mockReturnValue({ serverId: '1' }),
useNavigate: jest.fn(),
useLocation: jest.fn().mockReturnValue({}),
}));
describe('<ShortUrlsFilteringBar />', () => {
let wrapper: ShallowWrapper;
const ExportShortUrlsBtn = () => null;
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>(), ExportShortUrlsBtn);
const ShortUrlsFilteringBar = filteringBarCreator(() => <>ExportShortUrlsBtn</>, () => <>TagsSelector</>);
const navigate = jest.fn();
const handleOrderBy = jest.fn();
const now = new Date();
const createWrapper = (search = '', selectedServer?: SelectedServer) => {
const setUp = (search = '', selectedServer?: SelectedServer) => {
(useLocation as any).mockReturnValue({ search });
(useNavigate as any).mockReturnValue(navigate);
wrapper = shallow(
<ShortUrlsFilteringBar
selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
order={{}}
handleOrderBy={handleOrderBy}
/>,
);
return wrapper;
return {
user: userEvent.setup(),
...render(
<MemoryRouter>
<ShortUrlsFilteringBar
selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
order={{}}
handleOrderBy={handleOrderBy}
/>
</MemoryRouter>,
),
};
};
afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it('renders expected children components', () => {
const wrapper = createWrapper();
setUp();
expect(wrapper.find(SearchField)).toHaveLength(1);
expect(wrapper.find(DateRangeSelector)).toHaveLength(1);
expect(wrapper.find(OrderingDropdown)).toHaveLength(1);
expect(wrapper.find(ExportShortUrlsBtn)).toHaveLength(1);
expect(screen.getByText('ExportShortUrlsBtn')).toBeInTheDocument();
expect(screen.getByText('TagsSelector')).toBeInTheDocument();
});
it('redirects to first page when search field changes', async () => {
const { user } = setUp();
expect(navigate).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Search...'), 'search-term');
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?search=search-term'));
});
it.each([
['tags=foo,bar,baz', 3],
['tags=foo,baz', 2],
['', 0],
['foo=bar', 0],
])('renders the proper amount of tags', (search, expectedTagComps) => {
const wrapper = createWrapper(search);
expect(wrapper.find(Tag)).toHaveLength(expectedTagComps);
});
it('redirects to first page when search field changes', () => {
const wrapper = createWrapper();
const searchField = wrapper.find(SearchField);
expect(navigate).not.toHaveBeenCalled();
searchField.simulate('change', 'search-term');
expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?search=search-term');
});
it('redirects to first page when a tag is removed', () => {
const wrapper = createWrapper('tags=foo,bar');
const tag = wrapper.find(Tag).first();
expect(navigate).not.toHaveBeenCalled();
tag.simulate('close');
expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?tags=bar');
});
it.each([
[{ startDate: now }, `startDate=${encodeURIComponent(formatISO(now))}`],
[{ endDate: now }, `endDate=${encodeURIComponent(formatISO(now))}`],
[{ startDate: now } as DateRange, `startDate=${encodeURIComponent(formatISO(startOfDay(now)))}`],
[{ endDate: now } as DateRange, `endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`],
[
{ startDate: now, endDate: now },
`startDate=${encodeURIComponent(formatISO(now))}&endDate=${encodeURIComponent(formatISO(now))}`,
{ startDate: now, endDate: now } as DateRange,
`startDate=${encodeURIComponent(formatISO(startOfDay(now)))}&endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`,
],
])('redirects to first page when date range changes', (dates, expectedQuery) => {
const wrapper = createWrapper();
const dateRange = wrapper.find(DateRangeSelector);
])('redirects to first page when date range changes', async (dates, expectedQuery) => {
const { user } = setUp();
await user.click(screen.getByRole('button', { name: 'All short URLs' }));
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
dateRange.simulate('datesChange', dates);
expect(navigate).toHaveBeenCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`);
dates.startDate && await user.type(screen.getByPlaceholderText('Since...'), formatDate()(dates.startDate) ?? '');
dates.endDate && await user.type(screen.getByPlaceholderText('Until...'), formatDate()(dates.endDate) ?? '');
expect(navigate).toHaveBeenLastCalledWith(`/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],
['tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '3.0.0' }), true],
['tags=foo,bar', Mock.of<ReachableServer>({ version: '3.1.0' }), true],
['tags=foo', Mock.of<ReachableServer>({ version: '3.0.0' }), false],
['', Mock.of<ReachableServer>({ version: '3.0.0' }), false],
['tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '2.10.0' }), false],
['', Mock.of<ReachableServer>({ version: '2.10.0' }), false],
])(
'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);
(search, selectedServer, shouldHaveComponent) => {
setUp(search, selectedServer);
expect(toggle).toHaveLength(expectedTagToggleComponents);
if (shouldHaveComponent) {
expect(screen.getByLabelText('Change tags mode')).toBeInTheDocument();
} else {
expect(screen.queryByLabelText('Change tags mode')).not.toBeInTheDocument();
}
},
);
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);
['', 'With any of the tags.'],
['&tagsMode=all', 'With all the tags.'],
['&tagsMode=any', 'With any of the tags.'],
])('expected tags mode tooltip title', async (initialTagsMode, expectedToggleText) => {
const { user } = setUp(`tags=foo,bar${initialTagsMode}`, Mock.of<ReachableServer>({ version: '3.0.0' }));
expect(toggle.prop('children')).toEqual(expectedToggleText);
expect(toggle.prop('checked')).toEqual(expectedChecked);
await user.hover(screen.getByLabelText('Change tags mode'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(expectedToggleText);
});
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);
])('redirects to first page when tags mode changes', async (initialTagsMode, expectedRedirectTagsMode) => {
const { user } = setUp(`tags=foo,bar${initialTagsMode}`, Mock.of<ReachableServer>({ version: '3.0.0' }));
expect(navigate).not.toHaveBeenCalled();
toggle.simulate('change');
await user.click(screen.getByLabelText('Change tags mode'));
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
});
it('handles order through dropdown', () => {
const wrapper = createWrapper();
it('handles order through dropdown', async () => {
const { user } = setUp();
const clickMenuItem = async (name: string | RegExp) => {
await user.click(screen.getByRole('button', { name: 'Order by...' }));
await user.click(await screen.findByRole('menuitem', { name }));
};
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({});
await clickMenuItem(/^Short URL/);
expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'ASC');
wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC');
expect(handleOrderBy).toHaveBeenCalledWith('visits', 'ASC');
await clickMenuItem(/^Title/);
expect(handleOrderBy).toHaveBeenCalledWith('title', 'ASC');
wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC');
expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'DESC');
wrapper.find(OrderingDropdown).simulate('change', undefined, undefined);
expect(handleOrderBy).toHaveBeenCalledWith(undefined, undefined);
await clickMenuItem(/^Long URL/);
expect(handleOrderBy).toHaveBeenCalledWith('longUrl', 'ASC');
});
});

View file

@ -7,7 +7,7 @@ import { TagsList } from '../../src/tags/reducers/tagsList';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { Result } from '../../src/utils/Result';
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
import SearchField from '../../src/utils/SearchField';
import { SearchField } from '../../src/utils/SearchField';
import { Settings } from '../../src/settings/reducers/settings';
import { TagsOrderableFields } from '../../src/tags/data/TagsListChildrenProps';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';

View file

@ -1,29 +1,22 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileDownload } from '@fortawesome/free-solid-svg-icons';
import { render, screen } from '@testing-library/react';
import { ExportBtn } from '../../src/utils/ExportBtn';
describe('<ExportBtn />', () => {
let wrapper: ShallowWrapper;
const createWrapper = (amount?: number, loading = false) => {
wrapper = shallow(<ExportBtn amount={amount} loading={loading} />);
return wrapper;
};
afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
const setUp = (amount?: number, loading = false) => render(<ExportBtn amount={amount} loading={loading} />);
it.each([
[true, 'Exporting...'],
[false, 'Export ('],
])('renders a button', (loading, text) => {
const wrapper = createWrapper(undefined, loading);
[false, 'Export (0)'],
])('renders loading state when expected', async (loading, text) => {
setUp(undefined, loading);
const btn = await screen.findByRole('button');
expect(wrapper.prop('outline')).toEqual(true);
expect(wrapper.prop('color')).toEqual('primary');
expect(wrapper.prop('disabled')).toEqual(loading);
expect(wrapper.html()).toContain(text);
expect(btn).toHaveTextContent(text);
if (loading) {
expect(btn).toHaveAttribute('disabled');
} else {
expect(btn).not.toHaveAttribute('disabled');
}
});
it.each([
@ -31,17 +24,13 @@ describe('<ExportBtn />', () => {
[10, '10'],
[10_000, '10,000'],
[10_000_000, '10,000,000'],
])('renders expected amount', (amount, expectedRenderedAmount) => {
const wrapper = createWrapper(amount);
expect(wrapper.html()).toContain(`Export (${expectedRenderedAmount})`);
])('renders expected amount', async (amount, expectedRenderedAmount) => {
setUp(amount);
expect(await screen.findByRole('button')).toHaveTextContent(`Export (${expectedRenderedAmount})`);
});
it('renders expected icon', () => {
const wrapper = createWrapper();
const icon = wrapper.find(FontAwesomeIcon);
expect(icon).toHaveLength(1);
expect(icon.prop('icon')).toEqual(faFileDownload);
setUp();
expect(screen.getByRole('img', { hidden: true })).toMatchSnapshot();
});
});

View file

@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { values } from 'ramda';
import { OrderingDropdown, OrderingDropdownProps } from '../../src/utils/OrderingDropdown';
import { OrderDir } from '../../src/utils/helpers/ordering';
describe('<OrderingDropdown />', () => {
const items = {
foo: 'Foo',
bar: 'Bar',
baz: 'Hello World',
};
const setUp = (props: Partial<OrderingDropdownProps> = {}) => ({
user: userEvent.setup(),
...render(<OrderingDropdown items={items} order={{}} onChange={jest.fn()} {...props} />),
});
const setUpWithDisplayedMenu = async (props: Partial<OrderingDropdownProps> = {}) => {
const result = setUp(props);
const { user } = result;
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('menu')).toBeInTheDocument();
return result;
};
it('properly renders provided list of items', async () => {
await setUpWithDisplayedMenu();
const dropdownItems = screen.getAllByRole('menuitem');
expect(dropdownItems).toHaveLength(values(items).length);
expect(dropdownItems[0]).toHaveTextContent('Foo');
expect(dropdownItems[1]).toHaveTextContent('Bar');
expect(dropdownItems[2]).toHaveTextContent('Hello World');
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument();
});
it.each([
['foo', 0],
['bar', 1],
['baz', 2],
])('properly marks selected field as active with proper icon', async (field, expectedActiveIndex) => {
await setUpWithDisplayedMenu({ order: { field, dir: 'DESC' } });
const dropdownItems = screen.getAllByRole('menuitem');
expect(dropdownItems).toHaveLength(4);
expect(screen.queryByRole('button', { name: 'Clear selection' })).not.toBeInTheDocument();
dropdownItems.forEach((item, index) => {
if (index === expectedActiveIndex) {
expect(item).toHaveAttribute('class', expect.stringContaining('active'));
} else {
expect(item).not.toHaveAttribute('class', expect.stringContaining('active'));
}
});
});
it.each([
[{} as any, 'foo', 'ASC'],
[{ field: 'baz', dir: 'ASC' } as any, 'foo', 'ASC'],
[{ field: 'foo', dir: 'ASC' } as any, 'foo', 'DESC'],
[{ field: 'foo', dir: 'DESC' } as any, undefined, undefined],
])(
'triggers change with proper params depending on clicked item and initial state',
async (initialOrder, expectedNewField, expectedNewDir) => {
const onChange = jest.fn();
const { user } = await setUpWithDisplayedMenu({ onChange, order: initialOrder });
await user.click(screen.getAllByRole('menuitem')[0]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expectedNewField, expectedNewDir);
},
);
it('clears selection when last item is clicked', async () => {
const onChange = jest.fn();
const { user } = await setUpWithDisplayedMenu({ onChange, order: { field: 'baz', dir: 'ASC' } });
await user.click(screen.getAllByRole('menuitem')[3]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith();
});
it.each([
[{ isButton: false }, /Order by$/],
[{ isButton: true }, 'Order by...'],
[
{ isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } },
'Order by: Foo - ASC',
],
[
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } },
'Order by: Hello World - DESC',
],
[{ isButton: true, order: { field: 'baz' } }, 'Order by: Hello World - DESC'],
[
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir }, prefixed: false },
/^Hello World - DESC/,
],
])('with %s props displays %s in toggle', async (props, expectedText) => {
setUp(props);
expect(screen.getByRole('button')).toHaveTextContent(expectedText);
});
});

View file

@ -1,97 +0,0 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { DropdownItem, DropdownToggle } from 'reactstrap';
import { identity, values } from 'ramda';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons';
import { OrderingDropdown, OrderingDropdownProps } from '../../src/utils/OrderingDropdown';
import { OrderDir } from '../../src/utils/helpers/ordering';
describe('<SortingDropdown />', () => {
let wrapper: ShallowWrapper;
const items = {
foo: 'Foo',
bar: 'Bar',
baz: 'Hello World',
};
const createWrapper = (props: Partial<OrderingDropdownProps> = {}) => {
wrapper = shallow(<OrderingDropdown items={items} order={{}} onChange={identity} {...props} />);
return wrapper;
};
afterEach(() => wrapper?.unmount());
it('properly renders provided list of items', () => {
const wrapper = createWrapper();
const dropdownItems = wrapper.find(DropdownItem);
const secondIndex = 2;
const clearItemsCount = 2;
expect(dropdownItems).toHaveLength(values(items).length + clearItemsCount);
expect(dropdownItems.at(0).html()).toContain('Foo');
expect(dropdownItems.at(1).html()).toContain('Bar');
expect(dropdownItems.at(secondIndex).html()).toContain('Hello World');
});
it('properly marks selected field as active with proper icon', () => {
const wrapper = createWrapper({ order: { field: 'bar', dir: 'DESC' } });
const activeItem = wrapper.find('DropdownItem[active=true]');
const activeItemIcon = activeItem.first().find(FontAwesomeIcon);
expect(activeItem).toHaveLength(1);
expect(activeItemIcon.prop('icon')).toEqual(caretDownIcon);
});
it('triggers change function when item is clicked and no order field was provided', () => {
const onChange = jest.fn();
const wrapper = createWrapper({ onChange });
const firstItem = wrapper.find(DropdownItem).first();
firstItem.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('foo', 'ASC');
});
it('triggers change function when item is clicked and an order field was provided', () => {
const onChange = jest.fn();
const wrapper = createWrapper({ onChange, order: { field: 'baz', dir: 'ASC' } });
const firstItem = wrapper.find(DropdownItem).first();
firstItem.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('foo', 'ASC');
});
it('updates order dir when already selected item is clicked', () => {
const onChange = jest.fn();
const wrapper = createWrapper({ onChange, order: { field: 'foo', dir: 'ASC' } });
const firstItem = wrapper.find(DropdownItem).first();
firstItem.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('foo', 'DESC');
});
it.each([
[{ isButton: false }, <>Order by</>],
[{ isButton: true }, <>Order by...</>],
[
{ isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } },
'Order by: "Foo" - "ASC"',
],
[
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } },
'Order by: "Hello World" - "DESC"',
],
[{ isButton: true, order: { field: 'baz' } }, 'Order by: "Hello World" - "DESC"'],
])('displays expected text in toggle', (props, expectedText) => {
const wrapper = createWrapper(props);
const toggle = wrapper.find(DropdownToggle);
const [children] = (toggle.prop('children') as any[]).filter(Boolean);
expect(children).toEqual(expectedText);
});
});

View file

@ -1,38 +0,0 @@
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));
});
});

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ExportBtn /> renders expected icon 1`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-file-csv "
data-icon="file-csv"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 384 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 0v128h128L256 0zM224 128L224 0H48C21.49 0 0 21.49 0 48v416C0 490.5 21.49 512 48 512h288c26.51 0 48-21.49 48-48V160h-127.1C238.3 160 224 145.7 224 128zM128 280C128 284.4 124.4 288 120 288H112C103.1 288 96 295.1 96 304v32C96 344.9 103.1 352 112 352h8C124.4 352 128 355.6 128 360v16C128 380.4 124.4 384 120 384H112C85.5 384 64 362.5 64 336v-32C64 277.5 85.5 256 112 256h8C124.4 256 128 259.6 128 264V280zM172.3 384H160c-4.375 0-8-3.625-8-8v-16C152 355.6 155.6 352 160 352h12.25c6 0 10.38-3.5 10.38-6.625c0-1.25-.75-2.625-2.125-3.875l-21.88-18.75C150.3 315.5 145.4 305.3 145.4 294.6C145.4 273.4 164.4 256 187.8 256H200c4.375 0 8 3.625 8 8v16C208 284.4 204.4 288 200 288H187.8c-6 0-10.38 3.5-10.38 6.625c0 1.25 .75 2.625 2.125 3.875l21.88 18.75c8.375 7.25 13.25 17.5 13.25 28.12C214.6 366.6 195.6 384 172.3 384zM288 284.8V264C288 259.6 291.6 256 296 256h16C316.4 256 320 259.6 320 264v20.75c0 35.5-12.88 69-36.25 94.13C280.8 382.1 276.5 384 272 384s-8.75-1.875-11.75-5.125C236.9 353.8 224 320.3 224 284.8V264C224 259.6 227.6 256 232 256h16C252.4 256 256 259.6 256 264v20.75c0 20.38 5.75 40.25 16 56.88C282.3 325 288 305.1 288 284.8z"
fill="currentColor"
/>
</svg>
`;

View file

@ -3,7 +3,7 @@ import { Mock } from 'ts-mockery';
import VisitsTable, { VisitsTableProps } from '../../src/visits/VisitsTable';
import { rangeOf } from '../../src/utils/utils';
import { SimplePaginator } from '../../src/common/SimplePaginator';
import SearchField from '../../src/utils/SearchField';
import { SearchField } from '../../src/utils/SearchField';
import { NormalizedVisit } from '../../src/visits/types';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { SemVer } from '../../src/utils/helpers/version';