mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-12 10:33:49 +00:00
Merge pull request #493 from acelaya-forks/feature/tags-list
Feature/tags list
This commit is contained in:
@@ -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.
|
* [#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.
|
* [#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
|
### Changed
|
||||||
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
* [#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}>
|
<aside className={asideClass}>
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
<AsideMenuItem to={buildPath('/overview')}>
|
<AsideMenuItem to={buildPath('/overview')}>
|
||||||
<FontAwesomeIcon icon={overviewIcon} />
|
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||||
<span className="aside-menu__item-text">Overview</span>
|
<span className="aside-menu__item-text">Overview</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
<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>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
<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>
|
<span className="aside-menu__item-text">Create short URL</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
<span className="aside-menu__item-text">Manage tags</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
{addManageDomainsLink && (
|
{addManageDomainsLink && (
|
||||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||||
<FontAwesomeIcon icon={domainsIcon} />
|
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage domains</span>
|
<span className="aside-menu__item-text">Manage domains</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
)}
|
)}
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
<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>
|
<span className="aside-menu__item-text">Edit this server</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
{isServerWithId(selectedServer) && (
|
{isServerWithId(selectedServer) && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@import './common/react-tag-autocomplete.scss';
|
@import './common/react-tag-autocomplete.scss';
|
||||||
@import './theme/theme';
|
@import './theme/theme';
|
||||||
@import './utils/table/ResponsiveTable';
|
@import './utils/table/ResponsiveTable';
|
||||||
|
@import './utils/StickyCardPaginator';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className={className} onClick={showModal}>
|
<span className={className} onClick={showModal}>
|
||||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ export const isReachableServer = (server: SelectedServer): server is ReachableSe
|
|||||||
|
|
||||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||||
!!server?.hasOwnProperty('serverNotFound');
|
!!server?.hasOwnProperty('serverNotFound');
|
||||||
|
|
||||||
|
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||||
|
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||||
|
import { capitalize } from '../utils/utils';
|
||||||
import { Settings, UiSettings } from './reducers/settings';
|
import { Settings, UiSettings } from './reducers/settings';
|
||||||
import './UserInterface.scss';
|
import './UserInterface.scss';
|
||||||
|
|
||||||
@@ -14,17 +17,28 @@ interface UserInterfaceProps {
|
|||||||
|
|
||||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||||
<SimpleCard title="User interface" className="h-100">
|
<SimpleCard title="User interface" className="h-100">
|
||||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
<FormGroup>
|
||||||
<ToggleSwitch
|
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||||
checked={ui?.theme === 'dark'}
|
<ToggleSwitch
|
||||||
onChange={(useDarkTheme) => {
|
checked={ui?.theme === 'dark'}
|
||||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
onChange={(useDarkTheme) => {
|
||||||
|
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||||
|
|
||||||
setUiSettings({ theme });
|
setUiSettings({ ...ui, theme });
|
||||||
changeThemeInMarkup(theme);
|
changeThemeInMarkup(theme);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use dark theme.
|
Use dark theme.
|
||||||
</ToggleSwitch>
|
</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>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ export interface ShortUrlCreationSettings {
|
|||||||
tagFilteringMode?: TagFilteringMode;
|
tagFilteringMode?: TagFilteringMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TagsMode = 'cards' | 'list';
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
tagsMode?: TagsMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsSettings {
|
export interface VisitsSettings {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||||
import { ShlinkPaginator } from '../api/types';
|
import { ShlinkPaginator } from '../api/types';
|
||||||
import './Paginator.scss';
|
|
||||||
|
|
||||||
interface PaginatorProps {
|
interface PaginatorProps {
|
||||||
paginator?: ShlinkPaginator;
|
paginator?: ShlinkPaginator;
|
||||||
@@ -33,7 +32,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
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}>
|
<PaginationItem disabled={currentPage === 1}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
previous
|
previous
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
@import '../../utils/base';
|
@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 {
|
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
||||||
color: $dangerColor;
|
color: $dangerColor;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
faChartPie as pieChartIcon,
|
faChartPie as pieChartIcon,
|
||||||
faEllipsisV as menuIcon,
|
|
||||||
faQrcode as qrIcon,
|
faQrcode as qrIcon,
|
||||||
faMinusCircle as deleteIcon,
|
faMinusCircle as deleteIcon,
|
||||||
faEdit as editIcon,
|
faEdit as editIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { ShortUrl, ShortUrlModalProps } from '../data';
|
import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||||
import './ShortUrlsRowMenu.scss';
|
import './ShortUrlsRowMenu.scss';
|
||||||
|
|
||||||
@@ -29,32 +29,27 @@ const ShortUrlsRowMenu = (
|
|||||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||||
</DropdownToggle>
|
</DropdownItem>
|
||||||
<DropdownMenu right>
|
|
||||||
<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">
|
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem onClick={toggleQrCode}>
|
<DropdownItem onClick={toggleQrCode}>
|
||||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||||
</DropdownMenu>
|
</DropdownBtnMenu>
|
||||||
</ButtonDropdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 { FC, useEffect, useState } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
|
||||||
import { Row } from 'reactstrap';
|
import { Row } from 'reactstrap';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
@@ -8,23 +7,23 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
|||||||
import { Result } from '../utils/Result';
|
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 { TagsList as TagsListState } from './reducers/tagsList';
|
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
import { TagCardProps } from './TagCard';
|
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||||
|
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||||
const { ceil } = Math;
|
|
||||||
const TAGS_GROUPS_AMOUNT = 4;
|
|
||||||
|
|
||||||
export interface TagsListProps {
|
export interface TagsListProps {
|
||||||
filterTags: (searchTerm: string) => void;
|
filterTags: (searchTerm: string) => void;
|
||||||
forceListTags: Function;
|
forceListTags: Function;
|
||||||
tagsList: TagsListState;
|
tagsList: TagsListState;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
|
||||||
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
|
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||||
) => {
|
) => {
|
||||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
forceListTags();
|
forceListTags();
|
||||||
@@ -43,37 +42,23 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsCount = tagsList.filteredTags.length;
|
if (tagsList.filteredTags.length < 1) {
|
||||||
|
|
||||||
if (tagsCount < 1) {
|
|
||||||
return <Message>No tags found</Message>;
|
return <Message>No tags found</Message>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
return mode === 'cards'
|
||||||
|
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
|
||||||
return (
|
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
|
||||||
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchField className="mb-3" onChange={filterTags} />
|
<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()}
|
{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 Bottle, { IContainer } from 'bottlejs';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import TagsSelector from '../helpers/TagsSelector';
|
import TagsSelector from '../helpers/TagsSelector';
|
||||||
import TagCard from '../TagCard';
|
import TagCard from '../TagCard';
|
||||||
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
||||||
@@ -8,6 +9,9 @@ import { filterTags, listTags } from '../reducers/tagsList';
|
|||||||
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
||||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
import { TagsCards } from '../TagsCards';
|
||||||
|
import { TagsTable } from '../TagsTable';
|
||||||
|
import { TagsTableRow } from '../TagsTableRow';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
@@ -29,9 +33,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
||||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
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(
|
bottle.decorator('TagsList', connect(
|
||||||
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
|
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
|
||||||
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
|
[ '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;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--primary-color-alfa);
|
background-color: var(--primary-color-alfa);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
||||||
|
import { parseQuery, stringifyQuery } from './query';
|
||||||
|
|
||||||
const DEFAULT_DELAY = 2000;
|
const DEFAULT_DELAY = 2000;
|
||||||
|
|
||||||
@@ -51,3 +52,17 @@ export const useSwipeable = (showSidebar: () => void, hideSidebar: () => void) =
|
|||||||
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
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 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 { Mock } from 'ts-mockery';
|
||||||
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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 { UserInterface } from '../../src/settings/UserInterface';
|
||||||
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
||||||
import { Theme } from '../../src/utils/theme';
|
import { Theme } from '../../src/utils/theme';
|
||||||
|
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
|
||||||
|
|
||||||
describe('<UserInterface />', () => {
|
describe('<UserInterface />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const setUiSettings = jest.fn();
|
const setUiSettings = jest.fn();
|
||||||
const createWrapper = (ui?: UiSettings) => {
|
const createWrapper = (ui?: UiSettings) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(<UserInterface settings={Mock.of<Settings>({ ui })} setUiSettings={setUiSettings} />);
|
||||||
<UserInterface
|
|
||||||
settings={Mock.of<Settings>({ ui })}
|
|
||||||
setUiSettings={setUiSettings}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
};
|
};
|
||||||
@@ -49,7 +45,7 @@ describe('<UserInterface />', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
[ true, 'dark' ],
|
[ true, 'dark' ],
|
||||||
[ false, 'light' ],
|
[ false, 'light' ],
|
||||||
])('invokes setUiSettings when toggle value changes', (checked, theme) => {
|
])('invokes setUiSettings when theme toggle value changes', (checked, theme) => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
const toggle = wrapper.find(ToggleSwitch);
|
const toggle = wrapper.find(ToggleSwitch);
|
||||||
|
|
||||||
@@ -57,4 +53,30 @@ describe('<UserInterface />', () => {
|
|||||||
toggle.simulate('change', checked);
|
toggle.simulate('change', checked);
|
||||||
expect(setUiSettings).toHaveBeenCalledWith({ theme });
|
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 { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { ButtonDropdown, DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
|
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
|
||||||
import { ReachableServer } from '../../../src/servers/data';
|
import { ReachableServer } from '../../../src/servers/data';
|
||||||
import { ShortUrl } from '../../../src/short-urls/data';
|
import { ShortUrl } from '../../../src/short-urls/data';
|
||||||
|
import { DropdownBtnMenu } from '../../../src/utils/DropdownBtnMenu';
|
||||||
|
|
||||||
describe('<ShortUrlsRowMenu />', () => {
|
describe('<ShortUrlsRowMenu />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
@@ -52,6 +53,6 @@ describe('<ShortUrlsRowMenu />', () => {
|
|||||||
|
|
||||||
it('DeleteShortUrlModal', () => assert(DeleteShortUrlModal));
|
it('DeleteShortUrlModal', () => assert(DeleteShortUrlModal));
|
||||||
it('QrCodeModal', () => assert(QrCodeModal));
|
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 { Mock } from 'ts-mockery';
|
||||||
import createTagsList, { TagsListProps } from '../../src/tags/TagsList';
|
import createTagsList, { TagsListProps } from '../../src/tags/TagsList';
|
||||||
import Message from '../../src/utils/Message';
|
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 { TagsList } from '../../src/tags/reducers/tagsList';
|
||||||
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
||||||
import { Result } from '../../src/utils/Result';
|
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 />', () => {
|
describe('<TagsList />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const filterTags = jest.fn();
|
const filterTags = jest.fn();
|
||||||
const TagCard = () => null;
|
const TagsCards = () => null;
|
||||||
|
const TagsTable = () => null;
|
||||||
|
const TagsListComp = createTagsList(TagsCards, TagsTable);
|
||||||
const createWrapper = (tagsList: Partial<TagsList>) => {
|
const createWrapper = (tagsList: Partial<TagsList>) => {
|
||||||
const TagsListComp = createTagsList(TagCard);
|
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<TagsListComp
|
<TagsListComp
|
||||||
{...Mock.all<TagsListProps>()}
|
{...Mock.all<TagsListProps>()}
|
||||||
@@ -23,6 +24,7 @@ describe('<TagsList />', () => {
|
|||||||
forceListTags={identity}
|
forceListTags={identity}
|
||||||
filterTags={filterTags}
|
filterTags={filterTags}
|
||||||
tagsList={Mock.of<TagsList>(tagsList)}
|
tagsList={Mock.of<TagsList>(tagsList)}
|
||||||
|
settings={Mock.all<Settings>()}
|
||||||
/>,
|
/>,
|
||||||
).dive(); // Dive is needed as this component is wrapped in a HOC
|
).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');
|
expect(msg.html()).toContain('No tags found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the proper amount of groups and cards based on the amount of tags', () => {
|
it('renders proper component based on the display mode', () => {
|
||||||
const amountOfTags = 10;
|
const wrapper = createWrapper({ filteredTags: [ 'foo', 'bar' ], stats: {} });
|
||||||
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');
|
|
||||||
|
|
||||||
expect(cards).toHaveLength(amountOfTags);
|
expect(wrapper.find(TagsCards)).toHaveLength(1);
|
||||||
expect(groups).toHaveLength(amountOfGroups);
|
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 wrapper = createWrapper({ filteredTags: [] });
|
||||||
const searchField = wrapper.find(SearchField);
|
|
||||||
|
|
||||||
expect(searchField).toHaveLength(1);
|
|
||||||
expect(filterTags).not.toHaveBeenCalled();
|
expect(filterTags).not.toHaveBeenCalled();
|
||||||
searchField.simulate('change');
|
wrapper.find(SearchField).simulate('change');
|
||||||
|
expect(filterTags).toHaveBeenCalledTimes(1);
|
||||||
setImmediate(() => {
|
|
||||||
expect(filterTags).toHaveBeenCalledTimes(1);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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('utils', () => {
|
||||||
describe('determineOrderDir', () => {
|
describe('determineOrderDir', () => {
|
||||||
@@ -60,4 +60,15 @@ describe('utils', () => {
|
|||||||
expect(nonEmptyValueOrNull(value)).toEqual(expected);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user