mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Merge pull request #493 from acelaya-forks/feature/tags-list
Feature/tags list
This commit is contained in:
commit
7b0cda7191
31 changed files with 688 additions and 119 deletions
|
@ -16,6 +16,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
|
||||
* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
|
||||
* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
|
||||
* [#459](https://github.com/shlinkio/shlink-web-client/issues/459) Added new list mode to display tags.
|
||||
|
||||
The mode is optional, and you can toggle between the classic cards mode or the new list mode whenever you want.
|
||||
|
||||
You can also configure the default mode from settings.
|
||||
|
||||
### Changed
|
||||
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
||||
|
|
|
@ -52,29 +52,29 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/overview')}>
|
||||
<FontAwesomeIcon icon={overviewIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
{addManageDomainsLink && (
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon icon={domainsIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
)}
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
{isServerWithId(selectedServer) && (
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
@import './common/react-tag-autocomplete.scss';
|
||||
@import './theme/theme';
|
||||
@import './utils/table/ResponsiveTable';
|
||||
@import './utils/StickyCardPaginator';
|
||||
|
||||
* {
|
||||
outline: none !important;
|
||||
|
|
|
@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
|||
return (
|
||||
<>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
|
|
|
@ -40,3 +40,5 @@ export const isReachableServer = (server: SelectedServer): server is ReachableSe
|
|||
|
||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||
!!server?.hasOwnProperty('serverNotFound');
|
||||
|
||||
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||
import { capitalize } from '../utils/utils';
|
||||
import { Settings, UiSettings } from './reducers/settings';
|
||||
import './UserInterface.scss';
|
||||
|
||||
|
@ -14,17 +17,28 @@ interface UserInterfaceProps {
|
|||
|
||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||
<SimpleCard title="User interface" className="h-100">
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
<FormGroup>
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
|
||||
setUiSettings({ theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
setUiSettings({ ...ui, theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label>Default display mode when managing tags:</label>
|
||||
<TagsModeDropdown
|
||||
mode={ui?.tagsMode ?? 'cards'}
|
||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
|
||||
/>
|
||||
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
|
|
|
@ -24,8 +24,11 @@ export interface ShortUrlCreationSettings {
|
|||
tagFilteringMode?: TagFilteringMode;
|
||||
}
|
||||
|
||||
export type TagsMode = 'cards' | 'list';
|
||||
|
||||
export interface UiSettings {
|
||||
theme: Theme;
|
||||
tagsMode?: TagsMode;
|
||||
}
|
||||
|
||||
export interface VisitsSettings {
|
||||
|
|
|
@ -2,7 +2,6 @@ import { Link } from 'react-router-dom';
|
|||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||
import { ShlinkPaginator } from '../api/types';
|
||||
import './Paginator.scss';
|
||||
|
||||
interface PaginatorProps {
|
||||
paginator?: ShlinkPaginator;
|
||||
|
@ -33,7 +32,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
|||
));
|
||||
|
||||
return (
|
||||
<Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<PaginationItem disabled={currentPage === 1}>
|
||||
<PaginationLink
|
||||
previous
|
||||
|
|
|
@ -1,13 +1,5 @@
|
|||
@import '../../utils/base';
|
||||
|
||||
.short-urls-row-menu__dropdown-toggle:after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.short-urls-row-menu__dropdown-toggle--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
||||
color: $dangerColor;
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import {
|
||||
faChartPie as pieChartIcon,
|
||||
faEllipsisV as menuIcon,
|
||||
faQrcode as qrIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
|
||||
|
@ -29,32 +29,27 @@ const ShortUrlsRowMenu = (
|
|||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
|
||||
return (
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
|
||||
<DropdownItem divider />
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownBtnMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
33
src/tags/TagsCards.tsx
Normal file
33
src/tags/TagsCards.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { Row } from 'reactstrap';
|
||||
import { TagCardProps } from './TagCard';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
|
||||
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ tagsList, selectedServer }) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
};
|
|
@ -1,5 +1,4 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { Row } from 'reactstrap';
|
||||
import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
|
@ -8,23 +7,23 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
|||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import { TagCardProps } from './TagCard';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||
|
||||
export interface TagsListProps {
|
||||
filterTags: (searchTerm: string) => void;
|
||||
forceListTags: Function;
|
||||
tagsList: TagsListState;
|
||||
selectedServer: SelectedServer;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
|
||||
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||
) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
|
@ -43,37 +42,23 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
|||
);
|
||||
}
|
||||
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
|
||||
if (tagsCount < 1) {
|
||||
if (tagsList.filteredTags.length < 1) {
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
return mode === 'cards'
|
||||
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
|
||||
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterTags} />
|
||||
<Row className="mb-3">
|
||||
<div className="col-lg-6 offset-lg-6">
|
||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
</Row>
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
|
|
23
src/tags/TagsModeDropdown.tsx
Normal file
23
src/tags/TagsModeDropdown.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||
import { TagsMode } from '../settings/reducers/settings';
|
||||
|
||||
interface TagsModeDropdownProps {
|
||||
mode: TagsMode;
|
||||
onChange: (newMode: TagsMode) => void;
|
||||
renderTitle?: (mode: TagsMode) => string;
|
||||
}
|
||||
|
||||
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
|
||||
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
|
||||
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
|
||||
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
|
||||
</DropdownItem>
|
||||
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
|
||||
<FontAwesomeIcon icon={listIcon} fixedWidth className="mr-1" /> List
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
65
src/tags/TagsTable.tsx
Normal file
65
src/tags/TagsTable.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { FC, useEffect, useRef } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { RouteChildrenProps } from 'react-router';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import { useQueryState } from '../utils/helpers/hooks';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { TagsTableRowProps } from './TagsTableRow';
|
||||
|
||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||
|
||||
export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsTableRowProps>) => (
|
||||
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
||||
) => {
|
||||
const isFirstLoad = useRef(true);
|
||||
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||
const sortedTags = tagsList.filteredTags; // TODO Support sorting tags
|
||||
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||
const showPaginator = pages.length > 1;
|
||||
const currentPage = pages[page - 1] ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
!isFirstLoad.current && setPage(1);
|
||||
isFirstLoad.current = false;
|
||||
}, [ tagsList.filteredTags ]);
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ page ]);
|
||||
|
||||
return (
|
||||
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
||||
<table className="table table-hover mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th className="text-lg-right">Short URLs</th>
|
||||
<th className="text-lg-right">Visits</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||
{currentPage.map((tag) => (
|
||||
<TagsTableRow
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
colorGenerator={colorGenerator}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showPaginator && (
|
||||
<div className="sticky-card-paginator">
|
||||
<SimplePaginator pagesCount={pages.length} currentPage={page} setCurrentPage={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
59
src/tags/TagsTableRow.tsx
Normal file
59
src/tags/TagsTableRow.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getServerId, SelectedServer } from '../servers/data';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
import { TagModalProps, TagStats } from './data';
|
||||
|
||||
export interface TagsTableRowProps {
|
||||
tag: string;
|
||||
tagStats?: TagStats;
|
||||
selectedServer: SelectedServer;
|
||||
colorGenerator: ColorGenerator;
|
||||
}
|
||||
|
||||
export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>) => (
|
||||
{ tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps,
|
||||
) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||
const serverId = getServerId(selectedServer);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<th className="responsive-table__cell" data-th="Tag">
|
||||
<TagBullet tag={tag} colorGenerator={colorGenerator} /> {tag}
|
||||
</th>
|
||||
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
||||
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}>
|
||||
{prettify(tagStats?.shortUrlsCount ?? 0)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
||||
<Link to={`/server/${serverId}/tag/${tag}/visits`}>
|
||||
{prettify(tagStats?.visitsCount ?? 0)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-right">
|
||||
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
||||
<DropdownItem onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth className="mr-1" /> Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="mr-1" /> Delete
|
||||
</DropdownItem>
|
||||
</DropdownBtnMenu>
|
||||
</td>
|
||||
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
</tr>
|
||||
);
|
||||
};
|
7
src/tags/data/TagsListChildrenProps.ts
Normal file
7
src/tags/data/TagsListChildrenProps.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { TagsList as TagsListState } from '../reducers/tagsList';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
|
||||
export interface TagsListChildrenProps {
|
||||
tagsList: TagsListState;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import Bottle, { IContainer } from 'bottlejs';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import TagsSelector from '../helpers/TagsSelector';
|
||||
import TagCard from '../TagCard';
|
||||
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
||||
|
@ -8,6 +9,9 @@ import { filterTags, listTags } from '../reducers/tagsList';
|
|||
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { TagsCards } from '../TagsCards';
|
||||
import { TagsTable } from '../TagsTable';
|
||||
import { TagsTableRow } from '../TagsTableRow';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
|
@ -29,9 +33,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
||||
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
||||
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal');
|
||||
|
||||
bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow');
|
||||
bottle.decorator('TagsTable', withRouter);
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
||||
bottle.decorator('TagsList', connect(
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
|
||||
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
|
||||
));
|
||||
|
||||
|
|
3
src/utils/DropdownBtnMenu.scss
Normal file
3
src/utils/DropdownBtnMenu.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.dropdown-btn-menu__dropdown-toggle:after {
|
||||
display: none !important;
|
||||
}
|
20
src/utils/DropdownBtnMenu.tsx
Normal file
20
src/utils/DropdownBtnMenu.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { FC } from 'react';
|
||||
import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import './DropdownBtnMenu.scss';
|
||||
|
||||
export interface DropdownBtnMenuProps {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
right?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, children, right = true }) => (
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right={right}>{children}</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
);
|
|
@ -1,4 +1,4 @@
|
|||
.short-urls-paginator {
|
||||
.sticky-card-paginator {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: var(--primary-color-alfa);
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
||||
import { parseQuery, stringifyQuery } from './query';
|
||||
|
||||
const DEFAULT_DELAY = 2000;
|
||||
|
||||
|
@ -51,3 +52,17 @@ export const useSwipeable = (showSidebar: () => void, hideSidebar: () => void) =
|
|||
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
||||
});
|
||||
};
|
||||
|
||||
export const useQueryState = <T>(paramName: string, initialState: T): [ T, (newValue: T) => void ] => {
|
||||
const [ value, setValue ] = useState(initialState);
|
||||
const setValueWithLocation = (value: T) => {
|
||||
const { location, history } = window;
|
||||
const query = parseQuery<any>(location.search);
|
||||
|
||||
query[paramName] = value;
|
||||
history.pushState(null, '', `${location.pathname}?${stringifyQuery(query)}`);
|
||||
setValue(value);
|
||||
};
|
||||
|
||||
return [ value, setValueWithLocation ];
|
||||
};
|
||||
|
|
|
@ -45,3 +45,5 @@ export type RecursivePartial<T> = {
|
|||
};
|
||||
|
||||
export const nonEmptyValueOrNull = <T>(value: T): T | null => isEmpty(value) ? null : value;
|
||||
|
||||
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
||||
|
|
|
@ -2,21 +2,17 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
|||
import { Mock } from 'ts-mockery';
|
||||
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Settings, UiSettings } from '../../src/settings/reducers/settings';
|
||||
import { Settings, TagsMode, UiSettings } from '../../src/settings/reducers/settings';
|
||||
import { UserInterface } from '../../src/settings/UserInterface';
|
||||
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
||||
import { Theme } from '../../src/utils/theme';
|
||||
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
|
||||
|
||||
describe('<UserInterface />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
const setUiSettings = jest.fn();
|
||||
const createWrapper = (ui?: UiSettings) => {
|
||||
wrapper = shallow(
|
||||
<UserInterface
|
||||
settings={Mock.of<Settings>({ ui })}
|
||||
setUiSettings={setUiSettings}
|
||||
/>,
|
||||
);
|
||||
wrapper = shallow(<UserInterface settings={Mock.of<Settings>({ ui })} setUiSettings={setUiSettings} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
@ -49,7 +45,7 @@ describe('<UserInterface />', () => {
|
|||
it.each([
|
||||
[ true, 'dark' ],
|
||||
[ false, 'light' ],
|
||||
])('invokes setUiSettings when toggle value changes', (checked, theme) => {
|
||||
])('invokes setUiSettings when theme toggle value changes', (checked, theme) => {
|
||||
const wrapper = createWrapper();
|
||||
const toggle = wrapper.find(ToggleSwitch);
|
||||
|
||||
|
@ -57,4 +53,30 @@ describe('<UserInterface />', () => {
|
|||
toggle.simulate('change', checked);
|
||||
expect(setUiSettings).toHaveBeenCalledWith({ theme });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ undefined, 'cards' ],
|
||||
[{ theme: 'light' as Theme }, 'cards' ],
|
||||
[{ theme: 'light' as Theme, tagsMode: 'cards' as TagsMode }, 'cards' ],
|
||||
[{ theme: 'light' as Theme, tagsMode: 'list' as TagsMode }, 'list' ],
|
||||
])('shows expected tags displaying mode', (ui, expectedMode) => {
|
||||
const wrapper = createWrapper(ui);
|
||||
const dropdown = wrapper.find(TagsModeDropdown);
|
||||
const small = wrapper.find('small');
|
||||
|
||||
expect(dropdown.prop('mode')).toEqual(expectedMode);
|
||||
expect(small.html()).toContain(`Tags will be displayed as <b>${expectedMode}</b>.`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 'cards' as TagsMode ],
|
||||
[ 'list' as TagsMode ],
|
||||
])('invokes setUiSettings when tags mode changes', (tagsMode) => {
|
||||
const wrapper = createWrapper();
|
||||
const dropdown = wrapper.find(TagsModeDropdown);
|
||||
|
||||
expect(setUiSettings).not.toHaveBeenCalled();
|
||||
dropdown.simulate('change', tagsMode);
|
||||
expect(setUiSettings).toHaveBeenCalledWith({ theme: 'light', tagsMode });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { ButtonDropdown, DropdownItem } from 'reactstrap';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
|
||||
import { ReachableServer } from '../../../src/servers/data';
|
||||
import { ShortUrl } from '../../../src/short-urls/data';
|
||||
import { DropdownBtnMenu } from '../../../src/utils/DropdownBtnMenu';
|
||||
|
||||
describe('<ShortUrlsRowMenu />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
|
@ -52,6 +53,6 @@ describe('<ShortUrlsRowMenu />', () => {
|
|||
|
||||
it('DeleteShortUrlModal', () => assert(DeleteShortUrlModal));
|
||||
it('QrCodeModal', () => assert(QrCodeModal));
|
||||
it('ShortUrlRowMenu', () => assert(ButtonDropdown));
|
||||
it('ShortUrlRowMenu', () => assert(DropdownBtnMenu));
|
||||
});
|
||||
});
|
||||
|
|
37
test/tags/TagsCards.test.tsx
Normal file
37
test/tags/TagsCards.test.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
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';
|
||||
|
||||
describe('<TagsCards />', () => {
|
||||
const amountOfTags = 10;
|
||||
const tagsList = Mock.of<TagsList>({ filteredTags: rangeOf(amountOfTags, (i) => `tag_${i}`), stats: {} });
|
||||
const TagCard = () => null;
|
||||
const TagsCards = createTagsCards(TagCard);
|
||||
let wrapper: ShallowWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<TagsCards tagsList={tagsList} selectedServer={Mock.all<SelectedServer>()} />);
|
||||
});
|
||||
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders the proper amount of groups and cards based on the amount of tags', () => {
|
||||
const amountOfGroups = 4;
|
||||
const cards = wrapper.find(TagCard);
|
||||
const groups = wrapper.find('.col-md-6');
|
||||
|
||||
expect(cards).toHaveLength(amountOfTags);
|
||||
expect(groups).toHaveLength(amountOfGroups);
|
||||
});
|
||||
|
||||
it('displays card on toggle', () => {
|
||||
const card = () => wrapper.find(TagCard).at(5);
|
||||
|
||||
expect(card().prop('displayed')).toEqual(false);
|
||||
(card().prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
expect(card().prop('displayed')).toEqual(true);
|
||||
});
|
||||
});
|
|
@ -3,19 +3,20 @@ import { identity } from 'ramda';
|
|||
import { Mock } from 'ts-mockery';
|
||||
import createTagsList, { TagsListProps } from '../../src/tags/TagsList';
|
||||
import Message from '../../src/utils/Message';
|
||||
import SearchField from '../../src/utils/SearchField';
|
||||
import { rangeOf } from '../../src/utils/utils';
|
||||
import { TagsList } from '../../src/tags/reducers/tagsList';
|
||||
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
||||
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';
|
||||
|
||||
describe('<TagsList />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
const filterTags = jest.fn();
|
||||
const TagCard = () => null;
|
||||
const TagsCards = () => null;
|
||||
const TagsTable = () => null;
|
||||
const TagsListComp = createTagsList(TagsCards, TagsTable);
|
||||
const createWrapper = (tagsList: Partial<TagsList>) => {
|
||||
const TagsListComp = createTagsList(TagCard);
|
||||
|
||||
wrapper = shallow(
|
||||
<TagsListComp
|
||||
{...Mock.all<TagsListProps>()}
|
||||
|
@ -23,6 +24,7 @@ describe('<TagsList />', () => {
|
|||
forceListTags={identity}
|
||||
filterTags={filterTags}
|
||||
tagsList={Mock.of<TagsList>(tagsList)}
|
||||
settings={Mock.all<Settings>()}
|
||||
/>,
|
||||
).dive(); // Dive is needed as this component is wrapped in a HOC
|
||||
|
||||
|
@ -56,28 +58,23 @@ describe('<TagsList />', () => {
|
|||
expect(msg.html()).toContain('No tags found');
|
||||
});
|
||||
|
||||
it('renders the proper amount of groups and cards based on the amount of tags', () => {
|
||||
const amountOfTags = 10;
|
||||
const amountOfGroups = 4;
|
||||
const wrapper = createWrapper({ filteredTags: rangeOf(amountOfTags, (i) => `tag_${i}`), stats: {} });
|
||||
const cards = wrapper.find(TagCard);
|
||||
const groups = wrapper.find('.col-md-6');
|
||||
it('renders proper component based on the display mode', () => {
|
||||
const wrapper = createWrapper({ filteredTags: [ 'foo', 'bar' ], stats: {} });
|
||||
|
||||
expect(cards).toHaveLength(amountOfTags);
|
||||
expect(groups).toHaveLength(amountOfGroups);
|
||||
expect(wrapper.find(TagsCards)).toHaveLength(1);
|
||||
expect(wrapper.find(TagsTable)).toHaveLength(0);
|
||||
|
||||
wrapper.find(TagsModeDropdown).simulate('change');
|
||||
|
||||
expect(wrapper.find(TagsCards)).toHaveLength(0);
|
||||
expect(wrapper.find(TagsTable)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('triggers tags filtering when search field changes', (done) => {
|
||||
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();
|
||||
searchField.simulate('change');
|
||||
|
||||
setImmediate(() => {
|
||||
expect(filterTags).toHaveBeenCalledTimes(1);
|
||||
done();
|
||||
});
|
||||
wrapper.find(SearchField).simulate('change');
|
||||
expect(filterTags).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
42
test/tags/TagsModeDropdown.test.tsx
Normal file
42
test/tags/TagsModeDropdown.test.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
|
||||
import { DropdownBtn } from '../../src/utils/DropdownBtn';
|
||||
|
||||
describe('<TagsModeDropdown />', () => {
|
||||
const onChange = jest.fn();
|
||||
let wrapper: ShallowWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<TagsModeDropdown mode="list" onChange={onChange} />);
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders expected items', () => {
|
||||
const btn = wrapper.find(DropdownBtn);
|
||||
const items = wrapper.find(DropdownItem);
|
||||
const icons = wrapper.find(FontAwesomeIcon);
|
||||
|
||||
expect(btn).toHaveLength(1);
|
||||
expect(btn.prop('text')).toEqual('Display mode: list');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(icons).toHaveLength(2);
|
||||
expect(icons.first().prop('icon')).toEqual(cardsIcon);
|
||||
expect(icons.last().prop('icon')).toEqual(listIcon);
|
||||
});
|
||||
|
||||
it('changes active element on click', () => {
|
||||
const items = wrapper.find(DropdownItem);
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
items.first().simulate('click');
|
||||
expect(onChange).toHaveBeenCalledWith('cards');
|
||||
|
||||
items.last().simulate('click');
|
||||
expect(onChange).toHaveBeenCalledWith('list');
|
||||
});
|
||||
});
|
100
test/tags/TagsTable.test.tsx
Normal file
100
test/tags/TagsTable.test.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { Mock } from 'ts-mockery';
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { match } from 'react-router';
|
||||
import { Location, History } from 'history';
|
||||
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
||||
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';
|
||||
|
||||
describe('<TagsTable />', () => {
|
||||
const colorGenerator = Mock.all<ColorGenerator>();
|
||||
const TagsTableRow = () => null;
|
||||
const TagsTable = createTagsTable(colorGenerator, TagsTableRow);
|
||||
const tags = (amount: number) => rangeOf(amount, (i) => `tag_${i}`);
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (filteredTags: string[] = [], search = '') => {
|
||||
wrapper = shallow(
|
||||
<TagsTable
|
||||
tagsList={Mock.of<TagsList>({ stats: {}, filteredTags })}
|
||||
selectedServer={Mock.all<SelectedServer>()}
|
||||
history={Mock.all<History>()}
|
||||
location={Mock.of<Location>({ search })}
|
||||
match={Mock.all<match>()}
|
||||
/>,
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(global as any).location = { search: '', pathname: '' };
|
||||
(global as any).history = { pushState: jest.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders empty result if there are no tags', () => {
|
||||
const wrapper = createWrapper();
|
||||
const regularRows = wrapper.find('tbody').find('tr');
|
||||
const tagRows = wrapper.find(TagsTableRow);
|
||||
|
||||
expect(regularRows).toHaveLength(1);
|
||||
expect(tagRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[ 'foo', 'bar', 'baz' ], 3 ],
|
||||
[[ 'foo' ], 1 ],
|
||||
[ tags(19), 19 ],
|
||||
[ tags(20), 20 ],
|
||||
[ tags(30), 20 ],
|
||||
[ tags(100), 20 ],
|
||||
])('renders as many rows as there are in current page', (filteredTags, expectedRows) => {
|
||||
const wrapper = createWrapper(filteredTags);
|
||||
const tagRows = wrapper.find(TagsTableRow);
|
||||
|
||||
expect(tagRows).toHaveLength(expectedRows);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[ 'foo', 'bar', 'baz' ], 0 ],
|
||||
[[ 'foo' ], 0 ],
|
||||
[ tags(19), 0 ],
|
||||
[ tags(20), 0 ],
|
||||
[ tags(30), 1 ],
|
||||
[ tags(100), 1 ],
|
||||
])('renders paginator if there are more than one page', (filteredTags, expectedPaginators) => {
|
||||
const wrapper = createWrapper(filteredTags);
|
||||
const paginator = wrapper.find(SimplePaginator);
|
||||
|
||||
expect(paginator).toHaveLength(expectedPaginators);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 1, 20, 0 ],
|
||||
[ 2, 20, 20 ],
|
||||
[ 3, 20, 40 ],
|
||||
[ 4, 20, 60 ],
|
||||
[ 5, 7, 80 ],
|
||||
[ 6, 0, 0 ],
|
||||
])('renders page from query if present', (page, expectedRows, offset) => {
|
||||
const wrapper = createWrapper(tags(87), `page=${page}`);
|
||||
const tagRows = wrapper.find(TagsTableRow);
|
||||
|
||||
expect(tagRows).toHaveLength(expectedRows);
|
||||
tagRows.forEach((row, index) => {
|
||||
expect(row.prop('tag')).toEqual(`tag_${index + offset + 1}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('allows changing current page in paginator', () => {
|
||||
const wrapper = createWrapper(tags(100));
|
||||
|
||||
expect(wrapper.find(SimplePaginator).prop('currentPage')).toEqual(1);
|
||||
(wrapper.find(SimplePaginator).prop('setCurrentPage') as Function)(5);
|
||||
expect(wrapper.find(SimplePaginator).prop('currentPage')).toEqual(5);
|
||||
});
|
||||
});
|
78
test/tags/TagsTableRow.test.tsx
Normal file
78
test/tags/TagsTableRow.test.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { TagsTableRow as createTagsTableRow } from '../../src/tags/TagsTableRow';
|
||||
import { ReachableServer } from '../../src/servers/data';
|
||||
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
||||
import { TagStats } from '../../src/tags/data';
|
||||
import { DropdownBtnMenu } from '../../src/utils/DropdownBtnMenu';
|
||||
|
||||
describe('<TagsTableRow />', () => {
|
||||
const DeleteTagConfirmModal = () => null;
|
||||
const EditTagModal = () => null;
|
||||
const TagsTableRow = createTagsTableRow(DeleteTagConfirmModal, EditTagModal);
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (tagStats?: TagStats) => {
|
||||
wrapper = shallow(
|
||||
<TagsTableRow
|
||||
tag="foo&bar"
|
||||
tagStats={tagStats}
|
||||
selectedServer={Mock.of<ReachableServer>({ id: 'abc123' })}
|
||||
colorGenerator={Mock.all<ColorGenerator>()}
|
||||
/>,
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it.each([
|
||||
[ undefined, '0', '0' ],
|
||||
[ Mock.of<TagStats>({ shortUrlsCount: 10, visitsCount: 3480 }), '10', '3,480' ],
|
||||
])('shows expected tag stats', (stats, expectedShortUrls, expectedVisits) => {
|
||||
const wrapper = createWrapper(stats);
|
||||
const links = wrapper.find(Link);
|
||||
const shortUrlsLink = links.first();
|
||||
const visitsLink = links.last();
|
||||
|
||||
expect(shortUrlsLink.prop('children')).toEqual(expectedShortUrls);
|
||||
expect(shortUrlsLink.prop('to')).toEqual(`/server/abc123/list-short-urls/1?tag=${encodeURIComponent('foo&bar')}`);
|
||||
expect(visitsLink.prop('children')).toEqual(expectedVisits);
|
||||
expect(visitsLink.prop('to')).toEqual('/server/abc123/tag/foo&bar/visits');
|
||||
});
|
||||
|
||||
it('allows toggling dropdown menu', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(DropdownBtnMenu).prop('isOpen')).toEqual(false);
|
||||
(wrapper.find(DropdownBtnMenu).prop('toggle') as Function)();
|
||||
expect(wrapper.find(DropdownBtnMenu).prop('isOpen')).toEqual(true);
|
||||
});
|
||||
|
||||
it('allows toggling modals through dropdown items', () => {
|
||||
const wrapper = createWrapper();
|
||||
const items = wrapper.find(DropdownItem);
|
||||
|
||||
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(false);
|
||||
items.first().simulate('click');
|
||||
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(true);
|
||||
|
||||
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(false);
|
||||
items.last().simulate('click');
|
||||
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(true);
|
||||
});
|
||||
|
||||
it('allows toggling modals through the modals themselves', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(false);
|
||||
(wrapper.find(EditTagModal).prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(true);
|
||||
|
||||
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(false);
|
||||
(wrapper.find(DeleteTagConfirmModal).prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(true);
|
||||
});
|
||||
});
|
48
test/utils/DropdownBtnMenu.test.tsx
Normal file
48
test/utils/DropdownBtnMenu.test.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { DropdownBtnMenu, DropdownBtnMenuProps } from '../../src/utils/DropdownBtnMenu';
|
||||
|
||||
describe('<DropdownBtnMenu />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (props: Partial<DropdownBtnMenuProps>) => {
|
||||
wrapper = shallow(<DropdownBtnMenu {...Mock.of<DropdownBtnMenuProps>(props)}>the children</DropdownBtnMenu>);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterAll(() => wrapper?.unmount());
|
||||
|
||||
it('renders expected components', () => {
|
||||
const wrapper = createWrapper({});
|
||||
const toggle = wrapper.find(DropdownToggle);
|
||||
const icon = wrapper.find(FontAwesomeIcon);
|
||||
|
||||
expect(wrapper.find(ButtonDropdown)).toHaveLength(1);
|
||||
expect(toggle).toHaveLength(1);
|
||||
expect(toggle.prop('size')).toEqual('sm');
|
||||
expect(toggle.prop('caret')).toEqual(true);
|
||||
expect(toggle.prop('outline')).toEqual(true);
|
||||
expect(toggle.prop('className')).toEqual('dropdown-btn-menu__dropdown-toggle');
|
||||
expect(icon).toHaveLength(1);
|
||||
expect(icon.prop('icon')).toEqual(menuIcon);
|
||||
});
|
||||
|
||||
it('renders expected children', () => {
|
||||
const menu = createWrapper({}).find(DropdownMenu);
|
||||
|
||||
expect(menu.prop('children')).toEqual('the children');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ undefined, true ],
|
||||
[ true, true ],
|
||||
[ false, false ],
|
||||
])('renders menu to right when expected', (right, expectedRight) => {
|
||||
const wrapper = createWrapper({ right });
|
||||
|
||||
expect(wrapper.find(DropdownMenu).prop('right')).toEqual(expectedRight);
|
||||
});
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils';
|
||||
import { capitalize, determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('determineOrderDir', () => {
|
||||
|
@ -60,4 +60,15 @@ describe('utils', () => {
|
|||
expect(nonEmptyValueOrNull(value)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capitalize', () => {
|
||||
it.each([
|
||||
[ 'foo', 'Foo' ],
|
||||
[ 'BAR', 'BAR' ],
|
||||
[ 'bAZ', 'BAZ' ],
|
||||
[ 'with spaces', 'With spaces' ],
|
||||
])('sets first letter in uppercase', (value, expectedResult) => {
|
||||
expect(capitalize(value)).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue