Moved logic to sort tags to TagsList component, to allow sorting on any context

This commit is contained in:
Alejandro Celaya 2021-11-06 10:55:01 +01:00
parent af08b53002
commit daf076a57e
7 changed files with 86 additions and 76 deletions

View file

@ -25,12 +25,12 @@ interface ShlinkTagsStats {
export interface ShlinkTags { export interface ShlinkTags {
tags: string[]; tags: string[];
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2 stats: ShlinkTagsStats[];
} }
export interface ShlinkTagsResponse { export interface ShlinkTagsResponse {
data: string[]; data: string[];
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2 stats: ShlinkTagsStats[];
} }
export interface ShlinkPaginator { export interface ShlinkPaginator {
@ -90,7 +90,6 @@ export interface ProblemDetailsError {
detail: string; detail: string;
title: string; title: string;
status: number; status: number;
[extraProps: string]: any; [extraProps: string]: any;
} }

View file

@ -18,7 +18,7 @@ const RealTimeUpdates = (
<SimpleCard title="Real-time updates" className="h-100"> <SimpleCard title="Real-time updates" className="h-100">
<FormGroup> <FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}> <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"> <small className="form-text text-muted">
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>. Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</small> </small>

View file

@ -8,12 +8,11 @@ import { useToggle } from '../utils/helpers/hooks';
import ColorGenerator from '../utils/services/ColorGenerator'; import ColorGenerator from '../utils/services/ColorGenerator';
import { isServerWithId, SelectedServer } from '../servers/data'; import { isServerWithId, SelectedServer } from '../servers/data';
import TagBullet from './helpers/TagBullet'; import TagBullet from './helpers/TagBullet';
import { TagModalProps, TagStats } from './data'; import { NormalizedTag, TagModalProps } from './data';
import './TagCard.scss'; import './TagCard.scss';
export interface TagCardProps { export interface TagCardProps {
tag: string; tag: NormalizedTag;
tagStats?: TagStats;
selectedServer: SelectedServer; selectedServer: SelectedServer;
displayed: boolean; displayed: boolean;
toggle: () => void; toggle: () => void;
@ -25,7 +24,7 @@ const TagCard = (
DeleteTagConfirmModal: FC<TagModalProps>, DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => { ) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle(); const [ isEditModalOpen, toggleEdit ] = useToggle();
const [ hasTitle,, displayTitle ] = useToggle(); const [ hasTitle,, displayTitle ] = useToggle();
@ -49,39 +48,37 @@ const TagCard = (
</Button> </Button>
<h5 <h5
className="tag-card__tag-title text-ellipsis" className="tag-card__tag-title text-ellipsis"
title={hasTitle ? tag : undefined} title={hasTitle ? tag.tag : undefined}
ref={(el) => { ref={(el) => {
titleRef.current = el ?? undefined; titleRef.current = el ?? undefined;
}} }}
> >
<TagBullet tag={tag} colorGenerator={colorGenerator} /> <TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span> <span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
</h5> </h5>
</CardHeader> </CardHeader>
{tagStats && (
<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)}`} 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" 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>
<b>{prettify(tagStats.shortUrlsCount)}</b> <b>{prettify(tag.shortUrls)}</b>
</Link> </Link>
<Link <Link
to={`/server/${serverId}/tag/${tag}/visits`} to={`/server/${serverId}/tag/${tag.tag}/visits`}
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center" 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> <span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
<b>{prettify(tagStats.visitsCount)}</b> <b>{prettify(tag.visits)}</b>
</Link> </Link>
</CardBody> </CardBody>
</Collapse> </Collapse>
)}
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} /> <DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} /> <EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
</Card> </Card>
); );
}; };

View file

@ -7,10 +7,10 @@ import { TagsListChildrenProps } from './data/TagsListChildrenProps';
const { ceil } = Math; const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4; 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 [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
const tagsCount = tagsList.filteredTags.length; const tagsCount = sortedTags.length;
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags); const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags);
return ( return (
<Row> <Row>
@ -18,12 +18,11 @@ export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps>
<div key={index} className="col-md-6 col-xl-3"> <div key={index} className="col-md-6 col-xl-3">
{group.map((tag) => ( {group.map((tag) => (
<TagCard <TagCard
key={tag} key={tag.tag}
tag={tag} tag={tag}
tagStats={tagsList.stats[tag]}
selectedServer={selectedServer} selectedServer={selectedServer}
displayed={displayedTag === tag} displayed={displayedTag === tag.tag}
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)} toggle={() => setDisplayedTag(displayedTag !== tag.tag ? tag.tag : undefined)}
/> />
))} ))}
</div> </div>

View file

@ -1,5 +1,8 @@
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useMemo, useState } from 'react';
import { Row } from 'reactstrap'; import { Row } from 'reactstrap';
import { pipe } from 'ramda';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import Message from '../utils/Message'; import Message from '../utils/Message';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
@ -8,9 +11,12 @@ import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { Settings, TagsMode } from '../settings/reducers/settings'; import { Settings, TagsMode } from '../settings/reducers/settings';
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
import { TagsList as TagsListState } from './reducers/tagsList'; import { TagsList as TagsListState } from './reducers/tagsList';
import { TagsListChildrenProps } from './data/TagsListChildrenProps'; import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
import { TagsModeDropdown } from './TagsModeDropdown'; import { TagsModeDropdown } from './TagsModeDropdown';
import { NormalizedTag } from './data';
import { TagsTableProps } from './TagsTable';
export interface TagsListProps { export interface TagsListProps {
filterTags: (searchTerm: string) => void; filterTags: (searchTerm: string) => void;
@ -20,10 +26,22 @@ export interface TagsListProps {
settings: Settings; 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, { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => { ) => {
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards'); const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
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 ],
);
useEffect(() => { useEffect(() => {
forceListTags(); forceListTags();
@ -33,6 +51,10 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListCh
return <Message loading />; return <Message loading />;
} }
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 renderContent = () => { const renderContent = () => {
if (tagsList.error) { if (tagsList.error) {
return ( return (
@ -47,8 +69,15 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListCh
} }
return mode === 'cards' return mode === 'cards'
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} /> ? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />; : (
<TagsTable
sortedTags={sortedTags}
selectedServer={selectedServer}
orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon}
/>
);
}; };
return ( return (

View file

@ -1,54 +1,35 @@
import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FC, ReactNode, useEffect, useRef } from 'react';
import { pipe, splitEvery } from 'ramda'; import { 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 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 { OrderableFields, TagsListChildrenProps } from './data/TagsListChildrenProps';
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
import { TagsTableRowProps } from './TagsTableRow'; import { TagsTableRowProps } from './TagsTableRow';
import { NormalizedTag } from './data';
import './TagsTable.scss'; import './TagsTable.scss';
export interface TagsTableProps extends TagsListChildrenProps {
orderByColumn: (field: OrderableFields) => () => void;
renderOrderIcon: (field: OrderableFields) => ReactNode;
}
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings 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>) => ( export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps, { sortedTags, selectedServer, location, orderByColumn, renderOrderIcon }: TagsTableProps & 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 [ 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;
}, [ tagsList.filteredTags ]); }, [ sortedTags ]);
useEffect(() => { useEffect(() => {
scrollTo(0, 0); scrollTo(0, 0);
}, [ page ]); }, [ page ]);

View file

@ -1,7 +1,12 @@
import { TagsList as TagsListState } from '../reducers/tagsList';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { Order } from '../../utils/helpers/ordering';
import { NormalizedTag } from './index';
export type OrderableFields = 'tag' | 'shortUrls' | 'visits';
export type TagsOrder = Order<OrderableFields>;
export interface TagsListChildrenProps { export interface TagsListChildrenProps {
tagsList: TagsListState; sortedTags: NormalizedTag[];
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }