diff --git a/CHANGELOG.md b/CHANGELOG.md
index 901b8b81..df595121 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/src/index.scss b/src/index.scss
index 480dd907..77df9f09 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -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;
}
diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js
index 7c70dfc0..3f66bf5b 100644
--- a/src/tags/TagCard.js
+++ b/src/tags/TagCard.js
@@ -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 (
-
-
+
- {tag}
+
+ {tag}
+
+
+ {tag}
+
-
+
-
-
+ {tagStats && (
+
+
+
+ Short URLs
+ {prettify(tagStats.shortUrlsCount)}
+
+
+ Visits
+ {prettify(tagStats.visitsCount)}
+
+
+
+ )}
+
+
+
);
- }
+ };
+
+ TagCardComp.propTypes = propTypes;
+
+ return TagCardComp;
};
export default TagCard;
diff --git a/src/tags/TagCard.scss b/src/tags/TagCard.scss
index c30300c3..799c83d3 100644
--- a/src/tags/TagCard.scss
+++ b/src/tags/TagCard.scss
@@ -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;
+}
diff --git a/src/tags/TagsList.js b/src/tags/TagsList.js
index 2c482b68..54b18c97 100644
--- a/src/tags/TagsList.js
+++ b/src/tags/TagsList.js
@@ -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) => {
setDisplayedTag(displayedTag !== tag ? tag : undefined)}
/>
))}
diff --git a/src/tags/reducers/tagsList.js b/src/tags/reducers/tagsList.js
index 6cb2ff49..e810f2c7 100644
--- a/src/tags/reducers/tagsList.js
+++ b/src/tags/reducers/tagsList.js
@@ -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 });
}
diff --git a/src/tags/services/provideServices.js b/src/tags/services/provideServices.js
index e7e0c481..566791ea 100644
--- a/src/tags/services/provideServices.js
+++ b/src/tags/services/provideServices.js
@@ -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);
diff --git a/src/utils/SortingDropdown.js b/src/utils/SortingDropdown.js
index e4018ddd..0f123ca2 100644
--- a/src/utils/SortingDropdown.js
+++ b/src/utils/SortingDropdown.js
@@ -33,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
Order by
diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js
index 8ba12ff8..76d1cd56 100644
--- a/src/utils/services/ShlinkApiClient.js
+++ b/src/utils/services/ShlinkApiClient.js
@@ -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 })
diff --git a/src/visits/SortableBarGraph.js b/src/visits/SortableBarGraph.js
index 29f664d4..a19e9f4f 100644
--- a/src/visits/SortableBarGraph.js
+++ b/src/visits/SortableBarGraph.js
@@ -122,7 +122,7 @@ export default class SortableBarGraph extends React.Component {
{withPagination && keys(stats).length > 50 && (
this.setState({ itemsPerPage, currentPage: 1 })}
diff --git a/test/tags/TagCard.test.js b/test/tags/TagCard.test.js
index b8049ba1..49fee1c8 100644
--- a/test/tags/TagCard.test.js
+++ b/test/tags/TagCard.test.js
@@ -6,43 +6,52 @@ import TagBullet from '../../src/tags/helpers/TagBullet';
describe('', () => {
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();
+ wrapper = shallow();
});
+
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');
});
});
diff --git a/test/tags/TagsList.test.js b/test/tags/TagsList.test.js
index bba25539..c3bd3393 100644
--- a/test/tags/TagsList.test.js
+++ b/test/tags/TagsList.test.js
@@ -53,7 +53,7 @@ describe('', () => {
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');
diff --git a/test/tags/reducers/tagsList.test.js b/test/tags/reducers/tagsList.test.js
index 7cede265..c312fea8 100644
--- a/test/tags/reducers/tagsList.test.js
+++ b/test/tags/reducers/tagsList.test.js
@@ -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 () => {
diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js
index 5b41516b..07f93a66 100644
--- a/test/utils/services/ShlinkApiClient.test.js
+++ b/test/utils/services/ShlinkApiClient.test.js
@@ -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' }));
});
});