From f3129399def21ebef38c033fd65aeddce054d792 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Apr 2020 17:11:52 +0200 Subject: [PATCH] Added EventSource connection to mercure hub possible --- package-lock.json | 5 ++ package.json | 1 + src/mercure/reducers/mercureInfo.js | 2 +- src/short-urls/ShortUrlsList.js | 32 ++++++++++ src/short-urls/reducers/shortUrlsList.js | 10 ++++ src/short-urls/services/provideServices.js | 4 +- src/visits/reducers/shortUrlVisits.js | 6 ++ src/visits/services/provideServices.js | 3 +- test/short-urls/ShortUrlsList.test.js | 69 +++++++++------------- 9 files changed, 86 insertions(+), 46 deletions(-) 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); + }); }); });