Migrated remaining tags-related elements to TS

This commit is contained in:
Alejandro Celaya 2020-08-30 20:48:09 +02:00
parent 18883caa6d
commit f8ea1ae3d5
8 changed files with 198 additions and 225 deletions

View file

@ -1,21 +0,0 @@
import PropTypes from 'prop-types';
const regularServerType = PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
version: PropTypes.string,
printableVersion: PropTypes.string,
serverNotReachable: PropTypes.bool,
});
const notFoundServerType = PropTypes.shape({
serverNotFound: PropTypes.bool.isRequired,
});
/** @deprecated Use SelectedServer type instead */
export const serverType = PropTypes.oneOfType([
regularServerType,
notFoundServerType,
]);

View file

@ -1,84 +0,0 @@
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';
import { serverType } from '../servers/prop-types';
import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks';
import TagBullet from './helpers/TagBullet';
import './TagCard.scss';
const propTypes = {
tag: PropTypes.string,
tagStats: PropTypes.shape({
shortUrlsCount: PropTypes.number,
visitsCount: PropTypes.number,
}),
selectedServer: serverType,
displayed: PropTypes.bool,
toggle: PropTypes.func,
};
const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGenerator) => {
const TagCardComp = ({ tag, tagStats, selectedServer, displayed, toggle }) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
const { id } = selectedServer;
const shortUrlsLink = `/server/${id}/list-short-urls/1?tag=${tag}`;
return (
<Card className="tag-card">
<CardHeader className="tag-card__header">
<Button color="light" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} />
</Button>
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</Button>
<h5 className="tag-card__tag-title text-ellipsis">
<TagBullet tag={tag} colorGenerator={colorGenerator} />
<ForServerVersion minVersion="2.2.0">
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
</ForServerVersion>
<ForServerVersion maxVersion="2.1.*">
<Link to={shortUrlsLink}>{tag}</Link>
</ForServerVersion>
</h5>
</CardHeader>
{tagStats && (
<Collapse isOpen={displayed}>
<CardBody className="tag-card__body">
<Link
to={shortUrlsLink}
className="btn btn-light btn-block d-flex justify-content-between align-items-center mb-1"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
<b>{prettify(tagStats.shortUrlsCount)}</b>
</Link>
<Link
to={`/server/${id}/tag/${tag}/visits`}
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
<b>{prettify(tagStats.visitsCount)}</b>
</Link>
</CardBody>
</Collapse>
)}
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
</Card>
);
};
TagCardComp.propTypes = propTypes;
return TagCardComp;
};
export default TagCard;

82
src/tags/TagCard.tsx Normal file
View file

@ -0,0 +1,82 @@
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import { prettify } from '../utils/helpers/numbers';
import { useToggle } from '../utils/helpers/hooks';
import { Versions } from '../utils/helpers/version';
import ColorGenerator from '../utils/services/ColorGenerator';
import { isServerWithId, SelectedServer } from '../servers/data';
import TagBullet from './helpers/TagBullet';
import { TagModalProps, TagStats } from './data';
import './TagCard.scss';
export interface TagCardProps {
tag: string;
tagStats?: TagStats;
selectedServer: SelectedServer;
displayed: boolean;
toggle: () => void;
}
const TagCard = (
DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>,
ForServerVersion: FC<Versions>,
colorGenerator: ColorGenerator,
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${tag}`;
return (
<Card className="tag-card">
<CardHeader className="tag-card__header">
<Button color="light" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} />
</Button>
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</Button>
<h5 className="tag-card__tag-title text-ellipsis">
<TagBullet tag={tag} colorGenerator={colorGenerator} />
<ForServerVersion minVersion="2.2.0">
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
</ForServerVersion>
<ForServerVersion maxVersion="2.1.*">
<Link to={shortUrlsLink}>{tag}</Link>
</ForServerVersion>
</h5>
</CardHeader>
{tagStats && (
<Collapse isOpen={displayed}>
<CardBody className="tag-card__body">
<Link
to={shortUrlsLink}
className="btn btn-light btn-block d-flex justify-content-between align-items-center mb-1"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
<b>{prettify(tagStats.shortUrlsCount)}</b>
</Link>
<Link
to={`/server/${serverId}/tag/${tag}/visits`}
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
<b>{prettify(tagStats.visitsCount)}</b>
</Link>
</CardBody>
</Collapse>
)}
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
</Card>
);
};
export default TagCard;

View file

@ -1,91 +0,0 @@
import React, { useEffect, useState } from 'react';
import { splitEvery } from 'ramda';
import PropTypes from 'prop-types';
import Message from '../utils/Message';
import SearchField from '../utils/SearchField';
import { serverType } from '../servers/prop-types';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { useMercureTopicBinding } from '../mercure/helpers';
import { TagsListType } from './reducers/tagsList';
const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4;
const propTypes = {
filterTags: PropTypes.func,
forceListTags: PropTypes.func,
tagsList: TagsListType,
selectedServer: serverType,
createNewVisit: PropTypes.func,
loadMercureInfo: PropTypes.func,
mercureInfo: MercureInfoType,
};
const TagsList = (TagCard) => {
const TagListComp = (
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo },
) => {
const [ displayedTag, setDisplayedTag ] = useState();
useEffect(() => {
forceListTags();
}, []);
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
const renderContent = () => {
if (tagsList.loading) {
return <Message noMargin loading />;
}
if (tagsList.error) {
return (
<div className="col-12">
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
</div>
);
}
const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) {
return <Message>No tags found</Message>;
}
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
return (
<React.Fragment>
{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>
))}
</React.Fragment>
);
};
return (
<React.Fragment>
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
<div className="row">
{renderContent()}
</div>
</React.Fragment>
);
};
TagListComp.propTypes = propTypes;
return TagListComp;
};
export default TagsList;

85
src/tags/TagsList.tsx Normal file
View file

@ -0,0 +1,85 @@
import React, { FC, useEffect, useState } from 'react';
import { splitEvery } from 'ramda';
import Message from '../utils/Message';
import SearchField from '../utils/SearchField';
import { MercureInfo } from '../mercure/reducers/mercureInfo';
import { useMercureTopicBinding } from '../mercure/helpers';
import { SelectedServer } from '../servers/data';
import { TagsList as TagsListState } from './reducers/tagsList';
import { TagCardProps } from './TagCard';
const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4;
export interface TagsListProps {
filterTags: (searchTerm: string) => void;
forceListTags: Function;
tagsList: TagsListState;
selectedServer: SelectedServer;
createNewVisit: () => void;
loadMercureInfo: Function;
mercureInfo: MercureInfo;
}
const TagsList = (TagCard: FC<TagCardProps>) => (
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo }: TagsListProps,
) => {
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
useEffect(() => {
forceListTags();
}, []);
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
const renderContent = () => {
if (tagsList.loading) {
return <Message noMargin loading />;
}
if (tagsList.error) {
return (
<div className="col-12">
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
</div>
);
}
const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) {
return <Message>No tags found</Message>;
}
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
return (
<React.Fragment>
{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>
))}
</React.Fragment>
);
};
return (
<React.Fragment>
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
<div className="row">
{renderContent()}
</div>
</React.Fragment>
);
};
export default TagsList;

View file

@ -1,5 +1,4 @@
import { isEmpty, reject } from 'ramda'; import { isEmpty, reject } from 'ramda';
import PropTypes from 'prop-types';
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { CREATE_VISIT, CreateVisitAction } from '../../visits/reducers/visitCreation'; import { CREATE_VISIT, CreateVisitAction } from '../../visits/reducers/visitCreation';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
@ -17,18 +16,6 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
/** @deprecated Use TagsList interface instead */
export const TagsListType = PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
filteredTags: PropTypes.arrayOf(PropTypes.string),
stats: PropTypes.objectOf(PropTypes.shape({
shortUrlsCount: PropTypes.number,
visitsCount: PropTypes.number,
})), // Record
loading: PropTypes.bool,
error: PropTypes.bool,
});
type TagsStatsMap = Record<string, TagStats>; type TagsStatsMap = Record<string, TagStats>;
export interface TagsList { export interface TagsList {

View file

@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Mock } from 'ts-mockery';
import createTagCard from '../../src/tags/TagCard'; import createTagCard from '../../src/tags/TagCard';
import TagBullet from '../../src/tags/helpers/TagBullet'; import TagBullet from '../../src/tags/helpers/TagBullet';
import ColorGenerator from '../../src/utils/services/ColorGenerator';
import { ReachableServer } from '../../src/servers/data';
describe('<TagCard />', () => { describe('<TagCard />', () => {
let wrapper; let wrapper: ShallowWrapper;
const tagStats = { const tagStats = {
shortUrlsCount: 48, shortUrlsCount: 48,
visitsCount: 23257, visitsCount: 23257,
@ -14,9 +17,17 @@ describe('<TagCard />', () => {
const EditTagModal = jest.fn(); const EditTagModal = jest.fn();
beforeEach(() => { beforeEach(() => {
const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, () => '', {}); const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, () => null, Mock.all<ColorGenerator>());
wrapper = shallow(<TagCard tag="ssr" selectedServer={{ id: 1, serverNotFound: false }} tagStats={tagStats} />); wrapper = shallow(
<TagCard
tag="ssr"
selectedServer={Mock.of<ReachableServer>({ id: '1' })}
tagStats={tagStats}
displayed={true}
toggle={() => {}}
/>,
);
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -1,30 +1,34 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { identity } from 'ramda'; import { identity } from 'ramda';
import createTagsList from '../../src/tags/TagsList'; import { Mock } from 'ts-mockery';
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 SearchField from '../../src/utils/SearchField';
import { rangeOf } from '../../src/utils/utils'; import { rangeOf } from '../../src/utils/utils';
import { TagsList } from '../../src/tags/reducers/tagsList';
describe('<TagsList />', () => { describe('<TagsList />', () => {
let wrapper; let wrapper: ShallowWrapper;
const filterTags = jest.fn(); const filterTags = jest.fn();
const TagCard = () => ''; const TagCard = () => null;
const createWrapper = (tagsList) => { const createWrapper = (tagsList: Partial<TagsList>) => {
const params = { serverId: '1' }; const TagsListComp = createTagsList(TagCard);
const TagsList = createTagsList(TagCard);
wrapper = shallow( wrapper = shallow(
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />, <TagsListComp
{...Mock.all<TagsListProps>()}
forceListTags={identity}
filterTags={filterTags}
tagsList={Mock.of<TagsList>(tagsList)}
/>,
); );
return wrapper; return wrapper;
}; };
afterEach(() => { afterEach(() => wrapper?.unmount());
wrapper && wrapper.unmount(); afterEach(jest.clearAllMocks);
filterTags.mockReset();
});
it('shows a loading message when tags are being loaded', () => { it('shows a loading message when tags are being loaded', () => {
const wrapper = createWrapper({ loading: true }); const wrapper = createWrapper({ loading: true });