mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #514 from acelaya-forks/feature/sticky-tags-header
Feature/sticky tags header
This commit is contained in:
commit
af08b53002
24 changed files with 199 additions and 158 deletions
|
@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
|
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
|
||||||
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
|
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
|
||||||
* [#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.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* Moved ci workflow to external repo and reused
|
* Moved ci workflow to external repo and reused
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
.short-urls-list__header-icon {
|
|
||||||
margin-left: .4rem;
|
|
||||||
}
|
|
|
@ -5,7 +5,7 @@ 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, OrderDir } from '../utils/utils';
|
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 { parseQuery } from '../utils/helpers/query';
|
||||||
|
@ -14,7 +14,6 @@ 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 './ShortUrlsList.scss';
|
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
page: string;
|
page: string;
|
||||||
|
@ -29,6 +28,8 @@ export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||||
resetShortUrlParams: () => void;
|
resetShortUrlParams: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ShortUrlsOrder = Order<OrderableFields>;
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
resetShortUrlParams,
|
||||||
|
@ -39,34 +40,20 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: ShortUrlsListProps) => {
|
}: ShortUrlsListProps) => {
|
||||||
const { orderBy } = shortUrlsListParams;
|
const { orderBy } = shortUrlsListParams;
|
||||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
||||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||||
orderDir: orderBy && head(values(orderBy)),
|
dir: orderBy && head(values(orderBy)),
|
||||||
});
|
});
|
||||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
||||||
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
||||||
setOrder({ orderField, orderDir });
|
setOrder({ field, dir });
|
||||||
refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined });
|
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
||||||
};
|
};
|
||||||
const orderByColumn = (field: OrderableFields) => () =>
|
const orderByColumn = (field: OrderableFields) => () =>
|
||||||
handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir));
|
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
||||||
const renderOrderIcon = (field: OrderableFields) => {
|
const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field &&
|
||||||
if (order.orderField !== field) {
|
<FontAwesomeIcon icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon} className="ml-1" />;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!order.orderDir) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
|
||||||
className="short-urls-list__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
||||||
|
@ -82,8 +69,8 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
||||||
<div className="d-block d-lg-none mb-3">
|
<div className="d-block d-lg-none mb-3">
|
||||||
<SortingDropdown
|
<SortingDropdown
|
||||||
items={SORTABLE_FIELDS}
|
items={SORTABLE_FIELDS}
|
||||||
orderField={order.orderField}
|
orderField={order.field}
|
||||||
orderDir={order.orderDir}
|
orderDir={order.dir}
|
||||||
onChange={handleOrderBy}
|
onChange={handleOrderBy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -63,34 +63,29 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
<thead className="responsive-table__header short-urls-table__header">
|
<thead className="responsive-table__header short-urls-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||||
Created at
|
Created at {renderOrderIcon?.('dateCreated')}
|
||||||
{renderOrderIcon?.('dateCreated')}
|
|
||||||
</th>
|
</th>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
||||||
Short URL
|
Short URL {renderOrderIcon?.('shortCode')}
|
||||||
{renderOrderIcon?.('shortCode')}
|
|
||||||
</th>
|
</th>
|
||||||
{!supportsTitle && (
|
{!supportsTitle && (
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
Long URL
|
Long URL {renderOrderIcon?.('longUrl')}
|
||||||
{renderOrderIcon?.('longUrl')}
|
|
||||||
</th>
|
</th>
|
||||||
) || (
|
) || (
|
||||||
<th className="short-urls-table__header-cell">
|
<th className="short-urls-table__header-cell">
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
||||||
Title
|
Title {renderOrderIcon?.('title')}
|
||||||
{renderOrderIcon?.('title')}
|
|
||||||
</span>
|
</span>
|
||||||
/
|
/
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
<span className="indivisible">Long URL</span>
|
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
|
||||||
{renderOrderIcon?.('longUrl')}
|
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
<th className="short-urls-table__header-cell">Tags</th>
|
<th className="short-urls-table__header-cell">Tags</th>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||||
<span className="indivisible">Visits{renderOrderIcon?.('visits')}</span>
|
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="short-urls-table__header-cell"> </th>
|
<th className="short-urls-table__header-cell"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
import { OrderDir } from '../../utils/utils';
|
import { OrderDir } from '../../utils/helpers/ordering';
|
||||||
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
||||||
|
|
||||||
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
||||||
|
|
10
src/tags/TagsTable.scss
Normal file
10
src/tags/TagsTable.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
@import '../utils/base';
|
||||||
|
@import '../utils/mixins/sticky-cell';
|
||||||
|
|
||||||
|
.tags-table__header-cell.tags-table__header-cell {
|
||||||
|
@include sticky-cell(false);
|
||||||
|
|
||||||
|
top: $headerHeight;
|
||||||
|
position: sticky;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
|
@ -1,27 +1,50 @@
|
||||||
import { FC, useEffect, useRef } from 'react';
|
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { pipe, splitEvery } from 'ramda';
|
||||||
import { RouteChildrenProps } from 'react-router';
|
import { RouteChildrenProps } from 'react-router';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
|
||||||
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 { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
|
||||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||||
import { TagsTableRowProps } from './TagsTableRow';
|
import { TagsTableRowProps } from './TagsTableRow';
|
||||||
|
import { NormalizedTag } from './data';
|
||||||
|
import './TagsTable.scss';
|
||||||
|
|
||||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||||
|
|
||||||
export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsTableRowProps>) => (
|
type OrderableFields = 'tag' | 'shortUrls' | 'visits';
|
||||||
|
type TagsOrder = Order<OrderableFields>;
|
||||||
|
|
||||||
|
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||||
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
||||||
) => {
|
) => {
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||||
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||||
const sortedTags = tagsList.filteredTags; // TODO Support sorting tags
|
const [ order, setOrder ] = useState<TagsOrder>({});
|
||||||
|
const sortedTags = useMemo(
|
||||||
|
pipe(
|
||||||
|
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
||||||
|
tag,
|
||||||
|
shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0,
|
||||||
|
visits: tagsList.stats[tag]?.visitsCount ?? 0,
|
||||||
|
})),
|
||||||
|
(normalizedTags) => sortList<NormalizedTag>(normalizedTags, order),
|
||||||
|
),
|
||||||
|
[ tagsList.filteredTags, order ],
|
||||||
|
);
|
||||||
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 orderByColumn = (field: OrderableFields) =>
|
||||||
|
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
||||||
|
const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field &&
|
||||||
|
<FontAwesomeIcon icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon} className="ml-1" />;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
!isFirstLoad.current && setPage(1);
|
!isFirstLoad.current && setPage(1);
|
||||||
isFirstLoad.current = false;
|
isFirstLoad.current = false;
|
||||||
|
@ -35,23 +58,20 @@ export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsT
|
||||||
<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>Tag</th>
|
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>Tag {renderOrderIcon('tag')}</th>
|
||||||
<th className="text-lg-right">Short URLs</th>
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
|
||||||
<th className="text-lg-right">Visits</th>
|
Short URLs {renderOrderIcon('shortUrls')}
|
||||||
<th />
|
</th>
|
||||||
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
|
||||||
|
Visits {renderOrderIcon('visits')}
|
||||||
|
</th>
|
||||||
|
<th className="tags-table__header-cell" />
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr><th colSpan={4} className="p-0 border-top-0" /></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||||
{currentPage.map((tag) => (
|
{currentPage.map((tag) => <TagsTableRow key={tag.tag} tag={tag} selectedServer={selectedServer} />)}
|
||||||
<TagsTableRow
|
|
||||||
key={tag}
|
|
||||||
tag={tag}
|
|
||||||
tagStats={tagsList.stats[tag]}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
|
@ -9,18 +9,18 @@ import { prettify } from '../utils/helpers/numbers';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||||
import TagBullet from './helpers/TagBullet';
|
import TagBullet from './helpers/TagBullet';
|
||||||
import { TagModalProps, TagStats } from './data';
|
import { NormalizedTag, TagModalProps } from './data';
|
||||||
|
|
||||||
export interface TagsTableRowProps {
|
export interface TagsTableRowProps {
|
||||||
tag: string;
|
tag: NormalizedTag;
|
||||||
tagStats?: TagStats;
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
colorGenerator: ColorGenerator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>) => (
|
export const TagsTableRow = (
|
||||||
{ tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps,
|
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||||
) => {
|
EditTagModal: FC<TagModalProps>,
|
||||||
|
colorGenerator: ColorGenerator,
|
||||||
|
) => ({ tag, selectedServer }: TagsTableRowProps) => {
|
||||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||||
|
@ -29,16 +29,16 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagMo
|
||||||
return (
|
return (
|
||||||
<tr className="responsive-table__row">
|
<tr className="responsive-table__row">
|
||||||
<th className="responsive-table__cell" data-th="Tag">
|
<th className="responsive-table__cell" data-th="Tag">
|
||||||
<TagBullet tag={tag} colorGenerator={colorGenerator} /> {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)}`}>
|
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag.tag)}`}>
|
||||||
{prettify(tagStats?.shortUrlsCount ?? 0)}
|
{prettify(tag.shortUrls)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
||||||
<Link to={`/server/${serverId}/tag/${tag}/visits`}>
|
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
|
||||||
{prettify(tagStats?.visitsCount ?? 0)}
|
{prettify(tag.visits)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-lg-right">
|
<td className="responsive-table__cell text-lg-right">
|
||||||
|
@ -52,8 +52,8 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagMo
|
||||||
</DropdownBtnMenu>
|
</DropdownBtnMenu>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,3 +8,9 @@ export interface TagModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NormalizedTag {
|
||||||
|
tag: string;
|
||||||
|
shortUrls: number;
|
||||||
|
visits: number;
|
||||||
|
}
|
||||||
|
|
|
@ -27,9 +27,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
||||||
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal');
|
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||||
|
|
||||||
bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow');
|
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
|
||||||
bottle.decorator('TagsTable', withRouter);
|
bottle.decorator('TagsTable', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { toPairs } from 'ramda';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { determineOrderDir, OrderDir } from './utils';
|
import { determineOrderDir, OrderDir } from './helpers/ordering';
|
||||||
import './SortingDropdown.scss';
|
import './SortingDropdown.scss';
|
||||||
|
|
||||||
export interface SortingDropdownProps<T extends string = string> {
|
export interface SortingDropdownProps<T extends string = string> {
|
||||||
|
|
32
src/utils/helpers/ordering.ts
Normal file
32
src/utils/helpers/ordering.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
||||||
|
|
||||||
|
export interface Order<Fields> {
|
||||||
|
field?: Fields;
|
||||||
|
dir?: OrderDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const determineOrderDir = <T extends string = string>(
|
||||||
|
currentField: T,
|
||||||
|
newField?: T,
|
||||||
|
currentOrderDir?: OrderDir,
|
||||||
|
): OrderDir => {
|
||||||
|
if (currentField !== newField) {
|
||||||
|
return 'ASC';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
|
||||||
|
ASC: 'DESC',
|
||||||
|
DESC: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortList = <List>(list: List[], { field, dir }: Order<Partial<keyof List>>) => !field || !dir
|
||||||
|
? list
|
||||||
|
: list.sort((a, b) => {
|
||||||
|
const greaterThan = dir === 'ASC' ? 1 : -1;
|
||||||
|
const smallerThan = dir === 'ASC' ? -1 : 1;
|
||||||
|
|
||||||
|
return a[field] > b[field] ? greaterThan : smallerThan;
|
||||||
|
});
|
|
@ -1,6 +1,6 @@
|
||||||
@import '../base';
|
@import '../base';
|
||||||
|
|
||||||
@mixin sticky-cell() {
|
@mixin sticky-cell($with-separators: true) {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -11,20 +11,20 @@
|
||||||
top: -1px;
|
top: -1px;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
right: -1px;
|
right: if($with-separators, -1px, 0);
|
||||||
background: var(--table-border-color);
|
background: var(--table-border-color);
|
||||||
z-index: -2;
|
z-index: -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:first-child:before {
|
&:first-child:before {
|
||||||
left: -1px;
|
left: if($with-separators, -1px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 1px;
|
left: if($with-separators, 1px, 0);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
|
|
|
@ -1,25 +1,6 @@
|
||||||
import { isEmpty, isNil, pipe, range } from 'ramda';
|
import { isEmpty, isNil, pipe, range } from 'ramda';
|
||||||
import { SyntheticEvent } from 'react';
|
import { SyntheticEvent } from 'react';
|
||||||
|
|
||||||
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
|
||||||
|
|
||||||
export const determineOrderDir = <T extends string = string>(
|
|
||||||
currentField: T,
|
|
||||||
newField?: T,
|
|
||||||
currentOrderDir?: OrderDir,
|
|
||||||
): OrderDir => {
|
|
||||||
if (currentField !== newField) {
|
|
||||||
return 'ASC';
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
|
|
||||||
ASC: 'DESC',
|
|
||||||
DESC: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rangeOf = <T>(size: number, mappingFn: (value: number) => T, startAt = 1): T[] =>
|
export const rangeOf = <T>(size: number, mappingFn: (value: number) => T, startAt = 1): T[] =>
|
||||||
range(startAt, size + 1).map(mappingFn);
|
range(startAt, size + 1).map(mappingFn);
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { supportsBotVisits } from '../utils/helpers/features';
|
import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import LineChartCard from './charts/LineChartCard';
|
import LineChartCard from './charts/LineChartCard';
|
||||||
import VisitsTable from './VisitsTable';
|
import VisitsTable from './VisitsTable';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
||||||
|
@ -295,7 +296,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
className="btn-md-block mr-2"
|
className="btn-md-block mr-2"
|
||||||
onClick={() => setSelectedVisits([])}
|
onClick={() => setSelectedVisits([])}
|
||||||
>
|
>
|
||||||
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
|
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
outline
|
outline
|
||||||
|
@ -303,7 +304,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
className="btn-md-block"
|
className="btn-md-block"
|
||||||
onClick={() => exportCsv(normalizedVisits)}
|
onClick={() => exportCsv(normalizedVisits)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faFileDownload} /> Export ({normalizedVisits.length})
|
<FontAwesomeIcon icon={faFileDownload} /> Export ({prettify(normalizedVisits.length)})
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,7 @@ 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, OrderDir } from '../utils/utils';
|
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';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
|
@ -29,11 +29,7 @@ export interface VisitsTableProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
|
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
|
||||||
|
type VisitsOrder = Order<OrderableFields>;
|
||||||
interface Order {
|
|
||||||
field?: OrderableFields;
|
|
||||||
dir?: OrderDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
|
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
|
||||||
|
@ -42,15 +38,8 @@ const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: No
|
||||||
);
|
);
|
||||||
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
||||||
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
||||||
const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort(
|
const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList<NormalizedVisit>(visits, order as any);
|
||||||
(a, b) => {
|
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => {
|
||||||
const greaterThan = dir === 'ASC' ? 1 : -1;
|
|
||||||
const smallerThan = dir === 'ASC' ? -1 : 1;
|
|
||||||
|
|
||||||
return (a as NormalizedOrphanVisit)[field] > (b as NormalizedOrphanVisit)[field] ? greaterThan : smallerThan;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => {
|
|
||||||
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
|
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
|
||||||
const sortedVisits = sortVisits(order, filteredVisits);
|
const sortedVisits = sortVisits(order, filteredVisits);
|
||||||
const total = sortedVisits.length;
|
const total = sortedVisits.length;
|
||||||
|
@ -72,7 +61,7 @@ const VisitsTable = ({
|
||||||
|
|
||||||
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
|
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
|
||||||
const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined);
|
const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined);
|
||||||
const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined });
|
const [ order, setOrder ] = useState<VisitsOrder>({});
|
||||||
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
|
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
const [ page, setPage ] = useState(1);
|
const [ page, setPage ] = useState(1);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||||
import { OrderDir, rangeOf } from '../../utils/utils';
|
import { rangeOf } from '../../utils/utils';
|
||||||
|
import { OrderDir } from '../../utils/helpers/ordering';
|
||||||
import SimplePaginator from '../../common/SimplePaginator';
|
import SimplePaginator from '../../common/SimplePaginator';
|
||||||
import { roundTen } from '../../utils/helpers/numbers';
|
import { roundTen } from '../../utils/helpers/numbers';
|
||||||
import SortingDropdown from '../../utils/SortingDropdown';
|
import SortingDropdown from '../../utils/SortingDropdown';
|
||||||
|
|
|
@ -79,13 +79,13 @@ describe('<ShortUrlsList />', () => {
|
||||||
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 | null)(field);
|
||||||
|
|
||||||
expect(renderIcon('visits')).toEqual(null);
|
expect(renderIcon('visits')).toEqual(undefined);
|
||||||
|
|
||||||
wrapper.find(SortingDropdown).simulate('change', 'visits');
|
wrapper.find(SortingDropdown).simulate('change', 'visits');
|
||||||
expect(renderIcon('visits')).toEqual(null);
|
expect(renderIcon('visits')).toEqual(undefined);
|
||||||
|
|
||||||
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
||||||
expect(renderIcon('visits')).not.toEqual(null);
|
expect(renderIcon('visits')).not.toEqual(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles order by through table', () => {
|
it('handles order by through table', () => {
|
||||||
|
|
|
@ -2,17 +2,16 @@ import { Mock } from 'ts-mockery';
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { match } from 'react-router';
|
import { match } from 'react-router';
|
||||||
import { Location, History } from 'history';
|
import { Location, History } from 'history';
|
||||||
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
|
||||||
import { TagsTable as createTagsTable } from '../../src/tags/TagsTable';
|
import { TagsTable as createTagsTable } from '../../src/tags/TagsTable';
|
||||||
import { SelectedServer } from '../../src/servers/data';
|
import { SelectedServer } from '../../src/servers/data';
|
||||||
import { TagsList } from '../../src/tags/reducers/tagsList';
|
import { TagsList } from '../../src/tags/reducers/tagsList';
|
||||||
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 { NormalizedTag } from '../../src/tags/data';
|
||||||
|
|
||||||
describe('<TagsTable />', () => {
|
describe('<TagsTable />', () => {
|
||||||
const colorGenerator = Mock.all<ColorGenerator>();
|
|
||||||
const TagsTableRow = () => null;
|
const TagsTableRow = () => null;
|
||||||
const TagsTable = createTagsTable(colorGenerator, TagsTableRow);
|
const TagsTable = createTagsTable(TagsTableRow);
|
||||||
const tags = (amount: number) => rangeOf(amount, (i) => `tag_${i}`);
|
const tags = (amount: number) => rangeOf(amount, (i) => `tag_${i}`);
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (filteredTags: string[] = [], search = '') => {
|
const createWrapper = (filteredTags: string[] = [], search = '') => {
|
||||||
|
@ -86,7 +85,7 @@ describe('<TagsTable />', () => {
|
||||||
|
|
||||||
expect(tagRows).toHaveLength(expectedRows);
|
expect(tagRows).toHaveLength(expectedRows);
|
||||||
tagRows.forEach((row, index) => {
|
tagRows.forEach((row, index) => {
|
||||||
expect(row.prop('tag')).toEqual(`tag_${index + offset + 1}`);
|
expect(row.prop('tag')).toEqual(expect.objectContaining({ tag: `tag_${index + offset + 1}` }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -97,4 +96,25 @@ describe('<TagsTable />', () => {
|
||||||
(wrapper.find(SimplePaginator).prop('setCurrentPage') as Function)(5);
|
(wrapper.find(SimplePaginator).prop('setCurrentPage') as Function)(5);
|
||||||
expect(wrapper.find(SimplePaginator).prop('currentPage')).toEqual(5);
|
expect(wrapper.find(SimplePaginator).prop('currentPage')).toEqual(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('orders tags when column is clicked', () => {
|
||||||
|
const wrapper = createWrapper(tags(100));
|
||||||
|
const firstRowText = () => (wrapper.find('tbody').find(TagsTableRow).first().prop('tag') as NormalizedTag).tag;
|
||||||
|
|
||||||
|
expect(firstRowText()).toEqual('tag_1');
|
||||||
|
wrapper.find('thead').find('th').first().simulate('click'); // Tag column ASC
|
||||||
|
expect(firstRowText()).toEqual('tag_1');
|
||||||
|
wrapper.find('thead').find('th').first().simulate('click'); // Tag column DESC
|
||||||
|
expect(firstRowText()).toEqual('tag_99');
|
||||||
|
wrapper.find('thead').find('th').at(2).simulate('click'); // Visits column - ASC
|
||||||
|
expect(firstRowText()).toEqual('tag_100');
|
||||||
|
wrapper.find('thead').find('th').at(2).simulate('click'); // Visits column - DESC
|
||||||
|
expect(firstRowText()).toEqual('tag_1');
|
||||||
|
wrapper.find('thead').find('th').at(2).simulate('click'); // Visits column - reset
|
||||||
|
expect(firstRowText()).toEqual('tag_1');
|
||||||
|
wrapper.find('thead').find('th').at(1).simulate('click'); // Short URLs column - ASC
|
||||||
|
expect(firstRowText()).toEqual('tag_100');
|
||||||
|
wrapper.find('thead').find('th').at(1).simulate('click'); // Short URLs column - DESC
|
||||||
|
expect(firstRowText()).toEqual('tag_1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,21 +5,18 @@ import { DropdownItem } from 'reactstrap';
|
||||||
import { TagsTableRow as createTagsTableRow } from '../../src/tags/TagsTableRow';
|
import { TagsTableRow as createTagsTableRow } from '../../src/tags/TagsTableRow';
|
||||||
import { ReachableServer } from '../../src/servers/data';
|
import { ReachableServer } from '../../src/servers/data';
|
||||||
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
||||||
import { TagStats } from '../../src/tags/data';
|
|
||||||
import { DropdownBtnMenu } from '../../src/utils/DropdownBtnMenu';
|
import { DropdownBtnMenu } from '../../src/utils/DropdownBtnMenu';
|
||||||
|
|
||||||
describe('<TagsTableRow />', () => {
|
describe('<TagsTableRow />', () => {
|
||||||
const DeleteTagConfirmModal = () => null;
|
const DeleteTagConfirmModal = () => null;
|
||||||
const EditTagModal = () => null;
|
const EditTagModal = () => null;
|
||||||
const TagsTableRow = createTagsTableRow(DeleteTagConfirmModal, EditTagModal);
|
const TagsTableRow = createTagsTableRow(DeleteTagConfirmModal, EditTagModal, Mock.all<ColorGenerator>());
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (tagStats?: TagStats) => {
|
const createWrapper = (tagStats?: { visits?: number; shortUrls?: number }) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<TagsTableRow
|
<TagsTableRow
|
||||||
tag="foo&bar"
|
tag={{ tag: 'foo&bar', visits: tagStats?.visits ?? 0, shortUrls: tagStats?.shortUrls ?? 0 }}
|
||||||
tagStats={tagStats}
|
|
||||||
selectedServer={Mock.of<ReachableServer>({ id: 'abc123' })}
|
selectedServer={Mock.of<ReachableServer>({ id: 'abc123' })}
|
||||||
colorGenerator={Mock.all<ColorGenerator>()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -30,7 +27,7 @@ describe('<TagsTableRow />', () => {
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[ undefined, '0', '0' ],
|
[ undefined, '0', '0' ],
|
||||||
[ Mock.of<TagStats>({ shortUrlsCount: 10, visitsCount: 3480 }), '10', '3,480' ],
|
[{ shortUrls: 10, visits: 3480 }, '10', '3,480' ],
|
||||||
])('shows expected tag stats', (stats, expectedShortUrls, expectedVisits) => {
|
])('shows expected tag stats', (stats, expectedShortUrls, expectedVisits) => {
|
||||||
const wrapper = createWrapper(stats);
|
const wrapper = createWrapper(stats);
|
||||||
const links = wrapper.find(Link);
|
const links = wrapper.find(Link);
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { identity, values } from 'ramda';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import SortingDropdown, { SortingDropdownProps } from '../../src/utils/SortingDropdown';
|
import SortingDropdown, { SortingDropdownProps } from '../../src/utils/SortingDropdown';
|
||||||
import { OrderDir } from '../../src/utils/utils';
|
import { OrderDir } from '../../src/utils/helpers/ordering';
|
||||||
|
|
||||||
describe('<SortingDropdown />', () => {
|
describe('<SortingDropdown />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
|
|
25
test/utils/helpers/ordering.test.ts
Normal file
25
test/utils/helpers/ordering.test.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { determineOrderDir } from '../../../src/utils/helpers/ordering';
|
||||||
|
|
||||||
|
describe('ordering', () => {
|
||||||
|
describe('determineOrderDir', () => {
|
||||||
|
it('returns ASC when current order field and selected field are different', () => {
|
||||||
|
expect(determineOrderDir('foo', 'bar')).toEqual('ASC');
|
||||||
|
expect(determineOrderDir('bar', 'foo')).toEqual('ASC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ASC when no current order dir is provided', () => {
|
||||||
|
expect(determineOrderDir('foo', 'foo')).toEqual('ASC');
|
||||||
|
expect(determineOrderDir('bar', 'bar')).toEqual('ASC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns DESC when current order field and selected field are equal and current order dir is ASC', () => {
|
||||||
|
expect(determineOrderDir('foo', 'foo', 'ASC')).toEqual('DESC');
|
||||||
|
expect(determineOrderDir('bar', 'bar', 'ASC')).toEqual('DESC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when current order field and selected field are equal and current order dir is DESC', () => {
|
||||||
|
expect(determineOrderDir('foo', 'foo', 'DESC')).toBeUndefined();
|
||||||
|
expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,28 +1,6 @@
|
||||||
import { capitalize, determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils';
|
import { capitalize, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils';
|
||||||
|
|
||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
describe('determineOrderDir', () => {
|
|
||||||
it('returns ASC when current order field and selected field are different', () => {
|
|
||||||
expect(determineOrderDir('foo', 'bar')).toEqual('ASC');
|
|
||||||
expect(determineOrderDir('bar', 'foo')).toEqual('ASC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns ASC when no current order dir is provided', () => {
|
|
||||||
expect(determineOrderDir('foo', 'foo')).toEqual('ASC');
|
|
||||||
expect(determineOrderDir('bar', 'bar')).toEqual('ASC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns DESC when current order field and selected field are equal and current order dir is ASC', () => {
|
|
||||||
expect(determineOrderDir('foo', 'foo', 'ASC')).toEqual('DESC');
|
|
||||||
expect(determineOrderDir('bar', 'bar', 'ASC')).toEqual('DESC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when current order field and selected field are equal and current order dir is DESC', () => {
|
|
||||||
expect(determineOrderDir('foo', 'foo', 'DESC')).toBeUndefined();
|
|
||||||
expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rangeOf', () => {
|
describe('rangeOf', () => {
|
||||||
const func = (i: number) => `result_${i}`;
|
const func = (i: number) => `result_${i}`;
|
||||||
const size = 5;
|
const size = 5;
|
||||||
|
|
|
@ -2,7 +2,8 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { range } from 'ramda';
|
import { range } from 'ramda';
|
||||||
import SortingDropdown from '../../../src/utils/SortingDropdown';
|
import SortingDropdown from '../../../src/utils/SortingDropdown';
|
||||||
import PaginationDropdown from '../../../src/utils/PaginationDropdown';
|
import PaginationDropdown from '../../../src/utils/PaginationDropdown';
|
||||||
import { OrderDir, rangeOf } from '../../../src/utils/utils';
|
import { rangeOf } from '../../../src/utils/utils';
|
||||||
|
import { OrderDir } from '../../../src/utils/helpers/ordering';
|
||||||
import { Stats } from '../../../src/visits/types';
|
import { Stats } from '../../../src/visits/types';
|
||||||
import { SortableBarChartCard } from '../../../src/visits/charts/SortableBarChartCard';
|
import { SortableBarChartCard } from '../../../src/visits/charts/SortableBarChartCard';
|
||||||
import { HorizontalBarChart } from '../../../src/visits/charts/HorizontalBarChart';
|
import { HorizontalBarChart } from '../../../src/visits/charts/HorizontalBarChart';
|
||||||
|
|
Loading…
Reference in a new issue