Updated tags list to display visits and short URLs when remote shlink version allows it

This commit is contained in:
Alejandro Celaya 2020-05-10 10:57:49 +02:00
parent 8741f42fe8
commit 18e026e4ca
13 changed files with 143 additions and 46 deletions

View file

@ -44,11 +44,13 @@ body,
cursor: pointer;
}
.paddingless {
padding: 0;
.indivisible {
white-space: nowrap;
}
.indivisible {
.text-ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}

View file

@ -1,22 +1,38 @@
import { Card, CardBody } from 'reactstrap';
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
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 TagBullet from './helpers/TagBullet';
import './TagCard.scss';
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
const TagCard = (
DeleteTagConfirmModal,
EditTagModal,
ForServerVersion,
colorGenerator
) => class TagCard extends React.Component {
static propTypes = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
tagStats: PropTypes.shape({
shortUrlsCount: PropTypes.number,
visitsCount: PropTypes.number,
}),
selectedServer: serverType,
displayed: PropTypes.bool,
toggle: PropTypes.func,
};
state = { isDeleteModalOpen: false, isEditModalOpen: false };
render() {
const { tag, currentServerId } = this.props;
const { tag, tagStats, selectedServer, displayed, toggle } = this.props;
const { id } = selectedServer;
const shortUrlsLink = `/server/${id}/list-short-urls/1?tag=${tag}`;
const toggleDelete = () =>
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
const toggleEdit = () =>
@ -24,18 +40,44 @@ const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class T
return (
<Card className="tag-card">
<CardBody className="tag-card__body">
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<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 className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
</Button>
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} />
</button>
<h5 className="tag-card__tag-title">
</Button>
<h5 className="tag-card__tag-title text-ellipsis">
<TagBullet tag={tag} colorGenerator={colorGenerator} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
<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>
</CardBody>
</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}/tags/${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={this.state.isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />

View file

@ -1,8 +1,12 @@
.tag-card.tag-card {
background-color: #eee;
margin-bottom: .5rem;
}
.tag-card__header.tag-card__header {
background-color: #eee;
}
.tag-card__header.tag-card__header,
.tag-card__body.tag-card__body {
padding: .75rem;
}
@ -10,9 +14,6 @@
.tag-card__tag-title {
margin: 0;
line-height: 31px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-right: 5px;
}
@ -23,3 +24,17 @@
.tag-card__btn--last {
margin-left: 3px;
}
.tag-card__table-cell.tag-card__table-cell {
border: none;
}
.tag-card__tag-name {
color: #007bff;
cursor: pointer;
}
.tag-card__tag-name:hover {
color: #0056b3;
text-decoration: underline;
}

View file

@ -1,8 +1,10 @@
import React, { useEffect } from 'react';
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 { TagsListType } from './reducers/tagsList';
const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4;
@ -10,16 +12,14 @@ const TAGS_GROUPS_AMOUNT = 4;
const propTypes = {
filterTags: PropTypes.func,
forceListTags: PropTypes.func,
tagsList: PropTypes.shape({
loading: PropTypes.bool,
error: PropTypes.bool,
filteredTags: PropTypes.arrayOf(PropTypes.string),
}),
match: PropTypes.object,
tagsList: TagsListType,
selectedServer: serverType,
};
const TagsList = (TagCard) => {
const TagListComp = ({ filterTags, forceListTags, tagsList, match }) => {
const TagListComp = ({ filterTags, forceListTags, tagsList, selectedServer }) => {
const [ displayedTag, setDisplayedTag ] = useState();
useEffect(() => {
forceListTags();
}, []);
@ -53,7 +53,10 @@ const TagsList = (TagCard) => {
<TagCard
key={tag}
tag={tag}
currentServerId={match.params.serverId}
tagStats={tagsList.stats[tag]}
selectedServer={selectedServer}
displayed={displayedTag === tag}
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
/>
))}
</div>

View file

@ -1,5 +1,6 @@
import { handleActions } from 'redux-actions';
import { isEmpty, reject } from 'ramda';
import PropTypes from 'prop-types';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
@ -10,9 +11,18 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements */
export const TagsListType = PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
filteredTags: PropTypes.arrayOf(PropTypes.string),
stats: PropTypes.object, // Record
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = {
tags: [],
filteredTags: [],
stats: {},
loading: false,
error: false,
};
@ -23,7 +33,7 @@ const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, ta
export default handleActions({
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[LIST_TAGS]: (state, { tags }) => ({ tags, filteredTags: tags, loading: false, error: false }),
[LIST_TAGS]: (state, { tags, stats }) => ({ stats, tags, filteredTags: tags, loading: false, error: false }),
[TAG_DELETED]: (state, { tag }) => ({
...state,
tags: rejectTag(state.tags, tag),
@ -51,9 +61,14 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
try {
const { listTags } = buildShlinkApiClient(getState);
const tags = await listTags();
const { stats = [], data } = await listTags();
const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => {
acc[tag] = { shortUrlsCount, visitsCount };
dispatch({ tags, type: LIST_TAGS });
return acc;
}, {});
dispatch({ tags: data, stats: processedStats, type: LIST_TAGS });
} catch (e) {
dispatch({ type: LIST_TAGS_ERROR });
}

View file

@ -12,7 +12,14 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory(
'TagCard',
TagCard,
'DeleteTagConfirmModal',
'EditTagModal',
'ForServerVersion',
'ColorGenerator'
);
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
@ -21,7 +28,7 @@ const provideServices = (bottle, connect) => {
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
bottle.decorator('TagsList', connect([ 'tagsList', 'selectedServer' ], [ 'forceListTags', 'filterTags' ]));
// Actions
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);

View file

@ -33,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
<DropdownToggle
caret
color={isButton ? 'secondary' : 'link'}
className={classNames({ 'btn-block': isButton, 'btn-sm paddingless': !isButton })}
className={classNames({ 'btn-block': isButton, 'btn-sm p-0': !isButton })}
>
Order by
</DropdownToggle>

View file

@ -53,8 +53,8 @@ export default class ShlinkApiClient {
.then(() => meta);
listTags = () =>
this._performRequest('/tags', 'GET')
.then((resp) => resp.data.tags.data);
this._performRequest('/tags', 'GET', { withStats: 'true' })
.then((resp) => resp.data.tags);
deleteTags = (tags) =>
this._performRequest('/tags', 'DELETE', { tags })

View file

@ -122,7 +122,7 @@ export default class SortableBarGraph extends React.Component {
{withPagination && keys(stats).length > 50 && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm paddingless mr-3"
toggleClassName="btn-sm p-0 mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={this.state.itemsPerPage}
setValue={(itemsPerPage) => this.setState({ itemsPerPage, currentPage: 1 })}

View file

@ -6,19 +6,23 @@ import TagBullet from '../../src/tags/helpers/TagBullet';
describe('<TagCard />', () => {
let wrapper;
const tagStats = {
shortUrlsCount: 48,
visitsCount: 23257,
};
beforeEach(() => {
const TagCard = createTagCard(() => '', () => '', {});
const TagCard = createTagCard(() => '', () => '', () => '', {});
wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />);
wrapper = shallow(<TagCard tag="ssr" selectedServer={{ id: 1, serverNotFound: false }} tagStats={tagStats} />);
});
afterEach(() => wrapper.unmount());
it('shows a TagBullet and a link to the list filtering by the tag', () => {
const link = wrapper.find(Link);
const links = wrapper.find(Link);
const bullet = wrapper.find(TagBullet);
expect(link.prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
expect(links.at(0).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
expect(bullet.prop('tag')).toEqual('ssr');
});
@ -45,4 +49,13 @@ describe('<TagCard />', () => {
done();
});
});
it('shows expected tag stats', () => {
const links = wrapper.find(Link);
expect(links.at(1).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
expect(links.at(1).text()).toContain('48');
expect(links.at(2).prop('to')).toEqual('/server/1/tags/ssr/visits');
expect(links.at(2).text()).toContain('23,257');
});
});

View file

@ -53,7 +53,7 @@ describe('<TagsList />', () => {
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}`) });
const wrapper = createWrapper({ filteredTags: rangeOf(amountOfTags, (i) => `tag_${i}`), stats: {} });
const cards = wrapper.find(TagCard);
const groups = wrapper.find('.col-md-6');

View file

@ -103,7 +103,7 @@ describe('tagsListReducer', () => {
it('dispatches loaded lists when no error occurs', async () => {
const tags = [ 'foo', 'bar', 'baz' ];
listTagsMock.mockResolvedValue(tags);
listTagsMock.mockResolvedValue({ data: tags, stats: [] });
buildShlinkApiClient.mockReturnValue({ listTags: listTagsMock });
await listTags(buildShlinkApiClient, true)()(dispatch, getState);
@ -112,7 +112,7 @@ describe('tagsListReducer', () => {
expect(getState).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_TAGS, tags });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_TAGS, tags, stats: {} });
});
const assertErrorResult = async () => {

View file

@ -141,7 +141,7 @@ describe('ShlinkApiClient', () => {
const result = await listTags();
expect(expectedTags).toEqual(result);
expect({ data: expectedTags }).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ url: '/tags', method: 'GET' }));
});
});