Merge pull request #251 from acelaya-forks/feature/real-time-updates

Feature/real time updates
This commit is contained in:
Alejandro Celaya 2020-04-18 20:57:07 +02:00 committed by GitHub
commit aa59a95f91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 598 additions and 203 deletions

View file

@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
#### Added
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 2.4.0 - 2020-04-10 ## 2.4.0 - 2020-04-10
#### Added #### Added

5
package-lock.json generated
View file

@ -6641,6 +6641,11 @@
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
"dev": true "dev": true
}, },
"event-source-polyfill": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.12.tgz",
"integrity": "sha512-WjOTn0LIbaN08z/8gNt3GYAomAdm6cZ2lr/QdvhTTEipr5KR6lds2ziUH+p/Iob4Lk6NClKhwPOmn1NjQEcJCg=="
},
"eventemitter3": { "eventemitter3": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",

View file

@ -38,6 +38,7 @@
"classnames": "^2.2.6", "classnames": "^2.2.6",
"compare-versions": "^3.5.1", "compare-versions": "^3.5.1",
"csvjson": "^5.1.0", "csvjson": "^5.1.0",
"event-source-polyfill": "^1.0.12",
"leaflet": "^1.5.1", "leaflet": "^1.5.1",
"moment": "^2.24.0", "moment": "^2.24.0",
"promise": "^8.0.3", "promise": "^8.0.3",

View file

@ -9,6 +9,7 @@ import provideServersServices from '../servers/services/provideServices';
import provideVisitsServices from '../visits/services/provideServices'; import provideVisitsServices from '../visits/services/provideServices';
import provideTagsServices from '../tags/services/provideServices'; import provideTagsServices from '../tags/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices'; import provideUtilsServices from '../utils/services/provideServices';
import provideMercureServices from '../mercure/services/provideServices';
const bottle = new Bottle(); const bottle = new Bottle();
const { container } = bottle; const { container } = bottle;
@ -34,5 +35,6 @@ provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect); provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect); provideVisitsServices(bottle, connect);
provideUtilsServices(bottle); provideUtilsServices(bottle);
provideMercureServices(bottle);
export default container; export default container;

View file

@ -0,0 +1,23 @@
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
export const bindToMercureTopic = (mercureInfo, topic, onMessage, onTokenExpired) => () => {
const { mercureHubUrl, token, loading, error } = mercureInfo;
if (loading || error) {
return undefined;
}
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', topic);
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
es.onmessage = ({ data }) => onMessage(JSON.parse(data));
es.onerror = ({ status }) => status === 401 && onTokenExpired();
return () => es.close();
};

View file

@ -0,0 +1,41 @@
import { handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
/* eslint-disable padding-line-between-statements */
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
/* eslint-enable padding-line-between-statements */
export const MercureInfoType = PropTypes.shape({
token: PropTypes.string,
mercureHubUrl: PropTypes.string,
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = {
token: undefined,
mercureHubUrl: undefined,
loading: true,
error: false,
};
export default handleActions({
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[GET_MERCURE_INFO]: (state, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }),
}, initialState);
export const loadMercureInfo = (buildShlinkApiClient) => () => async (dispatch, getState) => {
dispatch({ type: GET_MERCURE_INFO_START });
const { mercureInfo } = buildShlinkApiClient(getState);
try {
const result = await mercureInfo();
dispatch({ type: GET_MERCURE_INFO, ...result });
} catch (e) {
dispatch({ type: GET_MERCURE_INFO_ERROR });
}
};

View file

@ -0,0 +1,8 @@
import { loadMercureInfo } from '../reducers/mercureInfo';
const provideServices = (bottle) => {
// Actions
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
};
export default provideServices;

View file

@ -13,6 +13,7 @@ import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList'; import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete'; import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit'; import tagEditReducer from '../tags/reducers/tagEdit';
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
export default combineReducers({ export default combineReducers({
servers: serversReducer, servers: serversReducer,
@ -29,4 +30,5 @@ export default combineReducers({
tagsList: tagsListReducer, tagsList: tagsListReducer,
tagDelete: tagDeleteReducer, tagDelete: tagDeleteReducer,
tagEdit: tagEditReducer, tagEdit: tagEditReducer,
mercureInfo: mercureInfoReducer,
}); });

View file

@ -25,7 +25,9 @@ const getServerVersion = memoizeWith(identity, (serverId, health) => health().th
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER); export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => { export const selectServer = ({ findServerById }, buildShlinkApiClient, loadMercureInfo) => (serverId) => async (
dispatch
) => {
dispatch(resetSelectedServer()); dispatch(resetSelectedServer());
dispatch(resetShortUrlParams()); dispatch(resetShortUrlParams());
const selectedServer = findServerById(serverId); const selectedServer = findServerById(serverId);
@ -51,6 +53,7 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
printableVersion, printableVersion,
}, },
}); });
dispatch(loadMercureInfo());
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: SELECT_SERVER, type: SELECT_SERVER,

View file

@ -47,7 +47,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson'); bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions // Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient'); bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient', 'loadMercureInfo');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers'); bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers'); bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers'); bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');

View file

@ -1,12 +1,14 @@
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, values } from 'ramda'; import { head, isEmpty, keys, values } from 'ramda';
import React from 'react'; import React, { useState, useEffect } from 'react';
import qs from 'qs'; import qs from 'qs';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import SortingDropdown from '../utils/SortingDropdown'; import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils'; import { determineOrderDir } from '../utils/utils';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { bindToMercureTopic } from '../mercure/helpers';
import { shortUrlType } from './reducers/shortUrlsList'; import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss'; import './ShortUrlsList.scss';
@ -18,118 +20,115 @@ export const SORTABLE_FIELDS = {
visits: 'Visits', visits: 'Visits',
}; };
const propTypes = {
listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
match: PropTypes.object,
location: PropTypes.object,
loading: PropTypes.bool,
error: PropTypes.bool,
shortUrlsList: PropTypes.arrayOf(shortUrlType),
selectedServer: serverType,
createNewVisit: PropTypes.func,
loadMercureInfo: PropTypes.func,
mercureInfo: MercureInfoType,
};
// FIXME Replace with typescript: (ShortUrlsRow component) // FIXME Replace with typescript: (ShortUrlsRow component)
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component { const ShortUrlsList = (ShortUrlsRow) => {
static propTypes = { const ShortUrlsListComp = ({
listShortUrls: PropTypes.func, listShortUrls,
resetShortUrlParams: PropTypes.func, resetShortUrlParams,
shortUrlsListParams: shortUrlsListParamsType, shortUrlsListParams,
match: PropTypes.object, match,
location: PropTypes.object, location,
loading: PropTypes.bool, loading,
error: PropTypes.bool, error,
shortUrlsList: PropTypes.arrayOf(shortUrlType), shortUrlsList,
selectedServer: serverType, selectedServer,
}; createNewVisit,
loadMercureInfo,
refreshList = (extraParams) => { mercureInfo,
const { listShortUrls, shortUrlsListParams } = this.props; }) => {
const { orderBy } = shortUrlsListParams;
listShortUrls({ const [ order, setOrder ] = useState({
...shortUrlsListParams, orderField: orderBy && head(keys(orderBy)),
...extraParams, orderDir: orderBy && head(values(orderBy)),
}); });
}; const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
const handleOrderBy = (orderField, orderDir) => {
handleOrderBy = (orderField, orderDir) => { setOrder({ orderField, orderDir });
this.setState({ orderField, orderDir }); refreshList({ orderBy: { [orderField]: orderDir } });
this.refreshList({ orderBy: { [orderField]: orderDir } });
};
orderByColumn = (columnName) => () =>
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
renderOrderIcon = (field) => {
if (this.state.orderField !== field) {
return null;
}
if (!this.state.orderDir) {
return null;
}
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className="short-urls-list__header-icon"
/>
);
};
constructor(props) {
super(props);
const { orderBy } = props.shortUrlsListParams;
this.state = {
orderField: orderBy ? head(keys(orderBy)) : undefined,
orderDir: orderBy ? head(values(orderBy)) : undefined,
}; };
} const orderByColumn = (columnName) => () =>
handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir));
const renderOrderIcon = (field) => {
if (order.orderField !== field) {
return null;
}
componentDidMount() { if (!order.orderDir) {
const { match: { params }, location, shortUrlsListParams } = this.props; return null;
const query = qs.parse(location.search, { ignoreQueryPrefix: true }); }
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
this.refreshList({ page: params.page, tags });
}
componentWillUnmount() {
const { resetShortUrlParams } = this.props;
resetShortUrlParams();
}
renderShortUrls() {
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
if (error) {
return ( return (
<tr> <FontAwesomeIcon
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td> icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
</tr> className="short-urls-list__header-icon"
/>
); );
} };
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
}
if (loading) { if (loading) {
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>; return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
} }
if (!loading && isEmpty(shortUrlsList)) { if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan="6" className="text-center">No results found</td></tr>; return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
} }
return shortUrlsList.map((shortUrl) => ( return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow <ShortUrlsRow
key={shortUrl.shortUrl} key={shortUrl.shortUrl}
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
refreshList={this.refreshList} refreshList={refreshList}
shortUrlsListParams={shortUrlsListParams} shortUrlsListParams={shortUrlsListParams}
/> />
)); ));
} };
useEffect(() => {
const { params } = match;
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
refreshList({ page: params.page, tags });
return resetShortUrlParams;
}, []);
useEffect(
bindToMercureTopic(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
[ mercureInfo ]
);
render() {
return ( return (
<React.Fragment> <React.Fragment>
<div className="d-block d-md-none mb-3"> <div className="d-block d-md-none mb-3">
<SortingDropdown <SortingDropdown
items={SORTABLE_FIELDS} items={SORTABLE_FIELDS}
orderField={this.state.orderField} orderField={order.orderField}
orderDir={this.state.orderDir} orderDir={order.orderDir}
onChange={this.handleOrderBy} onChange={handleOrderBy}
/> />
</div> </div>
<table className="table table-striped table-hover"> <table className="table table-striped table-hover">
@ -137,42 +136,46 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
<tr> <tr>
<th <th
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('dateCreated')} onClick={orderByColumn('dateCreated')}
> >
{this.renderOrderIcon('dateCreated')} {renderOrderIcon('dateCreated')}
Created at Created at
</th> </th>
<th <th
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('shortCode')} onClick={orderByColumn('shortCode')}
> >
{this.renderOrderIcon('shortCode')} {renderOrderIcon('shortCode')}
Short URL Short URL
</th> </th>
<th <th
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('longUrl')} onClick={orderByColumn('longUrl')}
> >
{this.renderOrderIcon('longUrl')} {renderOrderIcon('longUrl')}
Long URL Long URL
</th> </th>
<th className="short-urls-list__header-cell">Tags</th> <th className="short-urls-list__header-cell">Tags</th>
<th <th
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={orderByColumn('visits')}
> >
<span className="indivisible">{this.renderOrderIcon('visits')} Visits</span> <span className="indivisible">{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>
</thead> </thead>
<tbody> <tbody>
{this.renderShortUrls()} {renderShortUrls()}
</tbody> </tbody>
</table> </table>
</React.Fragment> </React.Fragment>
); );
} };
ShortUrlsListComp.propTypes = propTypes;
return ShortUrlsListComp;
}; };
export default ShortUrlsList; export default ShortUrlsList;

View file

@ -1,24 +1,31 @@
import React from 'react'; import React, { useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import classNames from 'classnames';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { prettify } from '../../utils/helpers/numbers';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import './ShortUrlVisitsCount.scss';
import VisitStatsLink from './VisitStatsLink'; import VisitStatsLink from './VisitStatsLink';
import './ShortUrlVisitsCount.scss';
const propTypes = { const propTypes = {
visitsCount: PropTypes.number.isRequired, visitsCount: PropTypes.number.isRequired,
shortUrl: shortUrlType, shortUrl: shortUrlType,
selectedServer: serverType, selectedServer: serverType,
active: PropTypes.bool,
}; };
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => { const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }) => {
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits; const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
const visitsLink = ( const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}> <VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
<strong>{visitsCount}</strong> <strong
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
>
{prettify(visitsCount)}
</strong>
</VisitStatsLink> </VisitStatsLink>
); );
@ -26,19 +33,27 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
return visitsLink; return visitsLink;
} }
const prettifiedMaxVisits = prettify(maxVisits);
const tooltipRef = useRef();
return ( return (
<React.Fragment> <React.Fragment>
<span className="indivisible"> <span className="indivisible">
{visitsLink} {visitsLink}
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control"> <small
{' '}/ {maxVisits}{' '} className="short-urls-visits-count__max-visits-control"
ref={(el) => {
tooltipRef.current = el;
}}
>
{' '}/ {prettifiedMaxVisits}{' '}
<sup> <sup>
<FontAwesomeIcon icon={infoIcon} /> <FontAwesomeIcon icon={infoIcon} />
</sup> </sup>
</small> </small>
</span> </span>
<UncontrolledTooltip target="maxVisitsControl" placement="bottom"> <UncontrolledTooltip target={() => tooltipRef.current} placement="bottom">
This short URL will not accept more than <b>{maxVisits}</b> visits. This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
</UncontrolledTooltip> </UncontrolledTooltip>
</React.Fragment> </React.Fragment>
); );

View file

@ -1,3 +1,12 @@
.short-urls-visits-count__max-visits-control { .short-urls-visits-count__max-visits-control {
cursor: help; cursor: help;
} }
.short-url-visits-count__amount {
transition: transform .3s ease;
display: inline-block;
}
.short-url-visits-count__amount--big {
transform: scale(1.5);
}

View file

@ -1,5 +1,5 @@
import { isEmpty } from 'ramda'; import { isEmpty } from 'ramda';
import React from 'react'; import React, { useEffect, useRef } 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';
@ -26,7 +26,10 @@ const ShortUrlsRow = (
useStateFlagTimeout useStateFlagTimeout
) => { ) => {
const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => { const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(false); const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout();
const [ active, setActive ] = useStateFlagTimeout(false, 500);
const isFirstRun = useRef(true);
const renderTags = (tags) => { const renderTags = (tags) => {
if (isEmpty(tags)) { if (isEmpty(tags)) {
return <i className="indivisible"><small>No tags</small></i>; return <i className="indivisible"><small>No tags</small></i>;
@ -44,6 +47,14 @@ const ShortUrlsRow = (
)); ));
}; };
useEffect(() => {
if (isFirstRun.current) {
isFirstRun.current = false;
} else {
setActive(true);
}
}, [ shortUrl.visitsCount ]);
return ( return (
<tr className="short-urls-row"> <tr className="short-urls-row">
<td className="indivisible short-urls-row__cell" data-th="Created at: "> <td className="indivisible short-urls-row__cell" data-th="Created at: ">
@ -69,6 +80,7 @@ const ShortUrlsRow = (
visitsCount={shortUrl.visitsCount} visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
active={active}
/> />
</td> </td>
<td className="short-urls-row__cell"> <td className="short-urls-row__cell">

View file

@ -35,6 +35,7 @@
} }
} }
} }
.short-urls-row__cell--break { .short-urls-row__cell--break {
word-break: break-all; word-break: break-all;
} }
@ -43,6 +44,10 @@
position: relative; position: relative;
} }
.short-urls-row__cell--big {
transform: scale(1.5);
}
.short-urls-row__copy-btn { .short-urls-row__copy-btn {
cursor: pointer; cursor: pointer;
font-size: 1.2rem; font-size: 1.2rem;

View file

@ -0,0 +1,9 @@
import { isNil } from 'ramda';
export const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};

View file

@ -1,6 +1,8 @@
import { handleActions } from 'redux-actions'; import { handleActions } from 'redux-actions';
import { assoc, assocPath, isNil, reject } from 'ramda'; import { assoc, assocPath, reject } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits';
import { shortUrlMatches } from '../helpers';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
@ -28,14 +30,6 @@ const initialState = {
error: false, error: false,
}; };
const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath( const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
state.shortUrls.data.map( state.shortUrls.data.map(
@ -56,6 +50,15 @@ export default handleActions({
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
[CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
? assoc('visitsCount', visitsCount, shortUrl)
: shortUrl
),
state
),
}, initialState); }, initialState);
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => { export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {

View file

@ -31,8 +31,8 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect( bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'shortUrlsListParams' ], [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
[ 'listShortUrls', 'resetShortUrlParams' ] [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
)); ));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');

View file

@ -1,12 +1,18 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
const DEFAULT_TIMEOUT_DELAY = 2000; const DEFAULT_DELAY = 2000;
export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => { export const useStateFlagTimeout = (setTimeout, clearTimeout) => (initialValue = false, delay = DEFAULT_DELAY) => {
const [ flag, setFlag ] = useState(initialValue); const [ flag, setFlag ] = useState(initialValue);
const timeout = useRef(undefined);
const callback = () => { const callback = () => {
setFlag(!initialValue); setFlag(!initialValue);
setTimeout(() => setFlag(initialValue), delay);
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => setFlag(initialValue), delay);
}; };
return [ flag, callback ]; return [ flag, callback ];

View file

@ -66,6 +66,8 @@ export default class ShlinkApiClient {
health = () => this._performRequest('/health', 'GET').then((resp) => resp.data); health = () => this._performRequest('/health', 'GET').then((resp) => resp.data);
mercureInfo = () => this._performRequest('/mercure-info', 'GET').then((resp) => resp.data);
_performRequest = async (url, method = 'GET', query = {}, body = {}) => { _performRequest = async (url, method = 'GET', query = {}, body = {}) => {
try { try {
return await this.axios({ return await this.axios({

View file

@ -14,8 +14,9 @@ const provideServices = (bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios'); bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
bottle.constant('setTimeout', global.setTimeout); bottle.constant('setTimeout', global.setTimeout);
bottle.constant('clearTimeout', global.clearTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout'); bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout'); bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout');
}; };
export default provideServices; export default provideServices;

View file

@ -10,6 +10,8 @@ import DateRangeRow from '../utils/DateRangeRow';
import Message from '../utils/Message'; import Message from '../utils/Message';
import { formatDate } from '../utils/helpers/date'; import { formatDate } from '../utils/helpers/date';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { bindToMercureTopic } from '../mercure/helpers';
import SortableBarGraph from './SortableBarGraph'; import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader'; import VisitsHeader from './VisitsHeader';
@ -30,6 +32,9 @@ const propTypes = {
shortUrlDetail: shortUrlDetailType, shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func, cancelGetShortUrlVisits: PropTypes.func,
matchMedia: PropTypes.func, matchMedia: PropTypes.func,
createNewVisit: PropTypes.func,
loadMercureInfo: PropTypes.func,
mercureInfo: MercureInfoType,
}; };
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => { const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
@ -54,6 +59,9 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
getShortUrlDetail, getShortUrlDetail,
cancelGetShortUrlVisits, cancelGetShortUrlVisits,
matchMedia = window.matchMedia, matchMedia = window.matchMedia,
createNewVisit,
loadMercureInfo,
mercureInfo,
}) => { }) => {
const [ startDate, setStartDate ] = useState(undefined); const [ startDate, setStartDate ] = useState(undefined);
const [ endDate, setEndDate ] = useState(undefined); const [ endDate, setEndDate ] = useState(undefined);
@ -108,6 +116,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
useEffect(() => { useEffect(() => {
loadVisits(); loadVisits();
}, [ startDate, endDate ]); }, [ startDate, endDate ]);
useEffect(
bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit, loadMercureInfo),
[ mercureInfo ],
);
const renderVisitsContent = () => { const renderVisitsContent = () => {
if (loading) { if (loading) {

View file

@ -1,6 +1,7 @@
import { createAction, handleActions } from 'redux-actions'; import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { flatten, prop, range, splitEvery } from 'ramda'; import { flatten, prop, range, splitEvery } from 'ramda';
import { shortUrlMatches } from '../../short-urls/helpers';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
@ -8,6 +9,7 @@ export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_V
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE'; export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL'; export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export const visitType = PropTypes.shape({ export const visitType = PropTypes.shape({
@ -28,12 +30,16 @@ export const visitType = PropTypes.shape({
export const shortUrlVisitsType = PropTypes.shape({ export const shortUrlVisitsType = PropTypes.shape({
visits: PropTypes.arrayOf(visitType), visits: PropTypes.arrayOf(visitType),
shortCode: PropTypes.string,
domain: PropTypes.string,
loading: PropTypes.bool, loading: PropTypes.bool,
error: PropTypes.bool, error: PropTypes.bool,
}); });
const initialState = { const initialState = {
visits: [], visits: [],
shortCode: '',
domain: undefined,
loading: false, loading: false,
loadingLarge: false, loadingLarge: false,
error: false, error: false,
@ -54,8 +60,10 @@ export default handleActions({
error: true, error: true,
cancelLoad: false, cancelLoad: false,
}), }),
[GET_SHORT_URL_VISITS]: (state, { visits }) => ({ [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({
visits, visits,
shortCode,
domain,
loading: false, loading: false,
loadingLarge: false, loadingLarge: false,
error: false, error: false,
@ -63,9 +71,18 @@ export default handleActions({
}), }),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[CREATE_SHORT_URL_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand
const { shortCode, domain, visits } = state;
if (!shortUrlMatches(shortUrl, shortCode, domain)) {
return state;
}
return { ...state, visits: [ ...visits, visit ] };
},
}, initialState); }, initialState);
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => { export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START }); dispatch({ type: GET_SHORT_URL_VISITS_START });
const { getShortUrlVisits } = buildShlinkApiClient(getState); const { getShortUrlVisits } = buildShlinkApiClient(getState);
const itemsPerPage = 5000; const itemsPerPage = 5000;
@ -118,10 +135,12 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) =>
try { try {
const visits = await loadVisits(); const visits = await loadVisits();
dispatch({ visits, type: GET_SHORT_URL_VISITS }); dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS });
} catch (e) { } catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
} }
}; };
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL); export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_SHORT_URL_VISIT });

View file

@ -1,5 +1,5 @@
import ShortUrlVisits from '../ShortUrlVisits'; import ShortUrlVisits from '../ShortUrlVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import OpenMapModalBtn from '../helpers/OpenMapModalBtn'; import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
import MapModal from '../helpers/MapModal'; import MapModal from '../helpers/MapModal';
@ -11,8 +11,8 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('MapModal', () => MapModal); bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn'); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
bottle.decorator('ShortUrlVisits', connect( bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail' ], [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ] [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
)); ));
// Services // Services
@ -22,6 +22,7 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits); bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
bottle.serviceFactory('createNewVisit', () => createNewVisit);
}; };
export default provideServices; export default provideServices;

View file

@ -0,0 +1,57 @@
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
import { bindToMercureTopic } from '../../../src/mercure/helpers';
jest.mock('event-source-polyfill');
describe('helpers', () => {
afterEach(jest.resetAllMocks);
describe('bindToMercureTopic', () => {
const onMessage = jest.fn();
const onTokenExpired = jest.fn();
it.each([
[{ loading: true, error: false }],
[{ loading: false, error: true }],
[{ loading: true, error: true }],
])('does not bind an EventSource when loading or error', (mercureInfo) => {
bindToMercureTopic(mercureInfo)();
expect(EventSource).not.toHaveBeenCalled();
expect(onMessage).not.toHaveBeenCalled();
expect(onTokenExpired).not.toHaveBeenCalled();
});
it('binds an EventSource when mercure info is properly loaded', () => {
const token = 'abc.123.efg';
const mercureHubUrl = 'https://example.com/.well-known/mercure';
const topic = 'foo';
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', topic);
const callback = bindToMercureTopic({
loading: false,
error: false,
mercureHubUrl,
token,
}, topic, onMessage, onTokenExpired)();
expect(EventSource).toHaveBeenCalledWith(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const [ es ] = EventSource.mock.instances;
es.onmessage({ data: '{"foo": "bar"}' });
es.onerror({ status: 401 });
expect(onMessage).toHaveBeenCalledWith({ foo: 'bar' });
expect(onTokenExpired).toHaveBeenCalled();
callback();
expect(es.close).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,70 @@
import reducer, {
GET_MERCURE_INFO_START,
GET_MERCURE_INFO_ERROR,
GET_MERCURE_INFO,
loadMercureInfo,
} from '../../../src/mercure/reducers/mercureInfo.js';
describe('mercureInfoReducer', () => {
const mercureInfo = {
mercureHubUrl: 'http://example.com/.well-known/mercure',
token: 'abc.123.def',
};
describe('reducer', () => {
it('returns loading on GET_MERCURE_INFO_START', () => {
expect(reducer({}, { type: GET_MERCURE_INFO_START })).toEqual({
loading: true,
error: false,
});
});
it('returns error on GET_MERCURE_INFO_ERROR', () => {
expect(reducer({}, { type: GET_MERCURE_INFO_ERROR })).toEqual({
loading: false,
error: true,
});
});
it('returns mercure info on GET_MERCURE_INFO', () => {
expect(reducer({}, { type: GET_MERCURE_INFO, ...mercureInfo })).toEqual({
...mercureInfo,
loading: false,
error: false,
});
});
});
describe('loadMercureInfo', () => {
const createApiClientMock = (result) => ({
mercureInfo: jest.fn(() => result),
});
const dispatch = jest.fn();
const getState = () => ({});
afterEach(jest.resetAllMocks);
it('calls API on success', async () => {
const apiClientMock = createApiClientMock(Promise.resolve(mercureInfo));
await loadMercureInfo(() => apiClientMock)()(dispatch, getState());
expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO, ...mercureInfo });
});
it('throws error on failure', async () => {
const error = 'Error';
const apiClientMock = createApiClientMock(Promise.reject(error));
await loadMercureInfo(() => apiClientMock)()(dispatch, getState());
expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO_ERROR });
});
});
});

View file

@ -40,6 +40,7 @@ describe('selectedServerReducer', () => {
}; };
const buildApiClient = jest.fn().mockReturnValue(apiClientMock); const buildApiClient = jest.fn().mockReturnValue(apiClientMock);
const dispatch = jest.fn(); const dispatch = jest.fn();
const loadMercureInfo = jest.fn();
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
@ -56,16 +57,17 @@ describe('selectedServerReducer', () => {
apiClientMock.health.mockResolvedValue({ version: serverVersion }); apiClientMock.health.mockResolvedValue({ version: serverVersion });
await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch); await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch);
expect(dispatch).toHaveBeenCalledTimes(3); expect(dispatch).toHaveBeenCalledTimes(4);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: RESET_SHORT_URL_PARAMS }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: RESET_SHORT_URL_PARAMS });
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
expect(loadMercureInfo).toHaveBeenCalledTimes(1);
}); });
it('invokes dependencies', async () => { it('invokes dependencies', async () => {
await selectServer(ServersServiceMock, buildApiClient)(uuid())(() => {}); await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(() => {});
expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1); expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1);
expect(buildApiClient).toHaveBeenCalledTimes(1); expect(buildApiClient).toHaveBeenCalledTimes(1);
@ -76,10 +78,11 @@ describe('selectedServerReducer', () => {
apiClientMock.health.mockRejectedValue({}); apiClientMock.health.mockRejectedValue({});
await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch); await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch);
expect(apiClientMock.health).toHaveBeenCalled(); expect(apiClientMock.health).toHaveBeenCalled();
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
expect(loadMercureInfo).not.toHaveBeenCalled();
}); });
it('dispatches error when server is not found', async () => { it('dispatches error when server is not found', async () => {
@ -87,11 +90,12 @@ describe('selectedServerReducer', () => {
ServersServiceMock.findServerById.mockReturnValue(undefined); ServersServiceMock.findServerById.mockReturnValue(undefined);
await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch); await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch);
expect(ServersServiceMock.findServerById).toHaveBeenCalled(); expect(ServersServiceMock.findServerById).toHaveBeenCalled();
expect(apiClientMock.health).not.toHaveBeenCalled(); expect(apiClientMock.health).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
expect(loadMercureInfo).not.toHaveBeenCalled();
}); });
}); });
}); });

View file

@ -36,13 +36,13 @@ describe('<ShortUrlsList />', () => {
}, },
] ]
} }
mercureInfo={{ loading: true }}
/> />
); );
}); });
afterEach(() => { afterEach(() => {
listShortUrlsMock.mockReset(); jest.resetAllMocks();
resetShortUrlParamsMock.mockReset();
wrapper && wrapper.unmount(); wrapper && wrapper.unmount();
}); });
@ -55,66 +55,41 @@ describe('<ShortUrlsList />', () => {
}); });
it('should render table header by default', () => { it('should render table header by default', () => {
expect(wrapper.find('table').shallow().find('thead')).toHaveLength(1); expect(wrapper.find('table').find('thead')).toHaveLength(1);
}); });
it('should render 6 table header cells by default', () => { it('should render 6 table header cells by default', () => {
expect(wrapper.find('table').shallow() expect(wrapper.find('table').find('thead').find('tr').find('th')).toHaveLength(6);
.find('thead').shallow()
.find('tr').shallow()
.find('th')).toHaveLength(6);
}); });
it('should render 6 table header cells without order by icon by default', () => { it('should render 6 table header cells without order by icon by default', () => {
const thElements = wrapper.find('table').shallow() const thElements = wrapper.find('table').find('thead').find('tr').find('th');
.find('thead').shallow()
.find('tr').shallow()
.find('th').map((e) => e.shallow());
for (const thElement of thElements) { thElements.forEach((thElement) => {
expect(thElement.find(FontAwesomeIcon)).toHaveLength(0); expect(thElement.find(FontAwesomeIcon)).toHaveLength(0);
} });
}); });
it('should render 6 table header cells with conditional order by icon', () => { it('should render 6 table header cells with conditional order by icon', () => {
const orderDirOptionToIconMap = { const getThElementForSortableField = (sortableField) => wrapper.find('table')
ASC: caretUpIcon, .find('thead')
DESC: caretDownIcon, .find('tr')
}; .find('th')
.filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField]));
for (const sortableField of Object.getOwnPropertyNames(SORTABLE_FIELDS)) { Object.keys(SORTABLE_FIELDS).forEach((sortableField) => {
wrapper.setState({ orderField: sortableField, orderDir: undefined }); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
const [ sortableThElement ] = wrapper.find('table').shallow()
.find('thead').shallow()
.find('tr').shallow()
.find('th')
.filterWhere(
(e) =>
e.text().includes(SORTABLE_FIELDS[sortableField])
);
const sortableThElementWrapper = shallow(sortableThElement); getThElementForSortableField(sortableField).simulate('click');
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretUpIcon);
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0); getThElementForSortableField(sortableField).simulate('click');
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretDownIcon);
for (const orderDir of Object.getOwnPropertyNames(orderDirOptionToIconMap)) { getThElementForSortableField(sortableField).simulate('click');
wrapper.setState({ orderField: sortableField, orderDir }); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
const [ sortableThElement ] = wrapper.find('table').shallow() });
.find('thead').shallow()
.find('tr').shallow()
.find('th')
.filterWhere(
(e) =>
e.text().includes(SORTABLE_FIELDS[sortableField])
);
const sortableThElementWrapper = shallow(sortableThElement);
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(1);
expect(
sortableThElementWrapper.find(FontAwesomeIcon).prop('icon')
).toEqual(orderDirOptionToIconMap[orderDir]);
}
}
}); });
}); });

View file

@ -20,7 +20,9 @@ describe('<ShortUrlVisitsCount />', () => {
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
expect(wrapper.html()).toEqual(`<span><strong>${visitsCount}</strong></span>`); expect(wrapper.html()).toEqual(
`<span><strong class="short-url-visits-count__amount">${visitsCount}</strong></span>`
);
expect(maxVisitsHelper).toHaveLength(0); expect(maxVisitsHelper).toHaveLength(0);
expect(maxVisitsTooltip).toHaveLength(0); expect(maxVisitsTooltip).toHaveLength(0);
}); });

View file

@ -52,7 +52,7 @@ describe('shortUrlCreationReducer', () => {
const dispatch = jest.fn(); const dispatch = jest.fn();
const getState = () => ({}); const getState = () => ({});
afterEach(() => dispatch.mockReset()); afterEach(jest.resetAllMocks);
it('calls API on success', async () => { it('calls API on success', async () => {
const result = 'foo'; const result = 'foo';

View file

@ -7,6 +7,7 @@ import reducer, {
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags'; import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion'; import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta'; import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta';
import { CREATE_SHORT_URL_VISIT } from '../../../src/visits/reducers/shortUrlVisits';
describe('shortUrlsListReducer', () => { describe('shortUrlsListReducer', () => {
describe('reducer', () => { describe('reducer', () => {
@ -31,7 +32,7 @@ describe('shortUrlsListReducer', () => {
error: true, error: true,
})); }));
it('Updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => { it('updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => {
const shortCode = 'abc123'; const shortCode = 'abc123';
const tags = [ 'foo', 'bar', 'baz' ]; const tags = [ 'foo', 'bar', 'baz' ];
const state = { const state = {
@ -55,7 +56,7 @@ describe('shortUrlsListReducer', () => {
}); });
}); });
it('Updates meta on matching URL on SHORT_URL_META_EDITED', () => { it('updates meta on matching URL on SHORT_URL_META_EDITED', () => {
const shortCode = 'abc123'; const shortCode = 'abc123';
const domain = 'example.com'; const domain = 'example.com';
const meta = { const meta = {
@ -83,7 +84,7 @@ describe('shortUrlsListReducer', () => {
}); });
}); });
it('Removes matching URL on SHORT_URL_DELETED', () => { it('removes matching URL on SHORT_URL_DELETED', () => {
const shortCode = 'abc123'; const shortCode = 'abc123';
const state = { const state = {
shortUrls: { shortUrls: {
@ -101,6 +102,33 @@ describe('shortUrlsListReducer', () => {
}, },
}); });
}); });
it('updates visits count on CREATE_SHORT_URL_VISIT', () => {
const shortCode = 'abc123';
const shortUrl = {
shortCode,
visitsCount: 11,
};
const state = {
shortUrls: {
data: [
{ shortCode, domain: 'example.com', visitsCount: 5 },
{ shortCode, visitsCount: 10 },
{ shortCode: 'foo', visitsCount: 8 },
],
},
};
expect(reducer(state, { type: CREATE_SHORT_URL_VISIT, shortUrl })).toEqual({
shortUrls: {
data: [
{ shortCode, domain: 'example.com', visitsCount: 5 },
{ shortCode, visitsCount: 11 },
{ shortCode: 'foo', visitsCount: 8 },
],
},
});
});
}); });
describe('listShortUrls', () => { describe('listShortUrls', () => {

View file

@ -209,4 +209,20 @@ describe('ShlinkApiClient', () => {
expect(result).toEqual(expectedData); expect(result).toEqual(expectedData);
}); });
}); });
describe('mercureInfo', () => {
it('returns mercure info', async () => {
const expectedData = {
token: 'abc.123.def',
mercureHubUrl: 'http://example.com/.well-known/mercure',
};
const axiosSpy = jest.fn(createAxiosMock({ data: expectedData }));
const { mercureInfo } = new ShlinkApiClient(axiosSpy);
const result = await mercureInfo();
expect(axiosSpy).toHaveBeenCalled();
expect(result).toEqual(expectedData);
});
});
}); });

View file

@ -26,7 +26,9 @@ describe('<VisitsHeader />', () => {
it('shows the amount of visits', () => { it('shows the amount of visits', () => {
const visitsBadge = wrapper.find('.badge'); const visitsBadge = wrapper.find('.badge');
expect(visitsBadge.html()).toContain(`Visits: <span><strong>${shortUrlVisits.visits.length}</strong></span>`); expect(visitsBadge.html()).toContain(
`Visits: <span><strong class="short-url-visits-count__amount">${shortUrlVisits.visits.length}</strong></span>`
);
}); });
it('shows when the URL was created', () => { it('shows when the URL was created', () => {

View file

@ -1,11 +1,13 @@
import reducer, { import reducer, {
getShortUrlVisits, getShortUrlVisits,
cancelGetShortUrlVisits, cancelGetShortUrlVisits,
createNewVisit,
GET_SHORT_URL_VISITS_START, GET_SHORT_URL_VISITS_START,
GET_SHORT_URL_VISITS_ERROR, GET_SHORT_URL_VISITS_ERROR,
GET_SHORT_URL_VISITS, GET_SHORT_URL_VISITS,
GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_LARGE,
GET_SHORT_URL_VISITS_CANCEL, GET_SHORT_URL_VISITS_CANCEL,
CREATE_SHORT_URL_VISIT,
} from '../../../src/visits/reducers/shortUrlVisits'; } from '../../../src/visits/reducers/shortUrlVisits';
describe('shortUrlVisitsReducer', () => { describe('shortUrlVisitsReducer', () => {
@ -48,6 +50,23 @@ describe('shortUrlVisitsReducer', () => {
expect(error).toEqual(false); expect(error).toEqual(false);
expect(visits).toEqual(actionVisits); expect(visits).toEqual(actionVisits);
}); });
it.each([
[{ shortCode: 'abc123' }, [{}, {}, {}]],
[{ shortCode: 'def456' }, [{}, {}]],
])('appends a new visit on CREATE_SHORT_URL_VISIT', (state, expectedVisits) => {
const shortUrl = {
shortCode: 'abc123',
};
const prevState = {
...state,
visits: [{}, {}],
};
const { visits } = reducer(prevState, { type: CREATE_SHORT_URL_VISIT, shortUrl, visit: {} });
expect(visits).toEqual(expectedVisits);
});
}); });
describe('getShortUrlVisits', () => { describe('getShortUrlVisits', () => {
@ -72,8 +91,13 @@ describe('shortUrlVisitsReducer', () => {
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1);
}); });
it('dispatches start and success when promise is resolved', async () => { it.each([
[ undefined, undefined ],
[{}, undefined ],
[{ domain: 'foobar.com' }, 'foobar.com' ],
])('dispatches start and success when promise is resolved', async (query, domain) => {
const visits = [{}, {}]; const visits = [{}, {}];
const shortCode = 'abc123';
const ShlinkApiClient = buildApiClientMock(Promise.resolve({ const ShlinkApiClient = buildApiClientMock(Promise.resolve({
data: visits, data: visits,
pagination: { pagination: {
@ -82,11 +106,11 @@ describe('shortUrlVisitsReducer', () => {
}, },
})); }));
await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); await getShortUrlVisits(() => ShlinkApiClient)(shortCode, query)(dispatchMock, getState);
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits }); expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits, shortCode, domain });
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1);
}); });
@ -114,4 +138,11 @@ describe('shortUrlVisitsReducer', () => {
it('just returns the action with proper type', () => it('just returns the action with proper type', () =>
expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL })); expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL }));
}); });
describe('createNewVisit', () => {
it('just returns the action with proper type', () =>
expect(createNewVisit({ shortUrl: {}, visit: {} })).toEqual(
{ type: CREATE_SHORT_URL_VISIT, shortUrl: {}, visit: {} }
));
});
}); });