mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Moved logic to sort tags to TagsList component, to allow sorting on any context
This commit is contained in:
parent
af08b53002
commit
daf076a57e
7 changed files with 86 additions and 76 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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 ]);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue