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

Feature/tags list improvements
This commit is contained in:
Alejandro Celaya 2020-05-10 11:34:48 +02:00 committed by GitHub
commit 6eead70511
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 164 additions and 71 deletions

View file

@ -17,6 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
* [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag.
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
#### Changed
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.

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,47 +1,84 @@
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 { useToggle } from '../utils/helpers/hooks';
import TagBullet from './helpers/TagBullet';
import './TagCard.scss';
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
static propTypes = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
};
const propTypes = {
tag: PropTypes.string,
tagStats: PropTypes.shape({
shortUrlsCount: PropTypes.number,
visitsCount: PropTypes.number,
}),
selectedServer: serverType,
displayed: PropTypes.bool,
toggle: PropTypes.func,
};
state = { isDeleteModalOpen: false, isEditModalOpen: false };
const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGenerator) => {
const TagCardComp = ({ tag, tagStats, selectedServer, displayed, toggle }) => {
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
render() {
const { tag, currentServerId } = this.props;
const toggleDelete = () =>
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
const toggleEdit = () =>
this.setState(({ isEditModalOpen }) => ({ isEditModalOpen: !isEditModalOpen }));
const { id } = selectedServer;
const shortUrlsLink = `/server/${id}/list-short-urls/1?tag=${tag}`;
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>
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
{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={isDeleteModalOpen} />
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
</Card>
);
}
};
TagCardComp.propTypes = propTypes;
return TagCardComp;
};
export default TagCard;

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 { tags, stats = [] } = await listTags();
const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => {
acc[tag] = { shortUrlsCount, visitsCount };
dispatch({ tags, type: LIST_TAGS });
return acc;
}, {});
dispatch({ tags, 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,9 @@ 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)
.then(({ data, stats }) => ({ tags: data, stats }));
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,43 +6,52 @@ import TagBullet from '../../src/tags/helpers/TagBullet';
describe('<TagCard />', () => {
let wrapper;
const tagStats = {
shortUrlsCount: 48,
visitsCount: 23257,
};
const DeleteTagConfirmModal = jest.fn();
const EditTagModal = jest.fn();
beforeEach(() => {
const TagCard = createTagCard(() => '', () => '', {});
const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, () => '', {});
wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />);
wrapper = shallow(<TagCard tag="ssr" selectedServer={{ id: 1, serverNotFound: false }} tagStats={tagStats} />);
});
afterEach(() => wrapper.unmount());
afterEach(jest.resetAllMocks);
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');
});
it('displays delete modal when delete btn is clicked', (done) => {
it('displays delete modal when delete btn is clicked', () => {
const delBtn = wrapper.find('.tag-card__btn').at(0);
expect(wrapper.state('isDeleteModalOpen')).toEqual(false);
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(false);
delBtn.simulate('click');
setImmediate(() => {
expect(wrapper.state('isDeleteModalOpen')).toEqual(true);
done();
});
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(true);
});
it('displays edit modal when edit btn is clicked', (done) => {
it('displays edit modal when edit btn is clicked', () => {
const editBtn = wrapper.find('.tag-card__btn').at(1);
expect(wrapper.state('isEditModalOpen')).toEqual(false);
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(false);
editBtn.simulate('click');
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(true);
});
setImmediate(() => {
expect(wrapper.state('isEditModalOpen')).toEqual(true);
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({ 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({ tags: expectedTags }).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ url: '/tags', method: 'GET' }));
});
});