Merge pull request #220 from acelaya-forks/feature/improvements

Feature/improvements
This commit is contained in:
Alejandro Celaya 2020-03-06 21:56:20 +01:00 committed by GitHub
commit 6ac89334fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 93 additions and 97 deletions

View file

@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
#### Changed #### Changed
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function. * [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
#### Deprecated #### Deprecated

View file

@ -10,10 +10,6 @@ body,
outline: none !important; outline: none !important;
} }
.nowrap {
white-space: nowrap;
}
.bg-main { .bg-main {
background-color: $mainColor !important; background-color: $mainColor !important;
} }

View file

@ -161,7 +161,7 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
className="short-urls-list__header-cell short-urls-list__header-cell--with-action" className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('visits')} onClick={this.orderByColumn('visits')}
> >
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span> <span className="indivisible">{this.renderOrderIcon('visits')} Visits</span>
</th> </th>
<th className="short-urls-list__header-cell">&nbsp;</th> <th className="short-urls-list__header-cell">&nbsp;</th>
</tr> </tr>

View file

@ -3,6 +3,9 @@ import React from 'react';
import Moment from 'react-moment'; import Moment from 'react-moment';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
@ -10,26 +13,25 @@ import Tag from '../../tags/helpers/Tag';
import ShortUrlVisitsCount from './ShortUrlVisitsCount'; import ShortUrlVisitsCount from './ShortUrlVisitsCount';
import './ShortUrlsRow.scss'; import './ShortUrlsRow.scss';
const ShortUrlsRow = ( const propTypes = {
ShortUrlsRowMenu,
colorGenerator,
stateFlagTimeout
) => class ShortUrlsRow extends React.Component {
static propTypes = {
refreshList: PropTypes.func, refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType, shortUrlsListParams: shortUrlsListParamsType,
selectedServer: serverType, selectedServer: serverType,
shortUrl: shortUrlType, shortUrl: shortUrlType,
}; };
state = { copiedToClipboard: false }; const ShortUrlsRow = (
ShortUrlsRowMenu,
renderTags(tags) { colorGenerator,
useStateFlagTimeout
) => {
const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(false);
const renderTags = (tags) => {
if (isEmpty(tags)) { if (isEmpty(tags)) {
return <i className="nowrap"><small>No tags</small></i>; return <i className="indivisible"><small>No tags</small></i>;
} }
const { refreshList, shortUrlsListParams } = this.props;
const selectedTags = shortUrlsListParams.tags || []; const selectedTags = shortUrlsListParams.tags || [];
return tags.map((tag) => ( return tags.map((tag) => (
@ -40,23 +42,28 @@ const ShortUrlsRow = (
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })} onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
/> />
)); ));
} };
render() {
const { shortUrl, selectedServer } = this.props;
return ( return (
<tr className="short-urls-row"> <tr className="short-urls-row">
<td className="nowrap short-urls-row__cell" data-th="Created at: "> <td className="indivisible short-urls-row__cell" data-th="Created at: ">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment> <Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td> </td>
<td className="short-urls-row__cell" data-th="Short URL: "> <td className="short-urls-row__cell" data-th="Short URL: ">
<span className="indivisible short-urls-row__cell--relative">
<ExternalLink href={shortUrl.shortUrl} /> <ExternalLink href={shortUrl.shortUrl} />
<CopyToClipboard text={shortUrl.shortUrl} onCopy={setCopiedToClipboard}>
<FontAwesomeIcon icon={copyIcon} className="ml-2 short-urls-row__copy-btn" />
</CopyToClipboard>
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
Copied short URL!
</span>
</span>
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: "> <td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<ExternalLink href={shortUrl.longUrl} /> <ExternalLink href={shortUrl.longUrl} />
</td> </td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td> <td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: "> <td className="short-urls-row__cell text-md-right" data-th="Visits: ">
<ShortUrlVisitsCount <ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount} visitsCount={shortUrl.visitsCount}
@ -64,22 +71,16 @@ const ShortUrlsRow = (
selectedServer={selectedServer} selectedServer={selectedServer}
/> />
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--relative"> <td className="short-urls-row__cell">
<small <ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
className="badge badge-warning short-urls-row__copy-hint"
hidden={!this.state.copiedToClipboard}
>
Copied short URL!
</small>
<ShortUrlsRowMenu
selectedServer={selectedServer}
shortUrl={shortUrl}
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}
/>
</td> </td>
</tr> </tr>
); );
} };
ShortUrlsRowComp.propTypes = propTypes;
return ShortUrlsRowComp;
}; };
export default ShortUrlsRow; export default ShortUrlsRow;

View file

@ -43,11 +43,16 @@
position: relative; position: relative;
} }
.short-urls-row__copy-btn {
cursor: pointer;
font-size: 1.2rem;
}
.short-urls-row__copy-hint { .short-urls-row__copy-hint {
@include vertical-align(); @include vertical-align(translateX(10px));
right: 100%; box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
@media (max-width: $smMax) { @media (max-width: $smMax) {
right: calc(100% + 10px); @include vertical-align(translateX(calc(-100% - 20px)));
} }
} }

View file

@ -1,4 +1,4 @@
import { faCopy as copyIcon, faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons'; import { faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
import { import {
faTags as tagsIcon, faTags as tagsIcon,
faChartPie as pieChartIcon, faChartPie as pieChartIcon,
@ -9,9 +9,7 @@ import {
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal'; import PreviewModal from './PreviewModal';
@ -26,7 +24,6 @@ const ShortUrlsRowMenu = (
ForServerVersion ForServerVersion
) => class ShortUrlsRowMenu extends React.Component { ) => class ShortUrlsRowMenu extends React.Component {
static propTypes = { static propTypes = {
onCopyToClipboard: PropTypes.func,
selectedServer: serverType, selectedServer: serverType,
shortUrl: shortUrlType, shortUrl: shortUrlType,
}; };
@ -42,7 +39,7 @@ const ShortUrlsRowMenu = (
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen })); toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() { render() {
const { onCopyToClipboard, shortUrl, selectedServer } = this.props; const { shortUrl, selectedServer } = this.props;
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] })); const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen'); const toggleQrCode = toggleModal('isQrModalOpen');
@ -73,12 +70,10 @@ const ShortUrlsRowMenu = (
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} /> <EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
</ForServerVersion> </ForServerVersion>
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}> <DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL <FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem> </DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} /> <QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
<DropdownItem divider />
<ForServerVersion maxVersion="1.x"> <ForServerVersion maxVersion="1.x">
<DropdownItem onClick={togglePreview}> <DropdownItem onClick={togglePreview}>
@ -87,20 +82,12 @@ const ShortUrlsRowMenu = (
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} /> <PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
</ForServerVersion> </ForServerVersion>
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
<ForServerVersion maxVersion="1.x">
<DropdownItem divider /> <DropdownItem divider />
</ForServerVersion>
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}> <DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<DropdownItem> <FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
</DropdownItem> </DropdownItem>
</CopyToClipboard> <DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
</DropdownMenu> </DropdownMenu>
</ButtonDropdown> </ButtonDropdown>
); );

View file

@ -33,7 +33,7 @@ const provideServices = (bottle, connect) => {
[ 'listShortUrls', 'resetShortUrlParams' ] [ 'listShortUrls', 'resetShortUrlParams' ]
)); ));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
bottle.serviceFactory( bottle.serviceFactory(
'ShortUrlsRowMenu', 'ShortUrlsRowMenu',

View file

@ -1,5 +1,5 @@
@mixin vertical-align { @mixin vertical-align($extraTransforms: '') {
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%) $extraTransforms;
} }

View file

@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { stateFlagTimeout } from '../utils'; import { stateFlagTimeout, useStateFlagTimeout } from '../utils';
import Storage from './Storage'; import Storage from './Storage';
import ColorGenerator from './ColorGenerator'; import ColorGenerator from './ColorGenerator';
import buildShlinkApiClient from './ShlinkApiClientBuilder'; import buildShlinkApiClient from './ShlinkApiClientBuilder';
@ -14,6 +14,7 @@ const provideServices = (bottle) => {
bottle.constant('setTimeout', global.setTimeout); bottle.constant('setTimeout', global.setTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout'); bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout');
}; };
export default provideServices; export default provideServices;

View file

@ -19,6 +19,16 @@ export const stateFlagTimeout = (setTimeout) => (
setTimeout(() => setState({ [flagName]: !initialValue }), delay); setTimeout(() => setState({ [flagName]: !initialValue }), delay);
}; };
export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
const [ flag, setFlag ] = useState(initialValue);
const callback = () => {
setFlag(!initialValue);
setTimeout(() => setFlag(initialValue), delay);
};
return [ flag, callback ];
};
export const determineOrderDir = (clickedField, currentOrderField, currentOrderDir) => { export const determineOrderDir = (clickedField, currentOrderField, currentOrderDir) => {
if (currentOrderField !== clickedField) { if (currentOrderField !== clickedField) {
return 'ASC'; return 'ASC';

View file

@ -61,7 +61,7 @@ const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
const updateReferrersStatsForVisit = (referrersStats, { referer }) => { const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
const notHasDomain = isNil(referer) || isEmpty(referer); const notHasDomain = isNil(referer) || isEmpty(referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(referer); const domain = notHasDomain ? 'Direct' : extractDomain(referer);
referrersStats[domain] = (referrersStats[domain] || 0) + 1; referrersStats[domain] = (referrersStats[domain] || 0) + 1;
}; };

View file

@ -4,6 +4,7 @@ import moment from 'moment';
import Moment from 'react-moment'; import Moment from 'react-moment';
import { assoc, toString } from 'ramda'; import { assoc, toString } from 'ramda';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow'; import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
import Tag from '../../../src/tags/helpers/Tag'; import Tag from '../../../src/tags/helpers/Tag';
@ -11,7 +12,8 @@ describe('<ShortUrlsRow />', () => {
let wrapper; let wrapper;
const mockFunction = () => ''; const mockFunction = () => '';
const ShortUrlsRowMenu = mockFunction; const ShortUrlsRowMenu = mockFunction;
const stateFlagTimeout = jest.fn(); const stateFlagTimeout = jest.fn(() => true);
const useStateFlagTimeout = jest.fn(() => [ false, stateFlagTimeout ]);
const colorGenerator = { const colorGenerator = {
getColorForKey: mockFunction, getColorForKey: mockFunction,
setColorForKey: mockFunction, setColorForKey: mockFunction,
@ -29,7 +31,7 @@ describe('<ShortUrlsRow />', () => {
}; };
beforeEach(() => { beforeEach(() => {
const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, stateFlagTimeout); const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, useStateFlagTimeout);
wrapper = shallow( wrapper = shallow(
<ShortUrlsRow shortUrlsListParams={{}} refreshList={mockFunction} selecrtedServer={server} shortUrl={shortUrl} /> <ShortUrlsRow shortUrlsListParams={{}} refreshList={mockFunction} selecrtedServer={server} shortUrl={shortUrl} />
@ -87,20 +89,12 @@ describe('<ShortUrlsRow />', () => {
}); });
it('updates state when copied to clipboard', () => { it('updates state when copied to clipboard', () => {
const col = wrapper.find('td').at(5); const col = wrapper.find('td').at(1);
const menu = col.find(ShortUrlsRowMenu); const menu = col.find(CopyToClipboard);
expect(menu).toHaveLength(1); expect(menu).toHaveLength(1);
expect(stateFlagTimeout).not.toHaveBeenCalled(); expect(stateFlagTimeout).not.toHaveBeenCalled();
menu.simulate('copyToClipboard'); menu.simulate('copy');
expect(stateFlagTimeout).toHaveBeenCalledTimes(1); expect(stateFlagTimeout).toHaveBeenCalledTimes(1);
}); });
it('shows copy hint when state prop is true', () => {
const isHidden = () => wrapper.find('td').at(5).find('.short-urls-row__copy-hint').prop('hidden');
expect(isHidden()).toEqual(true);
wrapper.setState({ copiedToClipboard: true });
expect(isHidden()).toEqual(false);
});
}); });

View file

@ -49,8 +49,8 @@ describe('<ShortUrlsRowMenu />', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const items = wrapper.find(DropdownItem); const items = wrapper.find(DropdownItem);
expect(items).toHaveLength(9); expect(items).toHaveLength(7);
expect(items.find('[divider]')).toHaveLength(2); expect(items.find('[divider]')).toHaveLength(1);
}); });
describe('toggles state when toggling modal windows', () => { describe('toggles state when toggling modal windows', () => {

View file

@ -74,7 +74,7 @@ describe('VisitsParser', () => {
const { referrers } = stats; const { referrers } = stats;
expect(referrers).toEqual({ expect(referrers).toEqual({
'Unknown': 2, 'Direct': 2,
'google.com': 2, 'google.com': 2,
'm.facebook.com': 1, 'm.facebook.com': 1,
}); });