mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #251 from acelaya-forks/feature/real-time-updates
Feature/real time updates
This commit is contained in:
commit
aa59a95f91
34 changed files with 598 additions and 203 deletions
28
CHANGELOG.md
28
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).
|
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
5
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
23
src/mercure/helpers/index.js
Normal file
23
src/mercure/helpers/index.js
Normal 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();
|
||||||
|
};
|
41
src/mercure/reducers/mercureInfo.js
Normal file
41
src/mercure/reducers/mercureInfo.js
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
8
src/mercure/services/provideServices.js
Normal file
8
src/mercure/services/provideServices.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { loadMercureInfo } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
|
const provideServices = (bottle) => {
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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"> </th>
|
<th className="short-urls-list__header-cell"> </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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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;
|
||||||
|
|
9
src/short-urls/helpers/index.js
Normal file
9
src/short-urls/helpers/index.js
Normal 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;
|
||||||
|
};
|
|
@ -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) => {
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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 ];
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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;
|
||||||
|
|
57
test/mercure/helpers/index.test.js
Normal file
57
test/mercure/helpers/index.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
70
test/mercure/reducers/mercureInfo.test.js
Normal file
70
test/mercure/reducers/mercureInfo.test.js
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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: {} }
|
||||||
|
));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue