mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Merge pull request #266 from acelaya-forks/feature/tags-list-improvements
Feature/tags list improvements
This commit is contained in:
commit
6eead70511
14 changed files with 164 additions and 71 deletions
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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 })}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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' }));
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue