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/mercure/reducers/mercureInfo.js b/src/mercure/reducers/mercureInfo.js
index aa75d26a..e9f812d1 100644
--- a/src/mercure/reducers/mercureInfo.js
+++ b/src/mercure/reducers/mercureInfo.js
@@ -17,7 +17,7 @@ export const MercureInfoType = PropTypes.shape({
const initialState = {
token: undefined,
mercureHubUrl: undefined,
- loading: false,
+ loading: true,
error: false,
};
diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js
index 94d63894..3010aef1 100644
--- a/src/short-urls/ShortUrlsList.js
+++ b/src/short-urls/ShortUrlsList.js
@@ -4,9 +4,11 @@ import { head, isEmpty, keys, values } from 'ramda';
import React from 'react';
import qs from 'qs';
import PropTypes from 'prop-types';
+import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
import { serverType } from '../servers/prop-types';
import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils';
+import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss';
@@ -30,6 +32,8 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
error: PropTypes.bool,
shortUrlsList: PropTypes.arrayOf(shortUrlType),
selectedServer: serverType,
+ createNewVisit: PropTypes.func,
+ mercureInfo: MercureInfoType,
};
refreshList = (extraParams) => {
@@ -85,12 +89,40 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
this.refreshList({ page: params.page, tags });
}
+ componentDidUpdate() {
+ const { mercureHubUrl, token, loading, error } = this.props.mercureInfo;
+
+ if (loading || error) {
+ return;
+ }
+
+ const hubUrl = new URL(mercureHubUrl);
+
+ hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit');
+ this.closeEventSource();
+ this.es = new EventSource(hubUrl, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ this.es.onmessage = ({ data }) => this.props.createNewVisit(JSON.parse(data));
+ }
+
componentWillUnmount() {
const { resetShortUrlParams } = this.props;
+ this.closeEventSource();
resetShortUrlParams();
}
+ closeEventSource = () => {
+ if (this.es) {
+ this.es.close();
+ this.es = undefined;
+ }
+ }
+
renderShortUrls() {
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js
index a141dba2..b8b11aa4 100644
--- a/src/short-urls/reducers/shortUrlsList.js
+++ b/src/short-urls/reducers/shortUrlsList.js
@@ -1,6 +1,7 @@
import { handleActions } from 'redux-actions';
import { assoc, assocPath, isNil, reject } from 'ramda';
import PropTypes from 'prop-types';
+import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
@@ -56,6 +57,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.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..49cb9143 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' ]
));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js
index 2d4256d9..9c19d592 100644
--- a/src/visits/reducers/shortUrlVisits.js
+++ b/src/visits/reducers/shortUrlVisits.js
@@ -8,6 +8,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({
@@ -63,6 +64,9 @@ export default handleActions({
}),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
+
+ // TODO
+ [CREATE_SHORT_URL_VISIT]: (state) => state,
}, initialState);
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
@@ -125,3 +129,5 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) =>
};
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..babb432c 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';
@@ -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/short-urls/ShortUrlsList.test.js b/test/short-urls/ShortUrlsList.test.js
index b2d21a65..95017966 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,25 +55,19 @@ 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', () => {
@@ -81,40 +75,31 @@ describe('', () => {
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])
- );
-
- const sortableThElementWrapper = shallow(sortableThElement);
+ Object.keys(SORTABLE_FIELDS).forEach((sortableField) => {
+ const sortableThElementWrapper = getThElementForSortableField(sortableField);
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0);
- 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])
- );
+ sortableThElementWrapper.simulate('click');
+ expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
+ expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(
+ orderDirOptionToIconMap.ASC,
+ );
- const sortableThElementWrapper = shallow(sortableThElement);
+ sortableThElementWrapper.simulate('click');
+ expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
+ expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(
+ orderDirOptionToIconMap.DESC,
+ );
- expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(1);
- expect(
- sortableThElementWrapper.find(FontAwesomeIcon).prop('icon')
- ).toEqual(orderDirOptionToIconMap[orderDir]);
- }
- }
+ sortableThElementWrapper.simulate('click');
+ expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0);
+ });
});
});