Merge pull request #493 from acelaya-forks/feature/tags-list

Feature/tags list
This commit is contained in:
Alejandro Celaya 2021-09-25 11:03:13 +02:00 committed by GitHub
commit 7b0cda7191
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 688 additions and 119 deletions

View file

@ -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

View file

@ -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) && (

View file

@ -5,6 +5,7 @@
@import './common/react-tag-autocomplete.scss';
@import './theme/theme';
@import './utils/table/ResponsiveTable';
@import './utils/StickyCardPaginator';
* {
outline: none !important;

View file

@ -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>

View file

@ -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 : '';

View file

@ -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>
);

View file

@ -24,8 +24,11 @@ export interface ShortUrlCreationSettings {
tagFilteringMode?: TagFilteringMode;
}
export type TagsMode = 'cards' | 'list';
export interface UiSettings {
theme: Theme;
tagsMode?: TagsMode;
}
export interface VisitsSettings {

View file

@ -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

View file

@ -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;

View file

@ -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">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</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
View 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>
);
};

View file

@ -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()}
</>
);

View 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
View 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
View 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>
);
};

View file

@ -0,0 +1,7 @@
import { TagsList as TagsListState } from '../reducers/tagsList';
import { SelectedServer } from '../../servers/data';
export interface TagsListChildrenProps {
tagsList: TagsListState;
selectedServer: SelectedServer;
}

View file

@ -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' ],
));

View file

@ -0,0 +1,3 @@
.dropdown-btn-menu__dropdown-toggle:after {
display: none !important;
}

View 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">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right={right}>{children}</DropdownMenu>
</ButtonDropdown>
);

View file

@ -1,4 +1,4 @@
.short-urls-paginator {
.sticky-card-paginator {
position: sticky;
bottom: 0;
background-color: var(--primary-color-alfa);

View file

@ -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 ];
};

View file

@ -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)}`;

View file

@ -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 });
});
});

View file

@ -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));
});
});

View 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);
});
});

View file

@ -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);
});
});

View 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');
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View file

@ -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);
});
});
});