mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #517 from acelaya-forks/feature/reset-page
Feature/reset page
This commit is contained in:
commit
3bc9bd2ef8
30 changed files with 339 additions and 260 deletions
|
@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
* [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens.
|
* [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens.
|
||||||
* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky.
|
* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky.
|
||||||
* [#515](https://github.com/shlinkio/shlink-web-client/issues/515) Allowed to sort tags even when using the cards display mode.
|
* [#515](https://github.com/shlinkio/shlink-web-client/issues/515) Allowed to sort tags even when using the cards display mode.
|
||||||
|
* [#518](https://github.com/shlinkio/shlink-web-client/issues/518) Improved short URLs list filtering by moving selected tags, search text and dates to the query string, allowing to navigate back and forth or even bookmark filters.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* Moved ci workflow to external repo and reused
|
* Moved ci workflow to external repo and reused
|
||||||
|
@ -24,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* [#252](https://github.com/shlinkio/shlink-web-client/issues/252) Fixed visits coming from mercure being added in real time, even when selected date interval does not match tha visit's date.
|
* [#252](https://github.com/shlinkio/shlink-web-client/issues/252) Fixed visits coming from mercure being added in real time, even when selected date interval does not match tha visit's date.
|
||||||
|
* [#48](https://github.com/shlinkio/shlink-web-client/issues/48) Fixed error when selected page gets out of range after filtering short URLs list by text, tags or dates. Now the page is reset to 1 in any of those cases.
|
||||||
|
|
||||||
|
|
||||||
## [3.3.2] - 2021-10-17
|
## [3.3.2] - 2021-10-17
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
import { isEmpty, isNil, reject } from 'ramda';
|
||||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import {
|
import {
|
||||||
|
@ -17,6 +16,7 @@ import {
|
||||||
ShlinkVisitsOverview,
|
ShlinkVisitsOverview,
|
||||||
ShlinkEditDomainRedirects,
|
ShlinkEditDomainRedirects,
|
||||||
ShlinkDomainRedirects,
|
ShlinkDomainRedirects,
|
||||||
|
ShlinkShortUrlsListParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { stringifyQuery } from '../../utils/helpers/query';
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ export default class ShlinkApiClient {
|
||||||
this.apiVersion = 2;
|
this.apiVersion = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
||||||
.then(({ data }) => data.shortUrls);
|
.then(({ data }) => data.shortUrls);
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Visit } from '../../visits/types';
|
import { Visit } from '../../visits/types';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||||
|
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
export interface ShlinkShortUrlsResponse {
|
||||||
data: ShortUrl[];
|
data: ShortUrl[];
|
||||||
|
@ -85,6 +86,16 @@ export interface ShlinkDomainsResponse {
|
||||||
data: ShlinkDomain[];
|
data: ShlinkDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListParams {
|
||||||
|
page?: string;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
tags?: string[];
|
||||||
|
searchTerm?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orderBy?: OrderBy;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
export interface ProblemDetailsError {
|
||||||
type: string;
|
type: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
|
|
@ -13,7 +13,7 @@ import './MenuLayout.scss';
|
||||||
|
|
||||||
const MenuLayout = (
|
const MenuLayout = (
|
||||||
TagsList: FC,
|
TagsList: FC,
|
||||||
ShortUrls: FC,
|
ShortUrlsList: FC,
|
||||||
AsideMenu: FC<AsideMenuProps>,
|
AsideMenu: FC<AsideMenuProps>,
|
||||||
CreateShortUrl: FC,
|
CreateShortUrl: FC,
|
||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
|
@ -49,7 +49,7 @@ const MenuLayout = (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||||
<Route exact path="/server/:serverId/overview" component={Overview} />
|
<Route exact path="/server/:serverId/overview" component={Overview} />
|
||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
||||||
|
|
|
@ -35,7 +35,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
'MenuLayout',
|
'MenuLayout',
|
||||||
MenuLayout,
|
MenuLayout,
|
||||||
'TagsList',
|
'TagsList',
|
||||||
'ShortUrls',
|
'ShortUrlsList',
|
||||||
'AsideMenu',
|
'AsideMenu',
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
|
@ -46,7 +46,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
'EditShortUrl',
|
'EditShortUrl',
|
||||||
'ManageDomains',
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
|
@ -36,7 +36,7 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||||
provideAppServices(bottle, connect);
|
provideAppServices(bottle, connect);
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
provideApiServices(bottle);
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect, withRouter);
|
||||||
provideServersServices(bottle, connect, withRouter);
|
provideServersServices(bottle, connect, withRouter);
|
||||||
provideTagsServices(bottle, connect);
|
provideTagsServices(bottle, connect);
|
||||||
provideVisitsServices(bottle, connect);
|
provideVisitsServices(bottle, connect);
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { TagsList } from '../tags/reducers/tagsList';
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
|
@ -11,12 +10,13 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { Versions } from '../utils/helpers/version';
|
import { Versions } from '../utils/helpers/version';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
import { getServerId, SelectedServer } from './data';
|
import { getServerId, SelectedServer } from './data';
|
||||||
import './Overview.scss';
|
import './Overview.scss';
|
||||||
|
|
||||||
interface OverviewConnectProps {
|
interface OverviewConnectProps {
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||||
listTags: Function;
|
listTags: Function;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
@ -107,7 +107,7 @@ export const Overview = (
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
|
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
import {
|
||||||
|
pageIsEllipsis,
|
||||||
|
keyForPage,
|
||||||
|
progressivePagination,
|
||||||
|
prettifyPageNumber,
|
||||||
|
NumberOrEllipsis,
|
||||||
|
} from '../utils/helpers/pagination';
|
||||||
import { ShlinkPaginator } from '../api/types';
|
import { ShlinkPaginator } from '../api/types';
|
||||||
|
|
||||||
interface PaginatorProps {
|
interface PaginatorProps {
|
||||||
paginator?: ShlinkPaginator;
|
paginator?: ShlinkPaginator;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
currentQueryString?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
|
||||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||||
|
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
||||||
|
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -22,10 +31,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||||
disabled={pageIsEllipsis(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink
|
<PaginationLink tag={Link} to={urlForPage(pageNumber)}>
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
|
||||||
>
|
|
||||||
{prettifyPageNumber(pageNumber)}
|
{prettifyPageNumber(pageNumber)}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
@ -34,19 +40,11 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||||
return (
|
return (
|
||||||
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||||
<PaginationItem disabled={currentPage === 1}>
|
<PaginationItem disabled={currentPage === 1}>
|
||||||
<PaginationLink
|
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
||||||
previous
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{renderPages()}
|
{renderPages()}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
<PaginationLink
|
<PaginationLink next tag={Link} to={urlForPage(currentPage + 1)} />
|
||||||
next
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,35 +2,43 @@ import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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 { RouteChildrenProps } from 'react-router-dom';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
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 ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
import './SearchBar.scss';
|
import './SearchBar.scss';
|
||||||
|
|
||||||
interface SearchBarProps {
|
export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
|
||||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
||||||
|
const selectedTags = tags?.split(',') ?? [];
|
||||||
const setDates = pipe(
|
const setDates = pipe(
|
||||||
({ startDate, endDate }: DateRange) => ({
|
({ startDate, endDate }: DateRange) => ({
|
||||||
startDate: formatIsoDate(startDate) ?? undefined,
|
startDate: formatIsoDate(startDate) ?? undefined,
|
||||||
endDate: formatIsoDate(endDate) ?? undefined,
|
endDate: formatIsoDate(endDate) ?? undefined,
|
||||||
}),
|
}),
|
||||||
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
|
toFirstPage,
|
||||||
|
);
|
||||||
|
const setSearch = pipe(
|
||||||
|
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
|
||||||
|
(search) => toFirstPage({ search }),
|
||||||
|
);
|
||||||
|
const removeTag = pipe(
|
||||||
|
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||||
|
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
|
||||||
|
(tags) => toFirstPage({ tags }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="search-bar-container">
|
<div className="search-bar-container">
|
||||||
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
|
<SearchField initialValue={search} onChange={setSearch} />
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
@ -38,8 +46,8 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={{
|
initialDateRange={{
|
||||||
startDate: dateOrNull(shortUrlsListParams.startDate),
|
startDate: dateOrNull(startDate),
|
||||||
endDate: dateOrNull(shortUrlsListParams.endDate),
|
endDate: dateOrNull(endDate),
|
||||||
}}
|
}}
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
|
@ -47,24 +55,12 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
{selectedTags.length > 0 && (
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
<h4 className="search-bar__selected-tag mt-3">
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||||
|
|
||||||
{selectedTags.map((tag) => (
|
{selectedTags.map((tag) =>
|
||||||
<Tag
|
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
key={tag}
|
|
||||||
text={tag}
|
|
||||||
clearable
|
|
||||||
onClose={() => listShortUrls(
|
|
||||||
{
|
|
||||||
...shortUrlsListParams,
|
|
||||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</h4>
|
</h4>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { ShortUrlsListProps } from './ShortUrlsList';
|
|
||||||
|
|
||||||
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
|
|
||||||
const { match } = props;
|
|
||||||
const { page = '1', serverId = '' } = match?.params ?? {};
|
|
||||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
|
||||||
|
|
||||||
// Using a key on a component makes react to create a new instance every time the key changes
|
|
||||||
// Without it, pagination on the URL will not make the component to be refreshed
|
|
||||||
useEffect(() => {
|
|
||||||
setUrlsListKey(`${serverId}_${page}`);
|
|
||||||
}, [ serverId, page ]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="form-group"><SearchBar /></div>
|
|
||||||
<ShortUrlsList {...props} key={urlsListKey} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortUrls;
|
|
|
@ -1,26 +1,21 @@
|
||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
import { head, keys, pipe, values } from 'ramda';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { head, keys, values } from 'ramda';
|
|
||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
|
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
|
||||||
import { getServerId, SelectedServer } from '../servers/data';
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
|
|
||||||
interface RouteParams {
|
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
||||||
page: string;
|
|
||||||
serverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
|
@ -30,54 +25,63 @@ export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||||
|
|
||||||
type ShortUrlsOrder = Order<OrderableFields>;
|
type ShortUrlsOrder = Order<OrderableFields>;
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
resetShortUrlParams,
|
||||||
shortUrlsListParams,
|
shortUrlsListParams,
|
||||||
match,
|
match,
|
||||||
location,
|
location,
|
||||||
|
history,
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: ShortUrlsListProps) => {
|
}: ShortUrlsListProps) => {
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
const { orderBy } = shortUrlsListParams;
|
const { orderBy } = shortUrlsListParams;
|
||||||
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
||||||
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||||
dir: orderBy && head(values(orderBy)),
|
dir: orderBy && head(values(orderBy)),
|
||||||
});
|
});
|
||||||
|
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
||||||
|
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
||||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
|
||||||
|
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
|
||||||
|
{ ...shortUrlsListParams, ...extraParams },
|
||||||
|
);
|
||||||
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
||||||
setOrder({ field, dir });
|
setOrder({ field, dir });
|
||||||
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
||||||
};
|
};
|
||||||
const orderByColumn = (field: OrderableFields) => () =>
|
const orderByColumn = (field: OrderableFields) => () =>
|
||||||
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
||||||
const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field &&
|
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
|
||||||
<FontAwesomeIcon icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon} className="ml-1" />;
|
const addTag = pipe(
|
||||||
|
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
||||||
|
(tags) => toFirstPage({ tags }),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => resetShortUrlParams, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
refreshList(
|
||||||
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags;
|
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
|
||||||
|
);
|
||||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
}, [ match.params.page, search, selectedTags, startDate, endDate ]);
|
||||||
|
|
||||||
return resetShortUrlParams;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-3"><SearchBar /></div>
|
||||||
<div className="d-block d-lg-none mb-3">
|
<div className="d-block d-lg-none mb-3">
|
||||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
||||||
</div>
|
</div>
|
||||||
<Card body className="pb-1">
|
<Card body className="pb-1">
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
orderByColumn={orderByColumn}
|
|
||||||
renderOrderIcon={renderOrderIcon}
|
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
onTagClick={(tag) => refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })}
|
orderByColumn={orderByColumn}
|
||||||
|
renderOrderIcon={renderOrderIcon}
|
||||||
|
onTagClick={addTag}
|
||||||
/>
|
/>
|
||||||
<Paginator paginator={pagination} serverId={getServerId(selectedServer)} />
|
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -35,7 +35,9 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
<td colSpan={6} className="text-center table-danger text-dark">
|
||||||
|
Something went wrong while loading short URLs :(
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
31
src/short-urls/helpers/hooks.ts
Normal file
31
src/short-urls/helpers/hooks.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { isEmpty } from 'ramda';
|
||||||
|
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
|
||||||
|
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
||||||
|
type ToFirstPage = (extra: Partial<ShortUrlsQuery>) => void;
|
||||||
|
|
||||||
|
export interface ShortUrlListRouteParams {
|
||||||
|
page: string;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortUrlsQuery {
|
||||||
|
tags?: string;
|
||||||
|
search?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => {
|
||||||
|
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]);
|
||||||
|
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => {
|
||||||
|
const evolvedQuery = stringifyQuery({ ...query, ...extra });
|
||||||
|
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||||
|
|
||||||
|
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [ query, toFirstPageWithExtra ];
|
||||||
|
};
|
|
@ -5,7 +5,7 @@ import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCr
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ShlinkShortUrlsResponse } from '../../api/types';
|
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
||||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||||
|
@ -101,7 +101,7 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
params: ShortUrlsListParams = {},
|
params: ShlinkShortUrlsListParams = {},
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
dispatch({ type: LIST_SHORT_URLS_START });
|
dispatch({ type: LIST_SHORT_URLS_START });
|
||||||
const { listShortUrls } = buildShlinkApiClient(getState);
|
const { listShortUrls } = buildShlinkApiClient(getState);
|
||||||
|
|
|
@ -19,10 +19,6 @@ export type OrderBy = Partial<Record<OrderableFields, OrderDir>>;
|
||||||
export interface ShortUrlsListParams {
|
export interface ShortUrlsListParams {
|
||||||
page?: string;
|
page?: string;
|
||||||
itemsPerPage?: number;
|
itemsPerPage?: number;
|
||||||
tags?: string[];
|
|
||||||
searchTerm?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
orderBy?: OrderBy;
|
orderBy?: OrderBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import ShortUrls from '../ShortUrls';
|
|
||||||
import SearchBar from '../SearchBar';
|
import SearchBar from '../SearchBar';
|
||||||
import ShortUrlsList from '../ShortUrlsList';
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
|
@ -19,14 +18,11 @@ import { ShortUrlForm } from '../ShortUrlForm';
|
||||||
import { EditShortUrl } from '../EditShortUrl';
|
import { EditShortUrl } from '../EditShortUrl';
|
||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar');
|
||||||
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
|
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@ -56,7 +52,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
bottle.decorator('SearchBar', withRouter);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
|
|
|
@ -61,7 +61,7 @@ const TagCard = (
|
||||||
<Collapse isOpen={displayed}>
|
<Collapse isOpen={displayed}>
|
||||||
<CardBody className="tag-card__body">
|
<CardBody className="tag-card__body">
|
||||||
<Link
|
<Link
|
||||||
to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag.tag)}`}
|
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
|
||||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
||||||
>
|
>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { FC, useEffect, useRef } from 'react';
|
import { FC, useEffect, useRef } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { splitEvery } from 'ramda';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { RouteChildrenProps } from 'react-router';
|
import { RouteChildrenProps } from 'react-router';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SimplePaginator from '../common/SimplePaginator';
|
import SimplePaginator from '../common/SimplePaginator';
|
||||||
import { useQueryState } from '../utils/helpers/hooks';
|
import { useQueryState } from '../utils/helpers/hooks';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||||
import { TagsTableRowProps } from './TagsTableRow';
|
import { TagsTableRowProps } from './TagsTableRow';
|
||||||
import './TagsTable.scss';
|
import './TagsTable.scss';
|
||||||
|
@ -27,8 +26,6 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||||
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||||
const showPaginator = pages.length > 1;
|
const showPaginator = pages.length > 1;
|
||||||
const currentPage = pages[page - 1] ?? [];
|
const currentPage = pages[page - 1] ?? [];
|
||||||
const renderOrderIcon = (field: OrderableFields) => currentOrder.dir && currentOrder.field === field &&
|
|
||||||
<FontAwesomeIcon icon={currentOrder.dir === 'ASC' ? caretUpIcon : caretDownIcon} className="ml-1" />;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
!isFirstLoad.current && setPage(1);
|
!isFirstLoad.current && setPage(1);
|
||||||
|
@ -43,12 +40,14 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||||
<table className="table table-hover mb-0">
|
<table className="table table-hover mb-0">
|
||||||
<thead className="responsive-table__header">
|
<thead className="responsive-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>Tag {renderOrderIcon('tag')}</th>
|
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
|
||||||
|
Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
|
||||||
|
</th>
|
||||||
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
|
||||||
Short URLs {renderOrderIcon('shortUrls')}
|
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
|
||||||
</th>
|
</th>
|
||||||
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
|
||||||
Visits {renderOrderIcon('visits')}
|
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
|
||||||
</th>
|
</th>
|
||||||
<th className="tags-table__header-cell" />
|
<th className="tags-table__header-cell" />
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const TagsTableRow = (
|
||||||
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
|
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
|
||||||
</th>
|
</th>
|
||||||
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
||||||
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag.tag)}`}>
|
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
|
||||||
{prettify(tag.shortUrls)}
|
{prettify(tag.shortUrls)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -12,10 +12,11 @@ interface SearchFieldProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
|
initialValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => {
|
const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
|
||||||
const [ searchTerm, setSearchTerm ] = useState('');
|
const [ searchTerm, setSearchTerm ] = useState(initialValue);
|
||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
timer && clearTimeout(timer);
|
timer && clearTimeout(timer);
|
||||||
|
|
|
@ -22,18 +22,16 @@ export interface DateRangeSelectorProps {
|
||||||
export const DateRangeSelector = (
|
export const DateRangeSelector = (
|
||||||
{ onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps,
|
{ onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps,
|
||||||
) => {
|
) => {
|
||||||
const [ activeInterval, setActiveInterval ] = useState(
|
const initialIntervalIsRange = rangeIsInterval(initialDateRange);
|
||||||
rangeIsInterval(initialDateRange) ? initialDateRange : undefined,
|
const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined);
|
||||||
);
|
const [ activeDateRange, setActiveDateRange ] = useState(initialIntervalIsRange ? undefined : initialDateRange);
|
||||||
const [ activeDateRange, setActiveDateRange ] = useState(
|
|
||||||
!rangeIsInterval(initialDateRange) ? initialDateRange : undefined,
|
|
||||||
);
|
|
||||||
const updateDateRange = (dateRange: DateRange) => {
|
const updateDateRange = (dateRange: DateRange) => {
|
||||||
setActiveInterval(dateRangeIsEmpty(dateRange) ? 'all' : undefined);
|
setActiveInterval(dateRangeIsEmpty(dateRange) ? 'all' : undefined);
|
||||||
setActiveDateRange(dateRange);
|
setActiveDateRange(dateRange);
|
||||||
onDatesChange(dateRange);
|
onDatesChange(dateRange);
|
||||||
};
|
};
|
||||||
const updateInterval = (dateInterval: DateInterval) => () => {
|
const updateInterval = (dateInterval: DateInterval) => {
|
||||||
setActiveInterval(dateInterval);
|
setActiveInterval(dateInterval);
|
||||||
setActiveDateRange(undefined);
|
setActiveDateRange(undefined);
|
||||||
onDatesChange(intervalToDateRange(dateInterval));
|
onDatesChange(intervalToDateRange(dateInterval));
|
||||||
|
@ -41,11 +39,7 @@ export const DateRangeSelector = (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
|
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
|
||||||
<DateIntervalDropdownItems
|
<DateIntervalDropdownItems allText={defaultText} active={activeInterval} onChange={updateInterval} />
|
||||||
allText={defaultText}
|
|
||||||
active={activeInterval}
|
|
||||||
onChange={(interval) => updateInterval(interval)()}
|
|
||||||
/>
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem header>Custom:</DropdownItem>
|
<DropdownItem header>Custom:</DropdownItem>
|
||||||
<DropdownItem text>
|
<DropdownItem text>
|
||||||
|
|
19
src/utils/table/TableOrderIcon.tsx
Normal file
19
src/utils/table/TableOrderIcon.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { Order } from '../helpers/ordering';
|
||||||
|
|
||||||
|
interface TableOrderIconProps<T> {
|
||||||
|
currentOrder: Order<T>;
|
||||||
|
field: T;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableOrderIcon<T extends string = string>(
|
||||||
|
{ currentOrder, field, className = 'ml-1' }: TableOrderIconProps<T>,
|
||||||
|
) {
|
||||||
|
if (!currentOrder.dir || currentOrder.field !== field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FontAwesomeIcon icon={currentOrder.dir === 'ASC' ? caretUpIcon : caretDownIcon} className={className} />;
|
||||||
|
}
|
|
@ -1,12 +1,7 @@
|
||||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { min, splitEvery } from 'ramda';
|
import { min, splitEvery } from 'ramda';
|
||||||
import {
|
import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
faCaretDown as caretDownIcon,
|
|
||||||
faCaretUp as caretUpIcon,
|
|
||||||
faCheck as checkIcon,
|
|
||||||
faRobot as botIcon,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
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';
|
||||||
|
@ -16,6 +11,7 @@ import { prettify } from '../utils/helpers/numbers';
|
||||||
import { supportsBotVisits } from '../utils/helpers/features';
|
import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { Time } from '../utils/Time';
|
import { Time } from '../utils/Time';
|
||||||
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||||
import './VisitsTable.scss';
|
import './VisitsTable.scss';
|
||||||
|
|
||||||
|
@ -72,12 +68,8 @@ const VisitsTable = ({
|
||||||
|
|
||||||
const orderByColumn = (field: OrderableFields) =>
|
const orderByColumn = (field: OrderableFields) =>
|
||||||
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
||||||
const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field && (
|
const renderOrderIcon = (field: OrderableFields) =>
|
||||||
<FontAwesomeIcon
|
<TableOrderIcon currentOrder={order} field={field} className="visits-table__header-icon" />;
|
||||||
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
|
|
||||||
className="visits-table__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = () => setIsMobileDevice(matchMobile());
|
const listener = () => setIsMobileDevice(matchMobile());
|
||||||
|
|
|
@ -1,28 +1,54 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { PaginationItem } from 'reactstrap';
|
import { PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
import Paginator from '../../src/short-urls/Paginator';
|
import Paginator from '../../src/short-urls/Paginator';
|
||||||
|
import { ShlinkPaginator } from '../../src/api/types';
|
||||||
|
import { ELLIPSIS } from '../../src/utils/helpers/pagination';
|
||||||
|
|
||||||
describe('<Paginator />', () => {
|
describe('<Paginator />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
|
const buildPaginator = (pagesCount?: number) => Mock.of<ShlinkPaginator>({ pagesCount, currentPage: 1 });
|
||||||
|
|
||||||
afterEach(() => wrapper?.unmount());
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
it('renders nothing if the number of pages is below 2', () => {
|
it.each([
|
||||||
wrapper = shallow(<Paginator serverId="abc123" />);
|
[ undefined ],
|
||||||
|
[ buildPaginator() ],
|
||||||
|
[ buildPaginator(0) ],
|
||||||
|
[ buildPaginator(1) ],
|
||||||
|
])('renders nothing if the number of pages is below 2', (paginator) => {
|
||||||
|
wrapper = shallow(<Paginator serverId="abc123" paginator={paginator} />);
|
||||||
expect(wrapper.text()).toEqual('');
|
expect(wrapper.text()).toEqual('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders previous, next and the list of pages', () => {
|
it.each([
|
||||||
const paginator = {
|
[ buildPaginator(2), 4, 0 ],
|
||||||
currentPage: 1,
|
[ buildPaginator(3), 5, 0 ],
|
||||||
pagesCount: 5,
|
[ buildPaginator(4), 6, 0 ],
|
||||||
totalItems: 10,
|
[ buildPaginator(5), 7, 1 ],
|
||||||
};
|
[ buildPaginator(6), 7, 1 ],
|
||||||
const extraPagesPrevNext = 2;
|
[ buildPaginator(23), 7, 1 ],
|
||||||
const expectedItems = paginator.pagesCount + extraPagesPrevNext;
|
])('renders previous, next and the list of pages, with ellipses when expected', (
|
||||||
|
paginator,
|
||||||
|
expectedPages,
|
||||||
|
expectedEllipsis,
|
||||||
|
) => {
|
||||||
wrapper = shallow(<Paginator serverId="abc123" paginator={paginator} />);
|
wrapper = shallow(<Paginator serverId="abc123" paginator={paginator} />);
|
||||||
|
const items = wrapper.find(PaginationItem);
|
||||||
|
const ellipsis = items.filterWhere((item) => item.find(PaginationLink).prop('children') === ELLIPSIS);
|
||||||
|
|
||||||
expect(wrapper.find(PaginationItem)).toHaveLength(expectedItems);
|
expect(items).toHaveLength(expectedPages);
|
||||||
|
expect(ellipsis).toHaveLength(expectedEllipsis);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends query string to all pages', () => {
|
||||||
|
const paginator = buildPaginator(3);
|
||||||
|
const currentQueryString = '?foo=bar';
|
||||||
|
|
||||||
|
wrapper = shallow(<Paginator serverId="abc123" paginator={paginator} currentQueryString={currentQueryString} />);
|
||||||
|
const links = wrapper.find(PaginationLink);
|
||||||
|
|
||||||
|
expect(links).toHaveLength(5);
|
||||||
|
links.forEach((link) => expect(link.prop('to')).toContain(currentQueryString));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,73 +1,85 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import searchBarCreator from '../../src/short-urls/SearchBar';
|
import { History, Location } from 'history';
|
||||||
|
import { match } from 'react-router';
|
||||||
|
import { formatISO } from 'date-fns';
|
||||||
|
import searchBarCreator, { SearchBarProps } from '../../src/short-urls/SearchBar';
|
||||||
import SearchField from '../../src/utils/SearchField';
|
import SearchField from '../../src/utils/SearchField';
|
||||||
import Tag from '../../src/tags/helpers/Tag';
|
import Tag from '../../src/tags/helpers/Tag';
|
||||||
import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
|
||||||
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
||||||
|
import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks';
|
||||||
|
|
||||||
describe('<SearchBar />', () => {
|
describe('<SearchBar />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const listShortUrlsMock = jest.fn();
|
|
||||||
const SearchBar = searchBarCreator(Mock.all<ColorGenerator>());
|
const SearchBar = searchBarCreator(Mock.all<ColorGenerator>());
|
||||||
|
const push = jest.fn();
|
||||||
|
const now = new Date();
|
||||||
|
const createWrapper = (props: Partial<SearchBarProps> = {}) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<SearchBar
|
||||||
|
history={Mock.of<History>({ push })}
|
||||||
|
location={Mock.of<Location>({ search: '' })}
|
||||||
|
match={Mock.of<match<ShortUrlListRouteParams>>({ params: { serverId: '1' } })}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(jest.clearAllMocks);
|
afterEach(jest.clearAllMocks);
|
||||||
afterEach(() => wrapper?.unmount());
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
it('renders a SearchField', () => {
|
it('renders some children components SearchField', () => {
|
||||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
expect(wrapper.find(SearchField)).toHaveLength(1);
|
expect(wrapper.find(SearchField)).toHaveLength(1);
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a DateRangeSelector', () => {
|
|
||||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
|
|
||||||
|
|
||||||
expect(wrapper.find(DateRangeSelector)).toHaveLength(1);
|
expect(wrapper.find(DateRangeSelector)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders no tags when the list of tags is empty', () => {
|
it.each([
|
||||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
|
[ 'tags=foo,bar,baz', 3 ],
|
||||||
|
[ 'tags=foo,baz', 2 ],
|
||||||
|
[ '', 0 ],
|
||||||
|
[ 'foo=bar', 0 ],
|
||||||
|
])('renders the proper amount of tags', (search, expectedTagComps) => {
|
||||||
|
const wrapper = createWrapper({ location: Mock.of<Location>({ search }) });
|
||||||
|
|
||||||
expect(wrapper.find(Tag)).toHaveLength(0);
|
expect(wrapper.find(Tag)).toHaveLength(expectedTagComps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the proper amount of tags', () => {
|
it('redirects to first page when search field changes', () => {
|
||||||
const tags = [ 'foo', 'bar', 'baz' ];
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
wrapper = shallow(<SearchBar shortUrlsListParams={{ tags }} listShortUrls={listShortUrlsMock} />);
|
|
||||||
|
|
||||||
expect(wrapper.find(Tag)).toHaveLength(tags.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates short URLs list when search field changes', () => {
|
|
||||||
wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
|
|
||||||
const searchField = wrapper.find(SearchField);
|
const searchField = wrapper.find(SearchField);
|
||||||
|
|
||||||
expect(listShortUrlsMock).not.toHaveBeenCalled();
|
expect(push).not.toHaveBeenCalled();
|
||||||
searchField.simulate('change');
|
searchField.simulate('change', 'search-term');
|
||||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
expect(push).toHaveBeenCalledWith('/server/1/list-short-urls/1?search=search-term');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates short URLs list when a tag is removed', () => {
|
it('redirects to first page when a tag is removed', () => {
|
||||||
wrapper = shallow(
|
const wrapper = createWrapper({ location: Mock.of<Location>({ search: 'tags=foo,bar' }) });
|
||||||
<SearchBar shortUrlsListParams={{ tags: [ 'foo' ] }} listShortUrls={listShortUrlsMock} />,
|
|
||||||
);
|
|
||||||
const tag = wrapper.find(Tag).first();
|
const tag = wrapper.find(Tag).first();
|
||||||
|
|
||||||
expect(listShortUrlsMock).not.toHaveBeenCalled();
|
expect(push).not.toHaveBeenCalled();
|
||||||
tag.simulate('close');
|
tag.simulate('close');
|
||||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
expect(push).toHaveBeenCalledWith('/server/1/list-short-urls/1?tags=bar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates short URLs list when date range changes', () => {
|
it.each([
|
||||||
wrapper = shallow(
|
[{ startDate: now }, `startDate=${encodeURIComponent(formatISO(now))}` ],
|
||||||
<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />,
|
[{ endDate: now }, `endDate=${encodeURIComponent(formatISO(now))}` ],
|
||||||
);
|
[
|
||||||
|
{ startDate: now, endDate: now },
|
||||||
|
`startDate=${encodeURIComponent(formatISO(now))}&endDate=${encodeURIComponent(formatISO(now))}`,
|
||||||
|
],
|
||||||
|
])('redirects to first page when date range changes', (dates, expectedQuery) => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
const dateRange = wrapper.find(DateRangeSelector);
|
const dateRange = wrapper.find(DateRangeSelector);
|
||||||
|
|
||||||
expect(listShortUrlsMock).not.toHaveBeenCalled();
|
expect(push).not.toHaveBeenCalled();
|
||||||
dateRange.simulate('datesChange', {});
|
dateRange.simulate('datesChange', dates);
|
||||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
|
expect(push).toHaveBeenCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
|
||||||
import { Mock } from 'ts-mockery';
|
|
||||||
import shortUrlsCreator from '../../src/short-urls/ShortUrls';
|
|
||||||
import { ShortUrlsListProps } from '../../src/short-urls/ShortUrlsList';
|
|
||||||
|
|
||||||
describe('<ShortUrls />', () => {
|
|
||||||
let wrapper: ShallowWrapper;
|
|
||||||
const SearchBar = () => null;
|
|
||||||
const ShortUrlsList = () => null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const ShortUrls = shortUrlsCreator(SearchBar, ShortUrlsList);
|
|
||||||
|
|
||||||
wrapper = shallow(
|
|
||||||
<ShortUrls {...Mock.all<ShortUrlsListProps>()} />,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
afterEach(() => wrapper.unmount());
|
|
||||||
|
|
||||||
it('wraps a SearchBar and ShortUrlsList', () => {
|
|
||||||
expect(wrapper.find(SearchBar)).toHaveLength(1);
|
|
||||||
expect(wrapper.find(ShortUrlsList)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,19 +1,24 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { ReactElement } from 'react';
|
import { ReactElement } from 'react';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import shortUrlsListCreator, { ShortUrlsListProps } from '../../src/short-urls/ShortUrlsList';
|
import { History, Location } from 'history';
|
||||||
|
import { match } from 'react-router';
|
||||||
|
import shortUrlsListCreator from '../../src/short-urls/ShortUrlsList';
|
||||||
import { ShortUrl } from '../../src/short-urls/data';
|
import { ShortUrl } from '../../src/short-urls/data';
|
||||||
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
||||||
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
|
||||||
import SortingDropdown from '../../src/utils/SortingDropdown';
|
import SortingDropdown from '../../src/utils/SortingDropdown';
|
||||||
import { OrderableFields, OrderBy } from '../../src/short-urls/reducers/shortUrlsListParams';
|
import { OrderableFields, OrderBy } from '../../src/short-urls/reducers/shortUrlsListParams';
|
||||||
import Paginator from '../../src/short-urls/Paginator';
|
import Paginator from '../../src/short-urls/Paginator';
|
||||||
|
import { ReachableServer } from '../../src/servers/data';
|
||||||
|
import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks';
|
||||||
|
|
||||||
describe('<ShortUrlsList />', () => {
|
describe('<ShortUrlsList />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const ShortUrlsTable = () => null;
|
const ShortUrlsTable = () => null;
|
||||||
|
const SearchBar = () => null;
|
||||||
const listShortUrlsMock = jest.fn();
|
const listShortUrlsMock = jest.fn();
|
||||||
const resetShortUrlParamsMock = jest.fn();
|
const push = jest.fn();
|
||||||
const shortUrlsList = Mock.of<ShortUrlsListModel>({
|
const shortUrlsList = Mock.of<ShortUrlsListModel>({
|
||||||
shortUrls: {
|
shortUrls: {
|
||||||
data: [
|
data: [
|
||||||
|
@ -26,22 +31,18 @@ describe('<ShortUrlsList />', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable);
|
const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, SearchBar);
|
||||||
const createWrapper = (orderBy: OrderBy = {}) => shallow(
|
const createWrapper = (orderBy: OrderBy = {}) => shallow(
|
||||||
<ShortUrlsList
|
<ShortUrlsList
|
||||||
{...Mock.all<ShortUrlsListProps>()}
|
|
||||||
{...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })}
|
{...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })}
|
||||||
listShortUrls={listShortUrlsMock}
|
listShortUrls={listShortUrlsMock}
|
||||||
resetShortUrlParams={resetShortUrlParamsMock}
|
resetShortUrlParams={jest.fn()}
|
||||||
shortUrlsListParams={{
|
shortUrlsListParams={{ page: '1', orderBy }}
|
||||||
page: '1',
|
match={Mock.of<match<ShortUrlListRouteParams>>({ params: {} })}
|
||||||
tags: [ 'test tag' ],
|
location={Mock.of<Location>({ search: '?tags=test%20tag&search=example.com' })}
|
||||||
searchTerm: 'example.com',
|
|
||||||
orderBy,
|
|
||||||
}}
|
|
||||||
match={{ params: {} } as any}
|
|
||||||
location={{} as any}
|
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
|
history={Mock.of<History>({ push })}
|
||||||
|
selectedServer={Mock.of<ReachableServer>({ id: '1' })}
|
||||||
/>,
|
/>,
|
||||||
).dive(); // Dive is needed as this component is wrapped in a HOC
|
).dive(); // Dive is needed as this component is wrapped in a HOC
|
||||||
|
|
||||||
|
@ -56,6 +57,11 @@ describe('<ShortUrlsList />', () => {
|
||||||
expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
|
expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
|
||||||
expect(wrapper.find(SortingDropdown)).toHaveLength(1);
|
expect(wrapper.find(SortingDropdown)).toHaveLength(1);
|
||||||
expect(wrapper.find(Paginator)).toHaveLength(1);
|
expect(wrapper.find(Paginator)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(SearchBar)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes current query to paginator', () => {
|
||||||
|
expect(wrapper.find(Paginator).prop('currentQueryString')).toEqual('?tags=test%20tag&search=example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gets list refreshed every time a tag is clicked', () => {
|
it('gets list refreshed every time a tag is clicked', () => {
|
||||||
|
@ -63,32 +69,26 @@ describe('<ShortUrlsList />', () => {
|
||||||
wrapper.find(ShortUrlsTable).simulate('tagClick', 'bar');
|
wrapper.find(ShortUrlsTable).simulate('tagClick', 'bar');
|
||||||
wrapper.find(ShortUrlsTable).simulate('tagClick', 'baz');
|
wrapper.find(ShortUrlsTable).simulate('tagClick', 'baz');
|
||||||
|
|
||||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(3);
|
expect(push).toHaveBeenCalledTimes(3);
|
||||||
expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
expect(push).toHaveBeenNthCalledWith(1, expect.stringContaining(`tags=${encodeURIComponent('test tag,foo')}`));
|
||||||
tags: [ 'test tag', 'foo' ],
|
expect(push).toHaveBeenNthCalledWith(2, expect.stringContaining(`tags=${encodeURIComponent('test tag,bar')}`));
|
||||||
}));
|
expect(push).toHaveBeenNthCalledWith(3, expect.stringContaining(`tags=${encodeURIComponent('test tag,baz')}`));
|
||||||
expect(listShortUrlsMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
|
||||||
tags: [ 'test tag', 'bar' ],
|
|
||||||
}));
|
|
||||||
expect(listShortUrlsMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
|
||||||
tags: [ 'test tag', 'baz' ],
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes order icon rendering', () => {
|
it('invokes order icon rendering', () => {
|
||||||
const renderIcon = (field: OrderableFields) =>
|
const renderIcon = (field: OrderableFields) =>
|
||||||
(wrapper.find(ShortUrlsTable).prop('renderOrderIcon') as (field: OrderableFields) => ReactElement | null)(field);
|
(wrapper.find(ShortUrlsTable).prop('renderOrderIcon') as (field: OrderableFields) => ReactElement)(field);
|
||||||
|
|
||||||
expect(renderIcon('visits')).toEqual(undefined);
|
expect(renderIcon('visits').props.currentOrder).toEqual({});
|
||||||
|
|
||||||
wrapper.find(SortingDropdown).simulate('change', 'visits');
|
wrapper.find(SortingDropdown).simulate('change', 'visits');
|
||||||
expect(renderIcon('visits')).toEqual(undefined);
|
expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits' });
|
||||||
|
|
||||||
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
||||||
expect(renderIcon('visits')).not.toEqual(undefined);
|
expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits', dir: 'ASC' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles order by through table', () => {
|
it('handles order through table', () => {
|
||||||
const orderByColumn: (field: OrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn');
|
const orderByColumn: (field: OrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn');
|
||||||
|
|
||||||
orderByColumn('visits')();
|
orderByColumn('visits')();
|
||||||
|
@ -107,7 +107,7 @@ describe('<ShortUrlsList />', () => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles order by through dropdown', () => {
|
it('handles order through dropdown', () => {
|
||||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
|
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
|
||||||
|
|
||||||
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
||||||
|
|
|
@ -30,8 +30,8 @@ describe('<TagCard />', () => {
|
||||||
afterEach(jest.resetAllMocks);
|
afterEach(jest.resetAllMocks);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[ 'ssr', '/server/1/list-short-urls/1?tag=ssr' ],
|
[ 'ssr', '/server/1/list-short-urls/1?tags=ssr' ],
|
||||||
[ 'ssr-&-foo', '/server/1/list-short-urls/1?tag=ssr-%26-foo' ],
|
[ 'ssr-&-foo', '/server/1/list-short-urls/1?tags=ssr-%26-foo' ],
|
||||||
])('shows a TagBullet and a link to the list filtering by the tag', (tag, expectedLink) => {
|
])('shows a TagBullet and a link to the list filtering by the tag', (tag, expectedLink) => {
|
||||||
const wrapper = createWrapper(tag);
|
const wrapper = createWrapper(tag);
|
||||||
const links = wrapper.find(Link);
|
const links = wrapper.find(Link);
|
||||||
|
@ -61,7 +61,7 @@ describe('<TagCard />', () => {
|
||||||
const links = wrapper.find(Link);
|
const links = wrapper.find(Link);
|
||||||
|
|
||||||
expect(links).toHaveLength(2);
|
expect(links).toHaveLength(2);
|
||||||
expect(links.at(0).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
|
expect(links.at(0).prop('to')).toEqual('/server/1/list-short-urls/1?tags=ssr');
|
||||||
expect(links.at(0).text()).toContain('48');
|
expect(links.at(0).text()).toContain('48');
|
||||||
expect(links.at(1).prop('to')).toEqual('/server/1/tag/ssr/visits');
|
expect(links.at(1).prop('to')).toEqual('/server/1/tag/ssr/visits');
|
||||||
expect(links.at(1).text()).toContain('23,257');
|
expect(links.at(1).text()).toContain('23,257');
|
||||||
|
|
|
@ -35,7 +35,7 @@ describe('<TagsTableRow />', () => {
|
||||||
const visitsLink = links.last();
|
const visitsLink = links.last();
|
||||||
|
|
||||||
expect(shortUrlsLink.prop('children')).toEqual(expectedShortUrls);
|
expect(shortUrlsLink.prop('children')).toEqual(expectedShortUrls);
|
||||||
expect(shortUrlsLink.prop('to')).toEqual(`/server/abc123/list-short-urls/1?tag=${encodeURIComponent('foo&bar')}`);
|
expect(shortUrlsLink.prop('to')).toEqual(`/server/abc123/list-short-urls/1?tags=${encodeURIComponent('foo&bar')}`);
|
||||||
expect(visitsLink.prop('children')).toEqual(expectedVisits);
|
expect(visitsLink.prop('children')).toEqual(expectedVisits);
|
||||||
expect(visitsLink.prop('to')).toEqual('/server/abc123/tag/foo&bar/visits');
|
expect(visitsLink.prop('to')).toEqual('/server/abc123/tag/foo&bar/visits');
|
||||||
});
|
});
|
||||||
|
|
47
test/utils/table/TableOrderIcon.test.tsx
Normal file
47
test/utils/table/TableOrderIcon.test.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { TableOrderIcon } from '../../../src/utils/table/TableOrderIcon';
|
||||||
|
import { OrderDir } from '../../../src/utils/helpers/ordering';
|
||||||
|
|
||||||
|
describe('<TableOrderIcon />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (field: string, currentDir?: OrderDir, className?: string) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<TableOrderIcon currentOrder={{ dir: currentDir, field: 'foo' }} field={field} className={className} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'foo', undefined ],
|
||||||
|
[ 'bar', 'DESC' as OrderDir ],
|
||||||
|
[ 'bar', 'ASC' as OrderDir ],
|
||||||
|
])('renders empty when not all conditions are met', (field, dir) => {
|
||||||
|
const wrapper = createWrapper(field, dir);
|
||||||
|
|
||||||
|
expect(wrapper.html()).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'DESC' as OrderDir, caretDownIcon ],
|
||||||
|
[ 'ASC' as OrderDir, caretUpIcon ],
|
||||||
|
])('renders an icon when all conditions are met', (dir, expectedIcon) => {
|
||||||
|
const wrapper = createWrapper('foo', dir);
|
||||||
|
|
||||||
|
expect(wrapper.html()).not.toEqual(null);
|
||||||
|
expect(wrapper.prop('icon')).toEqual(expectedIcon);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, 'ml-1' ],
|
||||||
|
[ 'foo', 'foo' ],
|
||||||
|
[ 'bar', 'bar' ],
|
||||||
|
])('renders expected classname', (className, expectedClassName) => {
|
||||||
|
const wrapper = createWrapper('foo', 'ASC', className);
|
||||||
|
|
||||||
|
expect(wrapper.prop('className')).toEqual(expectedClassName);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue