mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Merge pull request #516 from acelaya-forks/feature/consistent-tags-sorting
Feature/consistent tags sorting
This commit is contained in:
commit
5906921eec
19 changed files with 198 additions and 168 deletions
|
@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
* [#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.
|
||||
* [#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.
|
||||
|
||||
### Changed
|
||||
* Moved ci workflow to external repo and reused
|
||||
|
|
|
@ -25,12 +25,12 @@ interface ShlinkTagsStats {
|
|||
|
||||
export interface ShlinkTags {
|
||||
tags: string[];
|
||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkTagsResponse {
|
||||
data: string[];
|
||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkPaginator {
|
||||
|
@ -90,7 +90,6 @@ export interface ProblemDetailsError {
|
|||
detail: string;
|
||||
title: string;
|
||||
status: number;
|
||||
|
||||
[extraProps: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
@ -40,7 +40,8 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
|||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const hasId = isServerWithId(selectedServer);
|
||||
const serverId = hasId ? selectedServer.id : '';
|
||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
|
@ -77,7 +78,7 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
{isServerWithId(selectedServer) && (
|
||||
{hasId && (
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
|
|
|
@ -11,7 +11,7 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
|||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
import { Versions } from '../utils/helpers/version';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { isServerWithId, SelectedServer } from './data';
|
||||
import { getServerId, SelectedServer } from './data';
|
||||
import './Overview.scss';
|
||||
|
||||
interface OverviewConnectProps {
|
||||
|
@ -40,7 +40,7 @@ export const Overview = (
|
|||
const { loading, shortUrls } = shortUrlsList;
|
||||
const { loading: loadingTags } = tagsList;
|
||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const serverId = getServerId(selectedServer);
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -18,7 +18,7 @@ const RealTimeUpdates = (
|
|||
<SimpleCard title="Real-time updates" className="h-100">
|
||||
<FormGroup>
|
||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||
Enable or disable real-time updates.
|
||||
<small className="form-text text-muted">
|
||||
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
||||
</small>
|
||||
|
|
|
@ -67,12 +67,7 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
|||
return (
|
||||
<>
|
||||
<div className="d-block d-lg-none mb-3">
|
||||
<SortingDropdown
|
||||
items={SORTABLE_FIELDS}
|
||||
orderField={order.field}
|
||||
orderDir={order.dir}
|
||||
onChange={handleOrderBy}
|
||||
/>
|
||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
||||
</div>
|
||||
<Card body className="pb-1">
|
||||
<ShortUrlsTable
|
||||
|
|
|
@ -6,14 +6,13 @@ import { Link } from 'react-router-dom';
|
|||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import { getServerId, SelectedServer } from '../servers/data';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
import { TagModalProps, TagStats } from './data';
|
||||
import { NormalizedTag, TagModalProps } from './data';
|
||||
import './TagCard.scss';
|
||||
|
||||
export interface TagCardProps {
|
||||
tag: string;
|
||||
tagStats?: TagStats;
|
||||
tag: NormalizedTag;
|
||||
selectedServer: SelectedServer;
|
||||
displayed: boolean;
|
||||
toggle: () => void;
|
||||
|
@ -25,12 +24,12 @@ const TagCard = (
|
|||
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||
EditTagModal: FC<TagModalProps>,
|
||||
colorGenerator: ColorGenerator,
|
||||
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
|
||||
) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
const [ hasTitle,, displayTitle ] = useToggle();
|
||||
const titleRef = useRef<HTMLElement>();
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const serverId = getServerId(selectedServer);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTruncated(titleRef.current)) {
|
||||
|
@ -49,39 +48,37 @@ const TagCard = (
|
|||
</Button>
|
||||
<h5
|
||||
className="tag-card__tag-title text-ellipsis"
|
||||
title={hasTitle ? tag : undefined}
|
||||
title={hasTitle ? tag.tag : undefined}
|
||||
ref={(el) => {
|
||||
titleRef.current = el ?? undefined;
|
||||
}}
|
||||
>
|
||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
|
||||
</h5>
|
||||
</CardHeader>
|
||||
|
||||
{tagStats && (
|
||||
<Collapse isOpen={displayed}>
|
||||
<CardBody className="tag-card__body">
|
||||
<Link
|
||||
to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}
|
||||
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>
|
||||
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/server/${serverId}/tag/${tag}/visits`}
|
||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||
<b>{prettify(tagStats.visitsCount)}</b>
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
)}
|
||||
<Collapse isOpen={displayed}>
|
||||
<CardBody className="tag-card__body">
|
||||
<Link
|
||||
to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag.tag)}`}
|
||||
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>
|
||||
<b>{prettify(tag.shortUrls)}</b>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/server/${serverId}/tag/${tag.tag}/visits`}
|
||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||
<b>{prettify(tag.visits)}</b>
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,10 +7,10 @@ import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
|||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
|
||||
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ tagsList, selectedServer }) => {
|
||||
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ sortedTags, selectedServer }) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
const tagsCount = sortedTags.length;
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
|
@ -18,12 +18,11 @@ export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps>
|
|||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
key={tag.tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
displayed={displayedTag === tag.tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag.tag ? tag.tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { Row } from 'reactstrap';
|
||||
import { pipe } from 'ramda';
|
||||
import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
|
@ -8,9 +9,13 @@ import { Result } from '../utils/Result';
|
|||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
|
||||
import SortingDropdown from '../utils/SortingDropdown';
|
||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { OrderableFields, SORTABLE_FIELDS, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||
import { NormalizedTag } from './data';
|
||||
import { TagsTableProps } from './TagsTable';
|
||||
|
||||
export interface TagsListProps {
|
||||
filterTags: (searchTerm: string) => void;
|
||||
|
@ -20,10 +25,19 @@ export interface TagsListProps {
|
|||
settings: Settings;
|
||||
}
|
||||
|
||||
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
|
||||
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||
) => {
|
||||
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||
const [ order, setOrder ] = useState<TagsOrder>({});
|
||||
const resolveSortedTags = pipe(
|
||||
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
||||
tag,
|
||||
shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0,
|
||||
visits: tagsList.stats[tag]?.visitsCount ?? 0,
|
||||
})),
|
||||
(normalizedTags) => sortList<NormalizedTag>(normalizedTags, order),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
|
@ -33,31 +47,49 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListCh
|
|||
return <Message loading />;
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
const orderByColumn = (field: OrderableFields) => () => {
|
||||
const dir = determineOrderDir(field, order.field, order.dir);
|
||||
|
||||
setOrder({ field: dir ? field : undefined, dir });
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.filteredTags.length < 1) {
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
const sortedTags = resolveSortedTags();
|
||||
|
||||
return mode === 'cards'
|
||||
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
|
||||
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
|
||||
? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
|
||||
: (
|
||||
<TagsTable
|
||||
sortedTags={sortedTags}
|
||||
selectedServer={selectedServer}
|
||||
currentOrder={order}
|
||||
orderByColumn={orderByColumn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterTags} />
|
||||
<Row className="mb-3">
|
||||
<div className="col-lg-6 offset-lg-6">
|
||||
<div className="col-lg-6">
|
||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
<div className="col-lg-6 mt-3 mt-lg-0">
|
||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={(field, dir) => setOrder({ field, dir })} />
|
||||
</div>
|
||||
</Row>
|
||||
{renderContent()}
|
||||
</>
|
||||
|
|
|
@ -1,54 +1,39 @@
|
|||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { pipe, splitEvery } from 'ramda';
|
||||
import { RouteChildrenProps } from 'react-router';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
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 { SimpleCard } from '../utils/SimpleCard';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import { useQueryState } from '../utils/helpers/hooks';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||
import { TagsTableRowProps } from './TagsTableRow';
|
||||
import { NormalizedTag } from './data';
|
||||
import './TagsTable.scss';
|
||||
|
||||
export interface TagsTableProps extends TagsListChildrenProps {
|
||||
orderByColumn: (field: OrderableFields) => () => void;
|
||||
currentOrder: TagsOrder;
|
||||
}
|
||||
|
||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||
|
||||
type OrderableFields = 'tag' | 'shortUrls' | 'visits';
|
||||
type TagsOrder = Order<OrderableFields>;
|
||||
|
||||
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
||||
{ sortedTags, selectedServer, location, orderByColumn, currentOrder }: TagsTableProps & RouteChildrenProps,
|
||||
) => {
|
||||
const isFirstLoad = useRef(true);
|
||||
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||
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 showPaginator = pages.length > 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" />;
|
||||
const renderOrderIcon = (field: OrderableFields) => currentOrder.dir && currentOrder.field === field &&
|
||||
<FontAwesomeIcon icon={currentOrder.dir === 'ASC' ? caretUpIcon : caretDownIcon} className="ml-1" />;
|
||||
|
||||
useEffect(() => {
|
||||
!isFirstLoad.current && setPage(1);
|
||||
isFirstLoad.current = false;
|
||||
}, [ tagsList.filteredTags ]);
|
||||
}, [ sortedTags ]);
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ page ]);
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
import { TagsList as TagsListState } from '../reducers/tagsList';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { Order } from '../../utils/helpers/ordering';
|
||||
import { NormalizedTag } from './index';
|
||||
|
||||
export const SORTABLE_FIELDS = {
|
||||
tag: 'Tag',
|
||||
shortUrls: 'Short URLs',
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||
|
||||
export type TagsOrder = Order<OrderableFields>;
|
||||
|
||||
export interface TagsListChildrenProps {
|
||||
tagsList: TagsListState;
|
||||
sortedTags: NormalizedTag[];
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
|
|
@ -3,23 +3,22 @@ import { toPairs } from 'ramda';
|
|||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import classNames from 'classnames';
|
||||
import { determineOrderDir, OrderDir } from './helpers/ordering';
|
||||
import { determineOrderDir, Order, OrderDir } from './helpers/ordering';
|
||||
import './SortingDropdown.scss';
|
||||
|
||||
export interface SortingDropdownProps<T extends string = string> {
|
||||
items: Record<T, string>;
|
||||
orderField?: T;
|
||||
orderDir?: OrderDir;
|
||||
order: Order<T>;
|
||||
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
||||
isButton?: boolean;
|
||||
right?: boolean;
|
||||
}
|
||||
|
||||
export default function SortingDropdown<T extends string = string>(
|
||||
{ items, orderField, orderDir, onChange, isButton = true, right = false }: SortingDropdownProps<T>,
|
||||
{ items, order, onChange, isButton = true, right = false }: SortingDropdownProps<T>,
|
||||
) {
|
||||
const handleItemClick = (fieldKey: T) => () => {
|
||||
const newOrderDir = determineOrderDir(fieldKey, orderField, orderDir);
|
||||
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
|
||||
|
||||
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
||||
};
|
||||
|
@ -32,26 +31,26 @@ export default function SortingDropdown<T extends string = string>(
|
|||
className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })}
|
||||
>
|
||||
{!isButton && <>Order by</>}
|
||||
{isButton && !orderField && <>Order by...</>}
|
||||
{isButton && orderField && `Order by: "${items[orderField]}" - "${orderDir ?? 'DESC'}"`}
|
||||
{isButton && !order.field && <>Order by...</>}
|
||||
{isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu
|
||||
right={right}
|
||||
className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
|
||||
>
|
||||
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
||||
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
||||
<DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
||||
{fieldValue}
|
||||
{orderField === fieldKey && (
|
||||
{order.field === fieldKey && (
|
||||
<FontAwesomeIcon
|
||||
icon={orderDir === 'ASC' ? sortAscIcon : sortDescIcon}
|
||||
icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
|
||||
className="sorting-dropdown__sort-icon"
|
||||
/>
|
||||
)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem disabled={!orderField} onClick={() => onChange()}>
|
||||
<DropdownItem disabled={!order.field} onClick={() => onChange()}>
|
||||
<i>Clear selection</i>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||
import { rangeOf } from '../../utils/utils';
|
||||
import { OrderDir } from '../../utils/helpers/ordering';
|
||||
import { Order } from '../../utils/helpers/ordering';
|
||||
import SimplePaginator from '../../common/SimplePaginator';
|
||||
import { roundTen } from '../../utils/helpers/numbers';
|
||||
import SortingDropdown from '../../utils/SortingDropdown';
|
||||
|
@ -30,24 +30,21 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
|||
withPagination = true,
|
||||
...rest
|
||||
}) => {
|
||||
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
|
||||
orderField: undefined,
|
||||
orderDir: undefined,
|
||||
});
|
||||
const [ order, setOrder ] = useState<Order<string>>({});
|
||||
const [ currentPage, setCurrentPage ] = useState(1);
|
||||
const [ itemsPerPage, setItemsPerPage ] = useState(50);
|
||||
|
||||
const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => {
|
||||
const pairs = toPairs(stats);
|
||||
const sortedPairs = !order.orderField ? pairs : sortBy(
|
||||
const sortedPairs = !order.field ? pairs : sortBy(
|
||||
pipe<StatsRow, string | number, string | number>(
|
||||
order.orderField === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
|
||||
order.field === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
|
||||
toLowerIfString,
|
||||
),
|
||||
pairs,
|
||||
);
|
||||
|
||||
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
};
|
||||
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
|
||||
const page = pages[currentPage - 1];
|
||||
|
@ -103,10 +100,9 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
|||
isButton={false}
|
||||
right
|
||||
items={sortingItems}
|
||||
orderField={order.orderField}
|
||||
orderDir={order.orderDir}
|
||||
onChange={(orderField, orderDir) => {
|
||||
setOrder({ orderField, orderDir });
|
||||
order={order}
|
||||
onChange={(field, dir) => {
|
||||
setOrder({ field, dir });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -108,23 +108,16 @@ describe('<ShortUrlsList />', () => {
|
|||
});
|
||||
|
||||
it('handles order by through dropdown', () => {
|
||||
expect(wrapper.find(SortingDropdown).prop('orderField')).not.toBeDefined();
|
||||
expect(wrapper.find(SortingDropdown).prop('orderDir')).not.toBeDefined();
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
|
||||
|
||||
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
||||
|
||||
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual('visits');
|
||||
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual('ASC');
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' });
|
||||
|
||||
wrapper.find(SortingDropdown).simulate('change', 'shortCode', 'DESC');
|
||||
|
||||
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual('shortCode');
|
||||
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual('DESC');
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'DESC' });
|
||||
|
||||
wrapper.find(SortingDropdown).simulate('change', undefined, undefined);
|
||||
|
||||
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual(undefined);
|
||||
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual(undefined);
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
|
||||
|
||||
expect(listShortUrlsMock).toHaveBeenCalledTimes(3);
|
||||
expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
|
@ -140,10 +133,9 @@ describe('<ShortUrlsList />', () => {
|
|||
[ Mock.of<OrderBy>({ visits: 'ASC' }), 'visits', 'ASC' ],
|
||||
[ Mock.of<OrderBy>({ title: 'DESC' }), 'title', 'DESC' ],
|
||||
[ Mock.of<OrderBy>(), undefined, undefined ],
|
||||
])('has expected initial ordering', (initialOrderBy, expectedField, expectedDir) => {
|
||||
])('has expected initial ordering', (initialOrderBy, field, dir) => {
|
||||
const wrapper = createWrapper(initialOrderBy);
|
||||
|
||||
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual(expectedField);
|
||||
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual(expectedDir);
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field, dir });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,19 +8,14 @@ import { ReachableServer } from '../../src/servers/data';
|
|||
|
||||
describe('<TagCard />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
const tagStats = {
|
||||
shortUrlsCount: 48,
|
||||
visitsCount: 23257,
|
||||
};
|
||||
const DeleteTagConfirmModal = jest.fn();
|
||||
const EditTagModal = jest.fn();
|
||||
const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, Mock.all<ColorGenerator>());
|
||||
const createWrapper = (tag = 'ssr') => {
|
||||
wrapper = shallow(
|
||||
<TagCard
|
||||
tag={tag}
|
||||
tag={{ tag, visits: 23257, shortUrls: 48 }}
|
||||
selectedServer={Mock.of<ReachableServer>({ id: '1' })}
|
||||
tagStats={tagStats}
|
||||
displayed={true}
|
||||
toggle={() => {}}
|
||||
/>,
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { TagsCards as createTagsCards } from '../../src/tags/TagsCards';
|
||||
import { TagsList } from '../../src/tags/reducers/tagsList';
|
||||
import { SelectedServer } from '../../src/servers/data';
|
||||
import { rangeOf } from '../../src/utils/utils';
|
||||
import { NormalizedTag } from '../../src/tags/data';
|
||||
|
||||
describe('<TagsCards />', () => {
|
||||
const amountOfTags = 10;
|
||||
const tagsList = Mock.of<TagsList>({ filteredTags: rangeOf(amountOfTags, (i) => `tag_${i}`), stats: {} });
|
||||
const sortedTags = rangeOf(amountOfTags, (i) => Mock.of<NormalizedTag>({ tag: `tag_${i}` }));
|
||||
const TagCard = () => null;
|
||||
const TagsCards = createTagsCards(TagCard);
|
||||
let wrapper: ShallowWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<TagsCards tagsList={tagsList} selectedServer={Mock.all<SelectedServer>()} />);
|
||||
wrapper = shallow(<TagsCards sortedTags={sortedTags} selectedServer={Mock.all<SelectedServer>()} />);
|
||||
});
|
||||
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
|
|
@ -9,6 +9,8 @@ import { Result } from '../../src/utils/Result';
|
|||
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
|
||||
import SearchField from '../../src/utils/SearchField';
|
||||
import { Settings } from '../../src/settings/reducers/settings';
|
||||
import { OrderableFields } from '../../src/tags/data/TagsListChildrenProps';
|
||||
import SortingDropdown from '../../src/utils/SortingDropdown';
|
||||
|
||||
describe('<TagsList />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
|
@ -37,17 +39,21 @@ describe('<TagsList />', () => {
|
|||
it('shows a loading message when tags are being loaded', () => {
|
||||
const wrapper = createWrapper({ loading: true });
|
||||
const loadingMsg = wrapper.find(Message);
|
||||
const searchField = wrapper.find(SearchField);
|
||||
|
||||
expect(loadingMsg).toHaveLength(1);
|
||||
expect(loadingMsg.html()).toContain('Loading...');
|
||||
expect(searchField).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows an error when tags failed to be loaded', () => {
|
||||
const wrapper = createWrapper({ error: true });
|
||||
const errorMsg = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
|
||||
const searchField = wrapper.find(SearchField);
|
||||
|
||||
expect(errorMsg).toHaveLength(1);
|
||||
expect(errorMsg.html()).toContain('Error loading tags :(');
|
||||
expect(searchField).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows a message when the list of tags is empty', () => {
|
||||
|
@ -72,9 +78,39 @@ describe('<TagsList />', () => {
|
|||
|
||||
it('triggers tags filtering when search field changes', () => {
|
||||
const wrapper = createWrapper({ filteredTags: [] });
|
||||
const searchField = wrapper.find(SearchField);
|
||||
|
||||
expect(searchField).toHaveLength(1);
|
||||
expect(filterTags).not.toHaveBeenCalled();
|
||||
wrapper.find(SearchField).simulate('change');
|
||||
searchField.simulate('change');
|
||||
expect(filterTags).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('triggers ordering when sorting dropdown changes', () => {
|
||||
const wrapper = createWrapper({ filteredTags: [] });
|
||||
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
|
||||
wrapper.find(SortingDropdown).simulate('change', 'tag', 'DESC');
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'tag', dir: 'DESC' });
|
||||
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
|
||||
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' });
|
||||
});
|
||||
|
||||
it('can update current order via orderByColumn from table component', () => {
|
||||
const wrapper = createWrapper({ filteredTags: [ 'foo', 'bar' ], stats: {} });
|
||||
const callOrderBy = (field: OrderableFields) => {
|
||||
((wrapper.find(TagsTable).prop('orderByColumn') as Function)(field) as Function)();
|
||||
};
|
||||
|
||||
wrapper.find(TagsModeDropdown).simulate('change'); // Make sure table is rendered
|
||||
|
||||
callOrderBy('visits');
|
||||
expect(wrapper.find(TagsTable).prop('currentOrder')).toEqual({ field: 'visits', dir: 'ASC' });
|
||||
callOrderBy('visits');
|
||||
expect(wrapper.find(TagsTable).prop('currentOrder')).toEqual({ field: 'visits', dir: 'DESC' });
|
||||
callOrderBy('tag');
|
||||
expect(wrapper.find(TagsTable).prop('currentOrder')).toEqual({ field: 'tag', dir: 'ASC' });
|
||||
callOrderBy('shortUrls');
|
||||
expect(wrapper.find(TagsTable).prop('currentOrder')).toEqual({ field: 'shortUrls', dir: 'ASC' });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,24 +4,26 @@ import { match } from 'react-router';
|
|||
import { Location, History } from 'history';
|
||||
import { TagsTable as createTagsTable } from '../../src/tags/TagsTable';
|
||||
import { SelectedServer } from '../../src/servers/data';
|
||||
import { TagsList } from '../../src/tags/reducers/tagsList';
|
||||
import { rangeOf } from '../../src/utils/utils';
|
||||
import SimplePaginator from '../../src/common/SimplePaginator';
|
||||
import { NormalizedTag } from '../../src/tags/data';
|
||||
|
||||
describe('<TagsTable />', () => {
|
||||
const TagsTableRow = () => null;
|
||||
const orderByColumn = jest.fn();
|
||||
const TagsTable = createTagsTable(TagsTableRow);
|
||||
const tags = (amount: number) => rangeOf(amount, (i) => `tag_${i}`);
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (filteredTags: string[] = [], search = '') => {
|
||||
const createWrapper = (sortedTags: string[] = [], search = '') => {
|
||||
wrapper = shallow(
|
||||
<TagsTable
|
||||
tagsList={Mock.of<TagsList>({ stats: {}, filteredTags })}
|
||||
sortedTags={sortedTags.map((tag) => Mock.of<NormalizedTag>({ tag }))}
|
||||
selectedServer={Mock.all<SelectedServer>()}
|
||||
currentOrder={{}}
|
||||
history={Mock.all<History>()}
|
||||
location={Mock.of<Location>({ search })}
|
||||
match={Mock.all<match>()}
|
||||
orderByColumn={() => orderByColumn}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
@ -33,6 +35,7 @@ describe('<TagsTable />', () => {
|
|||
(global as any).history = { pushState: jest.fn() };
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders empty result if there are no tags', () => {
|
||||
|
@ -99,22 +102,11 @@ describe('<TagsTable />', () => {
|
|||
|
||||
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');
|
||||
expect(orderByColumn).not.toHaveBeenCalled();
|
||||
wrapper.find('thead').find('th').first().simulate('click');
|
||||
wrapper.find('thead').find('th').at(2).simulate('click');
|
||||
wrapper.find('thead').find('th').at(1).simulate('click');
|
||||
expect(orderByColumn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,7 +14,7 @@ describe('<SortingDropdown />', () => {
|
|||
baz: 'Hello World',
|
||||
};
|
||||
const createWrapper = (props: Partial<SortingDropdownProps> = {}) => {
|
||||
wrapper = shallow(<SortingDropdown items={items} onChange={identity} {...props} />);
|
||||
wrapper = shallow(<SortingDropdown items={items} order={{}} onChange={identity} {...props} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
@ -34,7 +34,7 @@ describe('<SortingDropdown />', () => {
|
|||
});
|
||||
|
||||
it('properly marks selected field as active with proper icon', () => {
|
||||
const wrapper = createWrapper({ orderField: 'bar', orderDir: 'DESC' });
|
||||
const wrapper = createWrapper({ order: { field: 'bar', dir: 'DESC' } });
|
||||
const activeItem = wrapper.find('DropdownItem[active=true]');
|
||||
const activeItemIcon = activeItem.first().find(FontAwesomeIcon);
|
||||
|
||||
|
@ -55,7 +55,7 @@ describe('<SortingDropdown />', () => {
|
|||
|
||||
it('triggers change function when item is clicked and an order field was provided', () => {
|
||||
const onChange = jest.fn();
|
||||
const wrapper = createWrapper({ onChange, orderField: 'baz', orderDir: 'ASC' });
|
||||
const wrapper = createWrapper({ onChange, order: { field: 'baz', dir: 'ASC' } });
|
||||
const firstItem = wrapper.find(DropdownItem).first();
|
||||
|
||||
firstItem.simulate('click');
|
||||
|
@ -66,7 +66,7 @@ describe('<SortingDropdown />', () => {
|
|||
|
||||
it('updates order dir when already selected item is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
const wrapper = createWrapper({ onChange, orderField: 'foo', orderDir: 'ASC' });
|
||||
const wrapper = createWrapper({ onChange, order: { field: 'foo', dir: 'ASC' } });
|
||||
const firstItem = wrapper.find(DropdownItem).first();
|
||||
|
||||
firstItem.simulate('click');
|
||||
|
@ -79,14 +79,14 @@ describe('<SortingDropdown />', () => {
|
|||
[{ isButton: false }, <>Order by</> ],
|
||||
[{ isButton: true }, <>Order by...</> ],
|
||||
[
|
||||
{ isButton: true, orderField: 'foo', orderDir: 'ASC' as OrderDir },
|
||||
{ isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } },
|
||||
'Order by: "Foo" - "ASC"',
|
||||
],
|
||||
[
|
||||
{ isButton: true, orderField: 'baz', orderDir: 'DESC' as OrderDir },
|
||||
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } },
|
||||
'Order by: "Hello World" - "DESC"',
|
||||
],
|
||||
[{ isButton: true, orderField: 'baz' }, 'Order by: "Hello World" - "DESC"' ],
|
||||
[{ isButton: true, order: { field: 'baz' } }, 'Order by: "Hello World" - "DESC"' ],
|
||||
])('displays expected text in toggle', (props, expectedText) => {
|
||||
const wrapper = createWrapper(props);
|
||||
const toggle = wrapper.find(DropdownToggle);
|
||||
|
|
Loading…
Reference in a new issue