mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #646 from acelaya-forks/feature/short-url-filtering
Feature/short url filtering
This commit is contained in:
commit
6f2639fd1f
27 changed files with 314 additions and 364 deletions
|
@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### 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.
|
* [#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
|
### Changed
|
||||||
* [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18.
|
* [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18.
|
||||||
|
|
18
package-lock.json
generated
18
package-lock.json
generated
|
@ -32,7 +32,7 @@
|
||||||
"react-copy-to-clipboard": "^5.0.4",
|
"react-copy-to-clipboard": "^5.0.4",
|
||||||
"react-datepicker": "^4.7.0",
|
"react-datepicker": "^4.7.0",
|
||||||
"react-dom": "^18.1.0",
|
"react-dom": "^18.1.0",
|
||||||
"react-external-link": "^1.2.2",
|
"react-external-link": "^2.0.0",
|
||||||
"react-leaflet": "^4.0.0",
|
"react-leaflet": "^4.0.0",
|
||||||
"react-redux": "^8.0.0",
|
"react-redux": "^8.0.0",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
|
@ -19960,12 +19960,12 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/react-external-link": {
|
"node_modules/react-external-link": {
|
||||||
"version": "1.2.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-2.0.0.tgz",
|
||||||
"integrity": "sha512-CbJidnDmhcKlH5gVyt2dbmylcwayMY1wuRW8J1V1o7ZPMHdoUrDDmh/GvAMe847eI3sQBg7PLwSLAl5GiyuI+g==",
|
"integrity": "sha512-Q/Lso75l6OHOTvmhJ2YhnfN2f/0RJw76C4rEFkiiivNApNvCtyAFythdW4SpXHMPK6bbE8kk4j23+Zx+r1ImbA==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^17.0",
|
"react": "^17.0 || ^18.0",
|
||||||
"react-dom": "^17.0"
|
"react-dom": "^17.0 || ^18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-fast-compare": {
|
"node_modules/react-fast-compare": {
|
||||||
|
@ -41730,9 +41730,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"react-external-link": {
|
"react-external-link": {
|
||||||
"version": "1.2.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-2.0.0.tgz",
|
||||||
"integrity": "sha512-CbJidnDmhcKlH5gVyt2dbmylcwayMY1wuRW8J1V1o7ZPMHdoUrDDmh/GvAMe847eI3sQBg7PLwSLAl5GiyuI+g==",
|
"integrity": "sha512-Q/Lso75l6OHOTvmhJ2YhnfN2f/0RJw76C4rEFkiiivNApNvCtyAFythdW4SpXHMPK6bbE8kk4j23+Zx+r1ImbA==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"react-fast-compare": {
|
"react-fast-compare": {
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
"react-copy-to-clipboard": "^5.0.4",
|
"react-copy-to-clipboard": "^5.0.4",
|
||||||
"react-datepicker": "^4.7.0",
|
"react-datepicker": "^4.7.0",
|
||||||
"react-dom": "^18.1.0",
|
"react-dom": "^18.1.0",
|
||||||
"react-external-link": "^1.2.2",
|
"react-external-link": "^2.0.0",
|
||||||
"react-leaflet": "^4.0.0",
|
"react-leaflet": "^4.0.0",
|
||||||
"react-redux": "^8.0.0",
|
"react-redux": "^8.0.0",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 5px 0 0 6px;
|
padding: 5px 0 0 6px;
|
||||||
border-radius: .3rem;
|
border-radius: .3rem;
|
||||||
background-color: var(--input-color);
|
background-color: var(--primary-color);
|
||||||
border: 1px solid var(--input-border-color);
|
border: 1px solid var(--input-border-color);
|
||||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||||
|
|
||||||
|
@ -16,6 +16,16 @@
|
||||||
cursor: text;
|
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 {
|
.react-tags.is-focused {
|
||||||
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
||||||
}
|
}
|
||||||
|
@ -76,7 +86,7 @@
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
color: var(--input-text-color);
|
color: var(--input-text-color);
|
||||||
background-color: var(--input-color);
|
background-color: inherit;
|
||||||
|
|
||||||
/* prevent autoresize overflowing the container */
|
/* prevent autoresize overflowing the container */
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
@ -88,6 +98,10 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-tags__search-input::placeholder {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
.react-tags__search-input::-ms-clear {
|
.react-tags__search-input::-ms-clear {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import Message from '../utils/Message';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SearchField from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { ShlinkDomainRedirects } from '../api/types';
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { DomainsList } from './reducers/domainsList';
|
import { DomainsList } from './reducers/domainsList';
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SearchField from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -31,9 +29,9 @@ export interface ShortUrlsFilteringProps {
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
|
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
|
||||||
|
|
||||||
const ShortUrlsFilteringBar = (
|
export 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" aria-label="Change tags mode">
|
||||||
<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,28 +81,18 @@ 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
|
||||||
|
prefixed={false}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortUrlsFilteringBar;
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar';
|
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
|
||||||
import ShortUrlsList from '../ShortUrlsList';
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
||||||
|
@ -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']));
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react';
|
||||||
import { Row } from 'reactstrap';
|
import { Row } from 'reactstrap';
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import SearchField from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Button, ButtonProps } from 'reactstrap';
|
import { Button, ButtonProps } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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';
|
import { prettify } from './helpers/numbers';
|
||||||
|
|
||||||
interface ExportBtnProps extends Omit<ButtonProps, 'outline' | 'color' | 'disabled'> {
|
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 }) => (
|
export const ExportBtn: FC<ExportBtnProps> = ({ amount = 0, loading = false, ...rest }) => (
|
||||||
<Button {...rest} outline color="primary" disabled={loading}>
|
<Button {...rest} outline color="primary" disabled={loading}>
|
||||||
<FontAwesomeIcon icon={faFileDownload} /> {loading ? 'Exporting...' : <>Export ({prettify(amount)})</>}
|
<FontAwesomeIcon icon={faFileCsv} /> {loading ? 'Exporting...' : <>Export ({prettify(amount)})</>}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,14 +12,14 @@ export interface OrderingDropdownProps<T extends string = string> {
|
||||||
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
||||||
isButton?: boolean;
|
isButton?: boolean;
|
||||||
right?: boolean;
|
right?: boolean;
|
||||||
|
prefixed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrderingDropdown<T extends string = string>(
|
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 handleItemClick = (fieldKey: T) => () => {
|
||||||
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
|
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
|
||||||
|
|
||||||
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -28,11 +28,14 @@ export function OrderingDropdown<T extends string = string>(
|
||||||
<DropdownToggle
|
<DropdownToggle
|
||||||
caret
|
caret
|
||||||
color={isButton ? 'primary' : 'link'}
|
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 by</>}
|
||||||
{isButton && !order.field && <>Order by...</>}
|
{isButton && !order.field && <i>Order by...</i>}
|
||||||
{isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`}
|
{isButton && order.field && <>{prefixed && 'Order by: '}{items[order.field]} - <small>{order.dir ?? 'DESC'}</small></>}
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
end={right}
|
end={right}
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
|
|
||||||
left: 15px;
|
left: 15px;
|
||||||
color: #707581;
|
color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-field__close {
|
.search-field__close {
|
||||||
|
|
|
@ -15,7 +15,7 @@ interface SearchFieldProps {
|
||||||
initialValue?: string;
|
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 [searchTerm, setSearchTerm] = useState(initialValue);
|
||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
|
@ -55,5 +55,3 @@ const SearchField = ({ onChange, className, large = true, noBorder = false, init
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SearchField;
|
|
||||||
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { endOfDay } from 'date-fns';
|
||||||
import DateInput from '../DateInput';
|
import DateInput from '../DateInput';
|
||||||
import { DateRange } from './types';
|
import { DateRange } from './types';
|
||||||
|
|
||||||
|
@ -29,7 +30,7 @@ const DateRangeRow = (
|
||||||
isClearable
|
isClearable
|
||||||
minDate={startDate ?? undefined}
|
minDate={startDate ?? undefined}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={onEndDateChange}
|
onChange={(date) => onEndDateChange(date && endOfDay(date))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-soli
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { SimplePaginator } from '../common/SimplePaginator';
|
import { SimplePaginator } from '../common/SimplePaginator';
|
||||||
import SearchField from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
|
import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { supportsBotVisits } from '../utils/helpers/features';
|
import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Button } from 'reactstrap';
|
||||||
import ServersExporter from '../../src/servers/services/ServersExporter';
|
import ServersExporter from '../../src/servers/services/ServersExporter';
|
||||||
import { ManageServers as createManageServers } from '../../src/servers/ManageServers';
|
import { ManageServers as createManageServers } from '../../src/servers/ManageServers';
|
||||||
import { ServersMap, ServerWithId } from '../../src/servers/data';
|
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';
|
import { Result } from '../../src/utils/Result';
|
||||||
|
|
||||||
describe('<ManageServers />', () => {
|
describe('<ManageServers />', () => {
|
||||||
|
|
|
@ -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 { Mock } from 'ts-mockery';
|
||||||
import { formatISO } from 'date-fns';
|
import { endOfDay, formatISO, startOfDay } from 'date-fns';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import filteringBarCreator from '../../src/short-urls/ShortUrlsFilteringBar';
|
import { ShortUrlsFilteringBar as 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 { ReachableServer, SelectedServer } from '../../src/servers/data';
|
import { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch';
|
import { DateRange } from '../../src/utils/dates/types';
|
||||||
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
|
import { formatDate } from '../../src/utils/helpers/date';
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('react-router-dom'),
|
...jest.requireActual('react-router-dom'),
|
||||||
useNavigate: jest.fn(),
|
|
||||||
useParams: jest.fn().mockReturnValue({ serverId: '1' }),
|
useParams: jest.fn().mockReturnValue({ serverId: '1' }),
|
||||||
|
useNavigate: jest.fn(),
|
||||||
useLocation: jest.fn().mockReturnValue({}),
|
useLocation: jest.fn().mockReturnValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('<ShortUrlsFilteringBar />', () => {
|
describe('<ShortUrlsFilteringBar />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
const ShortUrlsFilteringBar = filteringBarCreator(() => <>ExportShortUrlsBtn</>, () => <>TagsSelector</>);
|
||||||
const ExportShortUrlsBtn = () => null;
|
|
||||||
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>(), ExportShortUrlsBtn);
|
|
||||||
const navigate = jest.fn();
|
const navigate = jest.fn();
|
||||||
const handleOrderBy = jest.fn();
|
const handleOrderBy = jest.fn();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const createWrapper = (search = '', selectedServer?: SelectedServer) => {
|
const setUp = (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(
|
return {
|
||||||
<ShortUrlsFilteringBar
|
user: userEvent.setup(),
|
||||||
selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
|
...render(
|
||||||
order={{}}
|
<MemoryRouter>
|
||||||
handleOrderBy={handleOrderBy}
|
<ShortUrlsFilteringBar
|
||||||
/>,
|
selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
|
||||||
);
|
order={{}}
|
||||||
|
handleOrderBy={handleOrderBy}
|
||||||
return wrapper;
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(jest.clearAllMocks);
|
afterEach(jest.clearAllMocks);
|
||||||
afterEach(() => wrapper?.unmount());
|
|
||||||
|
|
||||||
it('renders expected children components', () => {
|
it('renders expected children components', () => {
|
||||||
const wrapper = createWrapper();
|
setUp();
|
||||||
|
|
||||||
expect(wrapper.find(SearchField)).toHaveLength(1);
|
expect(screen.getByText('ExportShortUrlsBtn')).toBeInTheDocument();
|
||||||
expect(wrapper.find(DateRangeSelector)).toHaveLength(1);
|
expect(screen.getByText('TagsSelector')).toBeInTheDocument();
|
||||||
expect(wrapper.find(OrderingDropdown)).toHaveLength(1);
|
});
|
||||||
expect(wrapper.find(ExportShortUrlsBtn)).toHaveLength(1);
|
|
||||||
|
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([
|
it.each([
|
||||||
['tags=foo,bar,baz', 3],
|
[{ startDate: now } as DateRange, `startDate=${encodeURIComponent(formatISO(startOfDay(now)))}`],
|
||||||
['tags=foo,baz', 2],
|
[{ endDate: now } as DateRange, `endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`],
|
||||||
['', 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, endDate: now },
|
{ startDate: now, endDate: now } as DateRange,
|
||||||
`startDate=${encodeURIComponent(formatISO(now))}&endDate=${encodeURIComponent(formatISO(now))}`,
|
`startDate=${encodeURIComponent(formatISO(startOfDay(now)))}&endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`,
|
||||||
],
|
],
|
||||||
])('redirects to first page when date range changes', (dates, expectedQuery) => {
|
])('redirects to first page when date range changes', async (dates, expectedQuery) => {
|
||||||
const wrapper = createWrapper();
|
const { user } = setUp();
|
||||||
const dateRange = wrapper.find(DateRangeSelector);
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'All short URLs' }));
|
||||||
|
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(navigate).not.toHaveBeenCalled();
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
dateRange.simulate('datesChange', dates);
|
dates.startDate && await user.type(screen.getByPlaceholderText('Since...'), formatDate()(dates.startDate) ?? '');
|
||||||
expect(navigate).toHaveBeenCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`);
|
dates.endDate && await user.type(screen.getByPlaceholderText('Until...'), formatDate()(dates.endDate) ?? '');
|
||||||
|
expect(navigate).toHaveBeenLastCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '3.0.0' }), 1],
|
['tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '3.0.0' }), true],
|
||||||
['tags=foo,bar', Mock.of<ReachableServer>({ version: '3.1.0' }), 1],
|
['tags=foo,bar', Mock.of<ReachableServer>({ version: '3.1.0' }), true],
|
||||||
['tags=foo', Mock.of<ReachableServer>({ version: '3.0.0' }), 0],
|
['tags=foo', Mock.of<ReachableServer>({ version: '3.0.0' }), false],
|
||||||
['', Mock.of<ReachableServer>({ version: '3.0.0' }), 0],
|
['', Mock.of<ReachableServer>({ version: '3.0.0' }), false],
|
||||||
['tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '2.10.0' }), 0],
|
['tags=foo,bar,baz', Mock.of<ReachableServer>({ version: '2.10.0' }), false],
|
||||||
['', Mock.of<ReachableServer>({ version: '2.10.0' }), 0],
|
['', 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',
|
'renders tags mode toggle if the server supports it and there is more than one tag selected',
|
||||||
(search, selectedServer, expectedTagToggleComponents) => {
|
(search, selectedServer, shouldHaveComponent) => {
|
||||||
const wrapper = createWrapper(search, selectedServer);
|
setUp(search, selectedServer);
|
||||||
const toggle = wrapper.find(TooltipToggleSwitch);
|
|
||||||
|
|
||||||
expect(toggle).toHaveLength(expectedTagToggleComponents);
|
if (shouldHaveComponent) {
|
||||||
|
expect(screen.getByLabelText('Change tags mode')).toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
expect(screen.queryByLabelText('Change tags mode')).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['', 'Short URLs including any tag.', false],
|
['', 'With any of the tags.'],
|
||||||
['&tagsMode=all', 'Short URLs including all tags.', true],
|
['&tagsMode=all', 'With all the tags.'],
|
||||||
['&tagsMode=any', 'Short URLs including any tag.', false],
|
['&tagsMode=any', 'With any of the tags.'],
|
||||||
])('expected tags mode tooltip title', (initialTagsMode, expectedToggleText, expectedChecked) => {
|
])('expected tags mode tooltip title', async (initialTagsMode, expectedToggleText) => {
|
||||||
const wrapper = createWrapper(`tags=foo,bar${initialTagsMode}`, Mock.of<ReachableServer>({ version: '3.0.0' }));
|
const { user } = setUp(`tags=foo,bar${initialTagsMode}`, Mock.of<ReachableServer>({ version: '3.0.0' }));
|
||||||
const toggle = wrapper.find(TooltipToggleSwitch);
|
|
||||||
|
|
||||||
expect(toggle.prop('children')).toEqual(expectedToggleText);
|
await user.hover(screen.getByLabelText('Change tags mode'));
|
||||||
expect(toggle.prop('checked')).toEqual(expectedChecked);
|
expect(await screen.findByRole('tooltip')).toHaveTextContent(expectedToggleText);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['', 'tagsMode=all'],
|
['', 'tagsMode=all'],
|
||||||
['&tagsMode=all', 'tagsMode=any'],
|
['&tagsMode=all', 'tagsMode=any'],
|
||||||
['&tagsMode=any', 'tagsMode=all'],
|
['&tagsMode=any', 'tagsMode=all'],
|
||||||
])('redirects to first page when tags mode changes', (initialTagsMode, expectedRedirectTagsMode) => {
|
])('redirects to first page when tags mode changes', async (initialTagsMode, expectedRedirectTagsMode) => {
|
||||||
const wrapper = createWrapper(`tags=foo,bar${initialTagsMode}`, Mock.of<ReachableServer>({ version: '3.0.0' }));
|
const { user } = setUp(`tags=foo,bar${initialTagsMode}`, Mock.of<ReachableServer>({ version: '3.0.0' }));
|
||||||
const toggle = wrapper.find(TooltipToggleSwitch);
|
|
||||||
|
|
||||||
expect(navigate).not.toHaveBeenCalled();
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
toggle.simulate('change');
|
await user.click(screen.getByLabelText('Change tags mode'));
|
||||||
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
|
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles order through dropdown', () => {
|
it('handles order through dropdown', async () => {
|
||||||
const wrapper = createWrapper();
|
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');
|
await clickMenuItem(/^Title/);
|
||||||
expect(handleOrderBy).toHaveBeenCalledWith('visits', 'ASC');
|
expect(handleOrderBy).toHaveBeenCalledWith('title', 'ASC');
|
||||||
|
|
||||||
wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC');
|
await clickMenuItem(/^Long URL/);
|
||||||
expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'DESC');
|
expect(handleOrderBy).toHaveBeenCalledWith('longUrl', 'ASC');
|
||||||
|
|
||||||
wrapper.find(OrderingDropdown).simulate('change', undefined, undefined);
|
|
||||||
expect(handleOrderBy).toHaveBeenCalledWith(undefined, undefined);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { TagsList } from '../../src/tags/reducers/tagsList';
|
||||||
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
||||||
import { Result } from '../../src/utils/Result';
|
import { Result } from '../../src/utils/Result';
|
||||||
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
|
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 { Settings } from '../../src/settings/reducers/settings';
|
||||||
import { TagsOrderableFields } from '../../src/tags/data/TagsListChildrenProps';
|
import { TagsOrderableFields } from '../../src/tags/data/TagsListChildrenProps';
|
||||||
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
|
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
|
||||||
|
|
|
@ -1,29 +1,22 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faFileDownload } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { ExportBtn } from '../../src/utils/ExportBtn';
|
import { ExportBtn } from '../../src/utils/ExportBtn';
|
||||||
|
|
||||||
describe('<ExportBtn />', () => {
|
describe('<ExportBtn />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
const setUp = (amount?: number, loading = false) => render(<ExportBtn amount={amount} loading={loading} />);
|
||||||
const createWrapper = (amount?: number, loading = false) => {
|
|
||||||
wrapper = shallow(<ExportBtn amount={amount} loading={loading} />);
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(jest.clearAllMocks);
|
|
||||||
afterEach(() => wrapper?.unmount());
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[true, 'Exporting...'],
|
[true, 'Exporting...'],
|
||||||
[false, 'Export ('],
|
[false, 'Export (0)'],
|
||||||
])('renders a button', (loading, text) => {
|
])('renders loading state when expected', async (loading, text) => {
|
||||||
const wrapper = createWrapper(undefined, loading);
|
setUp(undefined, loading);
|
||||||
|
const btn = await screen.findByRole('button');
|
||||||
|
|
||||||
expect(wrapper.prop('outline')).toEqual(true);
|
expect(btn).toHaveTextContent(text);
|
||||||
expect(wrapper.prop('color')).toEqual('primary');
|
if (loading) {
|
||||||
expect(wrapper.prop('disabled')).toEqual(loading);
|
expect(btn).toHaveAttribute('disabled');
|
||||||
expect(wrapper.html()).toContain(text);
|
} else {
|
||||||
|
expect(btn).not.toHaveAttribute('disabled');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
@ -31,17 +24,13 @@ describe('<ExportBtn />', () => {
|
||||||
[10, '10'],
|
[10, '10'],
|
||||||
[10_000, '10,000'],
|
[10_000, '10,000'],
|
||||||
[10_000_000, '10,000,000'],
|
[10_000_000, '10,000,000'],
|
||||||
])('renders expected amount', (amount, expectedRenderedAmount) => {
|
])('renders expected amount', async (amount, expectedRenderedAmount) => {
|
||||||
const wrapper = createWrapper(amount);
|
setUp(amount);
|
||||||
|
expect(await screen.findByRole('button')).toHaveTextContent(`Export (${expectedRenderedAmount})`);
|
||||||
expect(wrapper.html()).toContain(`Export (${expectedRenderedAmount})`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders expected icon', () => {
|
it('renders expected icon', () => {
|
||||||
const wrapper = createWrapper();
|
setUp();
|
||||||
const icon = wrapper.find(FontAwesomeIcon);
|
expect(screen.getByRole('img', { hidden: true })).toMatchSnapshot();
|
||||||
|
|
||||||
expect(icon).toHaveLength(1);
|
|
||||||
expect(icon.prop('icon')).toEqual(faFileDownload);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
108
test/utils/OrderingDropdown.test.tsx
Normal file
108
test/utils/OrderingDropdown.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
19
test/utils/__snapshots__/ExportBtn.test.tsx.snap
Normal file
19
test/utils/__snapshots__/ExportBtn.test.tsx.snap
Normal 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>
|
||||||
|
`;
|
|
@ -3,7 +3,7 @@ import { Mock } from 'ts-mockery';
|
||||||
import VisitsTable, { VisitsTableProps } from '../../src/visits/VisitsTable';
|
import VisitsTable, { VisitsTableProps } from '../../src/visits/VisitsTable';
|
||||||
import { rangeOf } from '../../src/utils/utils';
|
import { rangeOf } from '../../src/utils/utils';
|
||||||
import { SimplePaginator } from '../../src/common/SimplePaginator';
|
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 { NormalizedVisit } from '../../src/visits/types';
|
||||||
import { ReachableServer, SelectedServer } from '../../src/servers/data';
|
import { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
import { SemVer } from '../../src/utils/helpers/version';
|
import { SemVer } from '../../src/utils/helpers/version';
|
||||||
|
|
Loading…
Reference in a new issue