From 18e026e4ca89779055dfbeb8d1d5072073f2b5aa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 10:57:49 +0200 Subject: [PATCH] Updated tags list to display visits and short URLs when remote shlink version allows it --- src/index.scss | 8 ++- src/tags/TagCard.js | 68 +++++++++++++++++---- src/tags/TagCard.scss | 23 +++++-- src/tags/TagsList.js | 21 ++++--- src/tags/reducers/tagsList.js | 21 ++++++- src/tags/services/provideServices.js | 11 +++- src/utils/SortingDropdown.js | 2 +- src/utils/services/ShlinkApiClient.js | 4 +- src/visits/SortableBarGraph.js | 2 +- test/tags/TagCard.test.js | 21 +++++-- test/tags/TagsList.test.js | 2 +- test/tags/reducers/tagsList.test.js | 4 +- test/utils/services/ShlinkApiClient.test.js | 2 +- 13 files changed, 143 insertions(+), 46 deletions(-) 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..599bda1e 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -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 ( - - - + -
+ +
- {tag} + + {tag} + + + {tag} +
-
+ + + {tagStats && ( + + + + Short URLs + {prettify(tagStats.shortUrlsCount)} + + + Visits + {prettify(tagStats.visitsCount)} + + + + )} 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..03d36749 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 { 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 }); } 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..5bf5005a 100644 --- a/src/utils/services/ShlinkApiClient.js +++ b/src/utils/services/ShlinkApiClient.js @@ -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 }) 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..b837e3a3 100644 --- a/test/tags/TagCard.test.js +++ b/test/tags/TagCard.test.js @@ -6,19 +6,23 @@ import TagBullet from '../../src/tags/helpers/TagBullet'; describe('', () => { let wrapper; + const tagStats = { + shortUrlsCount: 48, + visitsCount: 23257, + }; beforeEach(() => { - const TagCard = createTagCard(() => '', () => '', {}); + const TagCard = createTagCard(() => '', () => '', () => '', {}); - wrapper = shallow(); + wrapper = shallow(); }); 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('', () => { 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..c5b04db0 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({ 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 () => { diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js index 5b41516b..bfe99f51 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({ data: expectedTags }).toEqual(result); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ url: '/tags', method: 'GET' })); }); });