Skip to content

Commit 6eead70

Browse files
authored
Merge pull request #266 from acelaya-forks/feature/tags-list-improvements
Feature/tags list improvements
2 parents 8741f42 + 6fd30ed commit 6eead70

14 files changed

+164
-71
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1717

1818
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
1919

20+
* [#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.
21+
22+
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
23+
2024
#### Changed
2125

2226
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.

src/index.scss

+5-3
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ body,
4444
cursor: pointer;
4545
}
4646

47-
.paddingless {
48-
padding: 0;
47+
.indivisible {
48+
white-space: nowrap;
4949
}
5050

51-
.indivisible {
51+
.text-ellipsis {
52+
text-overflow: ellipsis;
53+
overflow: hidden;
5254
white-space: nowrap;
5355
}
5456

src/tags/TagCard.js

+62-25
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,84 @@
1-
import { Card, CardBody } from 'reactstrap';
1+
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
22
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3-
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
3+
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
44
import PropTypes from 'prop-types';
55
import React from 'react';
66
import { Link } from 'react-router-dom';
7+
import { serverType } from '../servers/prop-types';
8+
import { prettify } from '../utils/helpers/numbers';
9+
import { useToggle } from '../utils/helpers/hooks';
710
import TagBullet from './helpers/TagBullet';
811
import './TagCard.scss';
912

10-
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
11-
static propTypes = {
12-
tag: PropTypes.string,
13-
currentServerId: PropTypes.string,
14-
};
13+
const propTypes = {
14+
tag: PropTypes.string,
15+
tagStats: PropTypes.shape({
16+
shortUrlsCount: PropTypes.number,
17+
visitsCount: PropTypes.number,
18+
}),
19+
selectedServer: serverType,
20+
displayed: PropTypes.bool,
21+
toggle: PropTypes.func,
22+
};
1523

16-
state = { isDeleteModalOpen: false, isEditModalOpen: false };
24+
const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGenerator) => {
25+
const TagCardComp = ({ tag, tagStats, selectedServer, displayed, toggle }) => {
26+
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
27+
const [ isEditModalOpen, toggleEdit ] = useToggle();
1728

18-
render() {
19-
const { tag, currentServerId } = this.props;
20-
const toggleDelete = () =>
21-
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
22-
const toggleEdit = () =>
23-
this.setState(({ isEditModalOpen }) => ({ isEditModalOpen: !isEditModalOpen }));
29+
const { id } = selectedServer;
30+
const shortUrlsLink = `/server/${id}/list-short-urls/1?tag=${tag}`;
2431

2532
return (
2633
<Card className="tag-card">
27-
<CardBody className="tag-card__body">
28-
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
34+
<CardHeader className="tag-card__header">
35+
<Button color="light" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
2936
<FontAwesomeIcon icon={deleteIcon} />
30-
</button>
31-
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
37+
</Button>
38+
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}>
3239
<FontAwesomeIcon icon={editIcon} />
33-
</button>
34-
<h5 className="tag-card__tag-title">
40+
</Button>
41+
<h5 className="tag-card__tag-title text-ellipsis">
3542
<TagBullet tag={tag} colorGenerator={colorGenerator} />
36-
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
43+
<ForServerVersion minVersion="2.2.0">
44+
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
45+
</ForServerVersion>
46+
<ForServerVersion maxVersion="2.1.*">
47+
<Link to={shortUrlsLink}>{tag}</Link>
48+
</ForServerVersion>
3749
</h5>
38-
</CardBody>
50+
</CardHeader>
51+
52+
{tagStats && (
53+
<Collapse isOpen={displayed}>
54+
<CardBody className="tag-card__body">
55+
<Link
56+
to={shortUrlsLink}
57+
className="btn btn-light btn-block d-flex justify-content-between align-items-center mb-1"
58+
>
59+
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
60+
<b>{prettify(tagStats.shortUrlsCount)}</b>
61+
</Link>
62+
<Link
63+
to={`/server/${id}/tags/${tag}/visits`}
64+
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
65+
>
66+
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
67+
<b>{prettify(tagStats.visitsCount)}</b>
68+
</Link>
69+
</CardBody>
70+
</Collapse>
71+
)}
3972

40-
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
41-
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
73+
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
74+
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
4275
</Card>
4376
);
44-
}
77+
};
78+
79+
TagCardComp.propTypes = propTypes;
80+
81+
return TagCardComp;
4582
};
4683

4784
export default TagCard;

src/tags/TagCard.scss

+19-4
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
.tag-card.tag-card {
2-
background-color: #eee;
32
margin-bottom: .5rem;
43
}
54

5+
.tag-card__header.tag-card__header {
6+
background-color: #eee;
7+
}
8+
9+
.tag-card__header.tag-card__header,
610
.tag-card__body.tag-card__body {
711
padding: .75rem;
812
}
913

1014
.tag-card__tag-title {
1115
margin: 0;
1216
line-height: 31px;
13-
text-overflow: ellipsis;
14-
overflow: hidden;
15-
white-space: nowrap;
1617
padding-right: 5px;
1718
}
1819

@@ -23,3 +24,17 @@
2324
.tag-card__btn--last {
2425
margin-left: 3px;
2526
}
27+
28+
.tag-card__table-cell.tag-card__table-cell {
29+
border: none;
30+
}
31+
32+
.tag-card__tag-name {
33+
color: #007bff;
34+
cursor: pointer;
35+
}
36+
37+
.tag-card__tag-name:hover {
38+
color: #0056b3;
39+
text-decoration: underline;
40+
}

src/tags/TagsList.js

+12-9
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1-
import React, { useEffect } from 'react';
1+
import React, { useEffect, useState } from 'react';
22
import { splitEvery } from 'ramda';
33
import PropTypes from 'prop-types';
44
import Message from '../utils/Message';
55
import SearchField from '../utils/SearchField';
6+
import { serverType } from '../servers/prop-types';
7+
import { TagsListType } from './reducers/tagsList';
68

79
const { ceil } = Math;
810
const TAGS_GROUPS_AMOUNT = 4;
911

1012
const propTypes = {
1113
filterTags: PropTypes.func,
1214
forceListTags: PropTypes.func,
13-
tagsList: PropTypes.shape({
14-
loading: PropTypes.bool,
15-
error: PropTypes.bool,
16-
filteredTags: PropTypes.arrayOf(PropTypes.string),
17-
}),
18-
match: PropTypes.object,
15+
tagsList: TagsListType,
16+
selectedServer: serverType,
1917
};
2018

2119
const TagsList = (TagCard) => {
22-
const TagListComp = ({ filterTags, forceListTags, tagsList, match }) => {
20+
const TagListComp = ({ filterTags, forceListTags, tagsList, selectedServer }) => {
21+
const [ displayedTag, setDisplayedTag ] = useState();
22+
2323
useEffect(() => {
2424
forceListTags();
2525
}, []);
@@ -53,7 +53,10 @@ const TagsList = (TagCard) => {
5353
<TagCard
5454
key={tag}
5555
tag={tag}
56-
currentServerId={match.params.serverId}
56+
tagStats={tagsList.stats[tag]}
57+
selectedServer={selectedServer}
58+
displayed={displayedTag === tag}
59+
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
5760
/>
5861
))}
5962
</div>

src/tags/reducers/tagsList.js

+18-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { handleActions } from 'redux-actions';
22
import { isEmpty, reject } from 'ramda';
3+
import PropTypes from 'prop-types';
34
import { TAG_DELETED } from './tagDelete';
45
import { TAG_EDITED } from './tagEdit';
56

@@ -10,9 +11,18 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
1011
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
1112
/* eslint-enable padding-line-between-statements */
1213

14+
export const TagsListType = PropTypes.shape({
15+
tags: PropTypes.arrayOf(PropTypes.string),
16+
filteredTags: PropTypes.arrayOf(PropTypes.string),
17+
stats: PropTypes.object, // Record
18+
loading: PropTypes.bool,
19+
error: PropTypes.bool,
20+
});
21+
1322
const initialState = {
1423
tags: [],
1524
filteredTags: [],
25+
stats: {},
1626
loading: false,
1727
error: false,
1828
};
@@ -23,7 +33,7 @@ const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, ta
2333
export default handleActions({
2434
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
2535
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
26-
[LIST_TAGS]: (state, { tags }) => ({ tags, filteredTags: tags, loading: false, error: false }),
36+
[LIST_TAGS]: (state, { tags, stats }) => ({ stats, tags, filteredTags: tags, loading: false, error: false }),
2737
[TAG_DELETED]: (state, { tag }) => ({
2838
...state,
2939
tags: rejectTag(state.tags, tag),
@@ -51,9 +61,14 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
5161

5262
try {
5363
const { listTags } = buildShlinkApiClient(getState);
54-
const tags = await listTags();
64+
const { tags, stats = [] } = await listTags();
65+
const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => {
66+
acc[tag] = { shortUrlsCount, visitsCount };
67+
68+
return acc;
69+
}, {});
5570

56-
dispatch({ tags, type: LIST_TAGS });
71+
dispatch({ tags, stats: processedStats, type: LIST_TAGS });
5772
} catch (e) {
5873
dispatch({ type: LIST_TAGS_ERROR });
5974
}

src/tags/services/provideServices.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ const provideServices = (bottle, connect) => {
1212
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
1313
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
1414

15-
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
15+
bottle.serviceFactory(
16+
'TagCard',
17+
TagCard,
18+
'DeleteTagConfirmModal',
19+
'EditTagModal',
20+
'ForServerVersion',
21+
'ColorGenerator'
22+
);
1623

1724
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
1825
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
@@ -21,7 +28,7 @@ const provideServices = (bottle, connect) => {
2128
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
2229

2330
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
24-
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
31+
bottle.decorator('TagsList', connect([ 'tagsList', 'selectedServer' ], [ 'forceListTags', 'filterTags' ]));
2532

2633
// Actions
2734
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);

src/utils/SortingDropdown.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
3333
<DropdownToggle
3434
caret
3535
color={isButton ? 'secondary' : 'link'}
36-
className={classNames({ 'btn-block': isButton, 'btn-sm paddingless': !isButton })}
36+
className={classNames({ 'btn-block': isButton, 'btn-sm p-0': !isButton })}
3737
>
3838
Order by
3939
</DropdownToggle>

src/utils/services/ShlinkApiClient.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ export default class ShlinkApiClient {
5353
.then(() => meta);
5454

5555
listTags = () =>
56-
this._performRequest('/tags', 'GET')
57-
.then((resp) => resp.data.tags.data);
56+
this._performRequest('/tags', 'GET', { withStats: 'true' })
57+
.then((resp) => resp.data.tags)
58+
.then(({ data, stats }) => ({ tags: data, stats }));
5859

5960
deleteTags = (tags) =>
6061
this._performRequest('/tags', 'DELETE', { tags })

src/visits/SortableBarGraph.js

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

0 commit comments

Comments
 (0)