diff --git a/CHANGELOG.md b/CHANGELOG.md
index 283f67bc..ad9b23ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
+## [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
#### Added
diff --git a/package-lock.json b/package-lock.json
index bec22699..1a22485b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6641,6 +6641,11 @@
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
diff --git a/package.json b/package.json
index 669cf8fd..ab2033c1 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"classnames": "^2.2.6",
"compare-versions": "^3.5.1",
"csvjson": "^5.1.0",
+ "event-source-polyfill": "^1.0.12",
"leaflet": "^1.5.1",
"moment": "^2.24.0",
"promise": "^8.0.3",
diff --git a/src/container/index.js b/src/container/index.js
index 771a46e8..d634dffb 100644
--- a/src/container/index.js
+++ b/src/container/index.js
@@ -9,6 +9,7 @@ import provideServersServices from '../servers/services/provideServices';
import provideVisitsServices from '../visits/services/provideServices';
import provideTagsServices from '../tags/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices';
+import provideMercureServices from '../mercure/services/provideServices';
const bottle = new Bottle();
const { container } = bottle;
@@ -34,5 +35,6 @@ provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);
+provideMercureServices(bottle);
export default container;
diff --git a/src/mercure/helpers/index.js b/src/mercure/helpers/index.js
new file mode 100644
index 00000000..0cdd367c
--- /dev/null
+++ b/src/mercure/helpers/index.js
@@ -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();
+};
diff --git a/src/mercure/reducers/mercureInfo.js b/src/mercure/reducers/mercureInfo.js
new file mode 100644
index 00000000..e9f812d1
--- /dev/null
+++ b/src/mercure/reducers/mercureInfo.js
@@ -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 });
+ }
+};
diff --git a/src/mercure/services/provideServices.js b/src/mercure/services/provideServices.js
new file mode 100644
index 00000000..152ebe4a
--- /dev/null
+++ b/src/mercure/services/provideServices.js
@@ -0,0 +1,8 @@
+import { loadMercureInfo } from '../reducers/mercureInfo';
+
+const provideServices = (bottle) => {
+ // Actions
+ bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
+};
+
+export default provideServices;
diff --git a/src/reducers/index.js b/src/reducers/index.js
index 2d80d488..8b96ede8 100644
--- a/src/reducers/index.js
+++ b/src/reducers/index.js
@@ -13,6 +13,7 @@ import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit';
+import mercureInfoReducer from '../mercure/reducers/mercureInfo';
export default combineReducers({
servers: serversReducer,
@@ -29,4 +30,5 @@ export default combineReducers({
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
tagEdit: tagEditReducer,
+ mercureInfo: mercureInfoReducer,
});
diff --git a/src/servers/reducers/selectedServer.js b/src/servers/reducers/selectedServer.js
index bd62181b..64d94ae1 100644
--- a/src/servers/reducers/selectedServer.js
+++ b/src/servers/reducers/selectedServer.js
@@ -25,7 +25,9 @@ const getServerVersion = memoizeWith(identity, (serverId, health) => health().th
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(resetShortUrlParams());
const selectedServer = findServerById(serverId);
@@ -51,6 +53,7 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
printableVersion,
},
});
+ dispatch(loadMercureInfo());
} catch (e) {
dispatch({
type: SELECT_SERVER,
diff --git a/src/servers/services/provideServices.js b/src/servers/services/provideServices.js
index 0516f665..ce325ffd 100644
--- a/src/servers/services/provideServices.js
+++ b/src/servers/services/provideServices.js
@@ -47,7 +47,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions
- bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
+ bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient', 'loadMercureInfo');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js
index 94d63894..9df2250b 100644
--- a/src/short-urls/ShortUrlsList.js
+++ b/src/short-urls/ShortUrlsList.js
@@ -1,12 +1,14 @@
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, values } from 'ramda';
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import qs from 'qs';
import PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils';
+import { MercureInfoType } from '../mercure/reducers/mercureInfo';
+import { bindToMercureTopic } from '../mercure/helpers';
import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss';
@@ -18,118 +20,115 @@ export const SORTABLE_FIELDS = {
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)
-const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
- static 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,
- };
-
- refreshList = (extraParams) => {
- const { listShortUrls, shortUrlsListParams } = this.props;
-
- listShortUrls({
- ...shortUrlsListParams,
- ...extraParams,
+const ShortUrlsList = (ShortUrlsRow) => {
+ const ShortUrlsListComp = ({
+ listShortUrls,
+ resetShortUrlParams,
+ shortUrlsListParams,
+ match,
+ location,
+ loading,
+ error,
+ shortUrlsList,
+ selectedServer,
+ createNewVisit,
+ loadMercureInfo,
+ mercureInfo,
+ }) => {
+ const { orderBy } = shortUrlsListParams;
+ const [ order, setOrder ] = useState({
+ orderField: orderBy && head(keys(orderBy)),
+ orderDir: orderBy && head(values(orderBy)),
});
- };
-
- handleOrderBy = (orderField, orderDir) => {
- this.setState({ 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 (
-
- );
- };
-
- constructor(props) {
- super(props);
-
- const { orderBy } = props.shortUrlsListParams;
-
- this.state = {
- orderField: orderBy ? head(keys(orderBy)) : undefined,
- orderDir: orderBy ? head(values(orderBy)) : undefined,
+ const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
+ const handleOrderBy = (orderField, orderDir) => {
+ setOrder({ orderField, orderDir });
+ refreshList({ orderBy: { [orderField]: orderDir } });
};
- }
+ const orderByColumn = (columnName) => () =>
+ handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir));
+ const renderOrderIcon = (field) => {
+ if (order.orderField !== field) {
+ return null;
+ }
- componentDidMount() {
- const { match: { params }, location, shortUrlsListParams } = this.props;
- const query = qs.parse(location.search, { ignoreQueryPrefix: true });
- const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
+ if (!order.orderDir) {
+ return null;
+ }
- this.refreshList({ page: params.page, tags });
- }
-
- componentWillUnmount() {
- const { resetShortUrlParams } = this.props;
-
- resetShortUrlParams();
- }
-
- renderShortUrls() {
- const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
-
- if (error) {
return (
-
@@ -69,6 +80,7 @@ const ShortUrlsRow = (
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
+ active={active}
/>
|
diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss
index 7a888cf3..914826ee 100644
--- a/src/short-urls/helpers/ShortUrlsRow.scss
+++ b/src/short-urls/helpers/ShortUrlsRow.scss
@@ -35,6 +35,7 @@
}
}
}
+
.short-urls-row__cell--break {
word-break: break-all;
}
@@ -43,6 +44,10 @@
position: relative;
}
+.short-urls-row__cell--big {
+ transform: scale(1.5);
+}
+
.short-urls-row__copy-btn {
cursor: pointer;
font-size: 1.2rem;
diff --git a/src/short-urls/helpers/index.js b/src/short-urls/helpers/index.js
new file mode 100644
index 00000000..32a12ad9
--- /dev/null
+++ b/src/short-urls/helpers/index.js
@@ -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;
+};
diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js
index a141dba2..b7d346cc 100644
--- a/src/short-urls/reducers/shortUrlsList.js
+++ b/src/short-urls/reducers/shortUrlsList.js
@@ -1,6 +1,8 @@
import { handleActions } from 'redux-actions';
-import { assoc, assocPath, isNil, reject } from 'ramda';
+import { assoc, assocPath, reject } from 'ramda';
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_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
@@ -28,14 +30,6 @@ const initialState = {
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(
[ 'shortUrls', 'data' ],
state.shortUrls.data.map(
@@ -56,6 +50,15 @@ export default handleActions({
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
[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);
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
diff --git a/src/short-urls/services/provideServices.js b/src/short-urls/services/provideServices.js
index 0dc79a73..385b0a1c 100644
--- a/src/short-urls/services/provideServices.js
+++ b/src/short-urls/services/provideServices.js
@@ -31,8 +31,8 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect(
- [ 'selectedServer', 'shortUrlsListParams' ],
- [ 'listShortUrls', 'resetShortUrlParams' ]
+ [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
+ [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
diff --git a/src/utils/helpers/hooks.js b/src/utils/helpers/hooks.js
index 81a60517..d0852714 100644
--- a/src/utils/helpers/hooks.js
+++ b/src/utils/helpers/hooks.js
@@ -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 timeout = useRef(undefined);
const callback = () => {
setFlag(!initialValue);
- setTimeout(() => setFlag(initialValue), delay);
+
+ if (timeout.current) {
+ clearTimeout(timeout.current);
+ }
+
+ timeout.current = setTimeout(() => setFlag(initialValue), delay);
};
return [ flag, callback ];
diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js
index fc9d5652..8ba12ff8 100644
--- a/src/utils/services/ShlinkApiClient.js
+++ b/src/utils/services/ShlinkApiClient.js
@@ -66,6 +66,8 @@ export default class ShlinkApiClient {
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 = {}) => {
try {
return await this.axios({
diff --git a/src/utils/services/provideServices.js b/src/utils/services/provideServices.js
index 1760c88c..d4068614 100644
--- a/src/utils/services/provideServices.js
+++ b/src/utils/services/provideServices.js
@@ -14,8 +14,9 @@ const provideServices = (bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
bottle.constant('setTimeout', global.setTimeout);
+ bottle.constant('clearTimeout', global.clearTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
- bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout');
+ bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout');
};
export default provideServices;
diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js
index 0d279ae2..d253b486 100644
--- a/src/visits/ShortUrlVisits.js
+++ b/src/visits/ShortUrlVisits.js
@@ -10,6 +10,8 @@ import DateRangeRow from '../utils/DateRangeRow';
import Message from '../utils/Message';
import { formatDate } from '../utils/helpers/date';
import { useToggle } from '../utils/helpers/hooks';
+import { MercureInfoType } from '../mercure/reducers/mercureInfo';
+import { bindToMercureTopic } from '../mercure/helpers';
import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader';
@@ -30,6 +32,9 @@ const propTypes = {
shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func,
matchMedia: PropTypes.func,
+ createNewVisit: PropTypes.func,
+ loadMercureInfo: PropTypes.func,
+ mercureInfo: MercureInfoType,
};
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
@@ -54,6 +59,9 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
getShortUrlDetail,
cancelGetShortUrlVisits,
matchMedia = window.matchMedia,
+ createNewVisit,
+ loadMercureInfo,
+ mercureInfo,
}) => {
const [ startDate, setStartDate ] = useState(undefined);
const [ endDate, setEndDate ] = useState(undefined);
@@ -108,6 +116,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
useEffect(() => {
loadVisits();
}, [ startDate, endDate ]);
+ useEffect(
+ bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit, loadMercureInfo),
+ [ mercureInfo ],
+ );
const renderVisitsContent = () => {
if (loading) {
diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js
index 2d4256d9..f3be7e33 100644
--- a/src/visits/reducers/shortUrlVisits.js
+++ b/src/visits/reducers/shortUrlVisits.js
@@ -1,6 +1,7 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { flatten, prop, range, splitEvery } from 'ramda';
+import { shortUrlMatches } from '../../short-urls/helpers';
/* eslint-disable padding-line-between-statements */
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_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
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 */
export const visitType = PropTypes.shape({
@@ -28,12 +30,16 @@ export const visitType = PropTypes.shape({
export const shortUrlVisitsType = PropTypes.shape({
visits: PropTypes.arrayOf(visitType),
+ shortCode: PropTypes.string,
+ domain: PropTypes.string,
loading: PropTypes.bool,
error: PropTypes.bool,
});
const initialState = {
visits: [],
+ shortCode: '',
+ domain: undefined,
loading: false,
loadingLarge: false,
error: false,
@@ -54,8 +60,10 @@ export default handleActions({
error: true,
cancelLoad: false,
}),
- [GET_SHORT_URL_VISITS]: (state, { visits }) => ({
+ [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({
visits,
+ shortCode,
+ domain,
loading: false,
loadingLarge: false,
error: false,
@@ -63,9 +71,18 @@ export default handleActions({
}),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: 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);
-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 });
const { getShortUrlVisits } = buildShlinkApiClient(getState);
const itemsPerPage = 5000;
@@ -118,10 +135,12 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) =>
try {
const visits = await loadVisits();
- dispatch({ visits, type: GET_SHORT_URL_VISITS });
+ dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS });
} catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
}
};
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
+
+export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_SHORT_URL_VISIT });
diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js
index 6258adda..f2363f67 100644
--- a/src/visits/services/provideServices.js
+++ b/src/visits/services/provideServices.js
@@ -1,5 +1,5 @@
import ShortUrlVisits from '../ShortUrlVisits';
-import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
+import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
import MapModal from '../helpers/MapModal';
@@ -11,8 +11,8 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
bottle.decorator('ShortUrlVisits', connect(
- [ 'shortUrlVisits', 'shortUrlDetail' ],
- [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ]
+ [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
+ [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
));
// Services
@@ -22,6 +22,7 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
+ bottle.serviceFactory('createNewVisit', () => createNewVisit);
};
export default provideServices;
diff --git a/test/mercure/helpers/index.test.js b/test/mercure/helpers/index.test.js
new file mode 100644
index 00000000..1e6fd3df
--- /dev/null
+++ b/test/mercure/helpers/index.test.js
@@ -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();
+ });
+ });
+});
diff --git a/test/mercure/reducers/mercureInfo.test.js b/test/mercure/reducers/mercureInfo.test.js
new file mode 100644
index 00000000..fa93636e
--- /dev/null
+++ b/test/mercure/reducers/mercureInfo.test.js
@@ -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 });
+ });
+ });
+});
diff --git a/test/servers/reducers/selectedServer.test.js b/test/servers/reducers/selectedServer.test.js
index e073f183..2efd8291 100644
--- a/test/servers/reducers/selectedServer.test.js
+++ b/test/servers/reducers/selectedServer.test.js
@@ -40,6 +40,7 @@ describe('selectedServerReducer', () => {
};
const buildApiClient = jest.fn().mockReturnValue(apiClientMock);
const dispatch = jest.fn();
+ const loadMercureInfo = jest.fn();
afterEach(jest.clearAllMocks);
@@ -56,16 +57,17 @@ describe('selectedServerReducer', () => {
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(2, { type: RESET_SHORT_URL_PARAMS });
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
+ expect(loadMercureInfo).toHaveBeenCalledTimes(1);
});
it('invokes dependencies', async () => {
- await selectServer(ServersServiceMock, buildApiClient)(uuid())(() => {});
+ await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(() => {});
expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1);
expect(buildApiClient).toHaveBeenCalledTimes(1);
@@ -76,10 +78,11 @@ describe('selectedServerReducer', () => {
apiClientMock.health.mockRejectedValue({});
- await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch);
+ await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch);
expect(apiClientMock.health).toHaveBeenCalled();
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
+ expect(loadMercureInfo).not.toHaveBeenCalled();
});
it('dispatches error when server is not found', async () => {
@@ -87,11 +90,12 @@ describe('selectedServerReducer', () => {
ServersServiceMock.findServerById.mockReturnValue(undefined);
- await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch);
+ await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch);
expect(ServersServiceMock.findServerById).toHaveBeenCalled();
expect(apiClientMock.health).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
+ expect(loadMercureInfo).not.toHaveBeenCalled();
});
});
});
diff --git a/test/short-urls/ShortUrlsList.test.js b/test/short-urls/ShortUrlsList.test.js
index b2d21a65..050238b9 100644
--- a/test/short-urls/ShortUrlsList.test.js
+++ b/test/short-urls/ShortUrlsList.test.js
@@ -36,13 +36,13 @@ describe('', () => {
},
]
}
+ mercureInfo={{ loading: true }}
/>
);
});
afterEach(() => {
- listShortUrlsMock.mockReset();
- resetShortUrlParamsMock.mockReset();
+ jest.resetAllMocks();
wrapper && wrapper.unmount();
});
@@ -55,66 +55,41 @@ describe('', () => {
});
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', () => {
- expect(wrapper.find('table').shallow()
- .find('thead').shallow()
- .find('tr').shallow()
- .find('th')).toHaveLength(6);
+ expect(wrapper.find('table').find('thead').find('tr').find('th')).toHaveLength(6);
});
it('should render 6 table header cells without order by icon by default', () => {
- const thElements = wrapper.find('table').shallow()
- .find('thead').shallow()
- .find('tr').shallow()
- .find('th').map((e) => e.shallow());
+ const thElements = wrapper.find('table').find('thead').find('tr').find('th');
- for (const thElement of thElements) {
+ thElements.forEach((thElement) => {
expect(thElement.find(FontAwesomeIcon)).toHaveLength(0);
- }
+ });
});
it('should render 6 table header cells with conditional order by icon', () => {
- const orderDirOptionToIconMap = {
- ASC: caretUpIcon,
- DESC: caretDownIcon,
- };
+ const getThElementForSortableField = (sortableField) => wrapper.find('table')
+ .find('thead')
+ .find('tr')
+ .find('th')
+ .filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField]));
- for (const sortableField of Object.getOwnPropertyNames(SORTABLE_FIELDS)) {
- wrapper.setState({ orderField: sortableField, orderDir: undefined });
- const [ sortableThElement ] = wrapper.find('table').shallow()
- .find('thead').shallow()
- .find('tr').shallow()
- .find('th')
- .filterWhere(
- (e) =>
- e.text().includes(SORTABLE_FIELDS[sortableField])
- );
+ Object.keys(SORTABLE_FIELDS).forEach((sortableField) => {
+ expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
- 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)) {
- wrapper.setState({ orderField: sortableField, orderDir });
- 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]);
- }
- }
+ getThElementForSortableField(sortableField).simulate('click');
+ expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
+ });
});
});
diff --git a/test/short-urls/helpers/ShortUrlVisitsCount.test.js b/test/short-urls/helpers/ShortUrlVisitsCount.test.js
index 0e9716f7..f12d3fe3 100644
--- a/test/short-urls/helpers/ShortUrlVisitsCount.test.js
+++ b/test/short-urls/helpers/ShortUrlVisitsCount.test.js
@@ -20,7 +20,9 @@ describe('', () => {
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
- expect(wrapper.html()).toEqual(`${visitsCount}`);
+ expect(wrapper.html()).toEqual(
+ `${visitsCount}`
+ );
expect(maxVisitsHelper).toHaveLength(0);
expect(maxVisitsTooltip).toHaveLength(0);
});
diff --git a/test/short-urls/reducers/shortUrlCreation.test.js b/test/short-urls/reducers/shortUrlCreation.test.js
index d373f972..0ebacbe5 100644
--- a/test/short-urls/reducers/shortUrlCreation.test.js
+++ b/test/short-urls/reducers/shortUrlCreation.test.js
@@ -52,7 +52,7 @@ describe('shortUrlCreationReducer', () => {
const dispatch = jest.fn();
const getState = () => ({});
- afterEach(() => dispatch.mockReset());
+ afterEach(jest.resetAllMocks);
it('calls API on success', async () => {
const result = 'foo';
diff --git a/test/short-urls/reducers/shortUrlsList.test.js b/test/short-urls/reducers/shortUrlsList.test.js
index 00a4e6d8..c8e725c2 100644
--- a/test/short-urls/reducers/shortUrlsList.test.js
+++ b/test/short-urls/reducers/shortUrlsList.test.js
@@ -7,6 +7,7 @@ import reducer, {
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta';
+import { CREATE_SHORT_URL_VISIT } from '../../../src/visits/reducers/shortUrlVisits';
describe('shortUrlsListReducer', () => {
describe('reducer', () => {
@@ -31,7 +32,7 @@ describe('shortUrlsListReducer', () => {
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 tags = [ 'foo', 'bar', 'baz' ];
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 domain = 'example.com';
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 state = {
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', () => {
diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js
index 1f35d268..5b41516b 100644
--- a/test/utils/services/ShlinkApiClient.test.js
+++ b/test/utils/services/ShlinkApiClient.test.js
@@ -209,4 +209,20 @@ describe('ShlinkApiClient', () => {
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);
+ });
+ });
});
diff --git a/test/visits/VisitsHeader.test.js b/test/visits/VisitsHeader.test.js
index ba258418..3d4b48dd 100644
--- a/test/visits/VisitsHeader.test.js
+++ b/test/visits/VisitsHeader.test.js
@@ -26,7 +26,9 @@ describe('', () => {
it('shows the amount of visits', () => {
const visitsBadge = wrapper.find('.badge');
- expect(visitsBadge.html()).toContain(`Visits: ${shortUrlVisits.visits.length}`);
+ expect(visitsBadge.html()).toContain(
+ `Visits: ${shortUrlVisits.visits.length}`
+ );
});
it('shows when the URL was created', () => {
diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js
index 9bc285ca..5e2544f9 100644
--- a/test/visits/reducers/shortUrlVisits.test.js
+++ b/test/visits/reducers/shortUrlVisits.test.js
@@ -1,11 +1,13 @@
import reducer, {
getShortUrlVisits,
cancelGetShortUrlVisits,
+ createNewVisit,
GET_SHORT_URL_VISITS_START,
GET_SHORT_URL_VISITS_ERROR,
GET_SHORT_URL_VISITS,
GET_SHORT_URL_VISITS_LARGE,
GET_SHORT_URL_VISITS_CANCEL,
+ CREATE_SHORT_URL_VISIT,
} from '../../../src/visits/reducers/shortUrlVisits';
describe('shortUrlVisitsReducer', () => {
@@ -48,6 +50,23 @@ describe('shortUrlVisitsReducer', () => {
expect(error).toEqual(false);
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', () => {
@@ -72,8 +91,13 @@ describe('shortUrlVisitsReducer', () => {
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 shortCode = 'abc123';
const ShlinkApiClient = buildApiClientMock(Promise.resolve({
data: visits,
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).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);
});
@@ -114,4 +138,11 @@ describe('shortUrlVisitsReducer', () => {
it('just returns the action with proper type', () =>
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: {} }
+ ));
+ });
});
|