From 37e6c2746177a037d5a8ae58adca910f97f8c46b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Apr 2020 15:51:18 +0200 Subject: [PATCH 1/9] Created mercure info reducer and loaded info when server is reachable --- src/container/index.js | 2 + src/mercure/reducers/mercureInfo.js | 41 ++++++++++++++++++++ src/mercure/services/provideServices.js | 8 ++++ src/reducers/index.js | 2 + src/servers/reducers/selectedServer.js | 5 ++- src/servers/services/provideServices.js | 2 +- src/utils/services/ShlinkApiClient.js | 2 + test/servers/reducers/selectedServer.test.js | 14 ++++--- test/utils/services/ShlinkApiClient.test.js | 16 ++++++++ 9 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 src/mercure/reducers/mercureInfo.js create mode 100644 src/mercure/services/provideServices.js diff --git a/src/container/index.js b/src/container/index.js index 771a46e8..d634dffb 100644 --- a/src/container/index.js +++ b/src/container/index.js @@ -9,6 +9,7 @@ import provideServersServices from '../servers/services/provideServices'; import provideVisitsServices from '../visits/services/provideServices'; import provideTagsServices from '../tags/services/provideServices'; import provideUtilsServices from '../utils/services/provideServices'; +import provideMercureServices from '../mercure/services/provideServices'; const bottle = new Bottle(); const { container } = bottle; @@ -34,5 +35,6 @@ provideServersServices(bottle, connect, withRouter); provideTagsServices(bottle, connect); provideVisitsServices(bottle, connect); provideUtilsServices(bottle); +provideMercureServices(bottle); export default container; diff --git a/src/mercure/reducers/mercureInfo.js b/src/mercure/reducers/mercureInfo.js new file mode 100644 index 00000000..aa75d26a --- /dev/null +++ b/src/mercure/reducers/mercureInfo.js @@ -0,0 +1,41 @@ +import { handleActions } from 'redux-actions'; +import PropTypes from 'prop-types'; + +/* eslint-disable padding-line-between-statements */ +export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START'; +export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR'; +export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO'; +/* eslint-enable padding-line-between-statements */ + +export const MercureInfoType = PropTypes.shape({ + token: PropTypes.string, + mercureHubUrl: PropTypes.string, + loading: PropTypes.bool, + error: PropTypes.bool, +}); + +const initialState = { + token: undefined, + mercureHubUrl: undefined, + loading: false, + error: false, +}; + +export default handleActions({ + [GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }), + [GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }), + [GET_MERCURE_INFO]: (state, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }), +}, initialState); + +export const loadMercureInfo = (buildShlinkApiClient) => () => async (dispatch, getState) => { + dispatch({ type: GET_MERCURE_INFO_START }); + const { mercureInfo } = buildShlinkApiClient(getState); + + try { + const result = await mercureInfo(); + + dispatch({ type: GET_MERCURE_INFO, ...result }); + } catch (e) { + dispatch({ type: GET_MERCURE_INFO_ERROR }); + } +}; diff --git a/src/mercure/services/provideServices.js b/src/mercure/services/provideServices.js new file mode 100644 index 00000000..152ebe4a --- /dev/null +++ b/src/mercure/services/provideServices.js @@ -0,0 +1,8 @@ +import { loadMercureInfo } from '../reducers/mercureInfo'; + +const provideServices = (bottle) => { + // Actions + bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient'); +}; + +export default provideServices; diff --git a/src/reducers/index.js b/src/reducers/index.js index 2d80d488..8b96ede8 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -13,6 +13,7 @@ import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; import tagEditReducer from '../tags/reducers/tagEdit'; +import mercureInfoReducer from '../mercure/reducers/mercureInfo'; export default combineReducers({ servers: serversReducer, @@ -29,4 +30,5 @@ export default combineReducers({ tagsList: tagsListReducer, tagDelete: tagDeleteReducer, tagEdit: tagEditReducer, + mercureInfo: mercureInfoReducer, }); diff --git a/src/servers/reducers/selectedServer.js b/src/servers/reducers/selectedServer.js index bd62181b..64d94ae1 100644 --- a/src/servers/reducers/selectedServer.js +++ b/src/servers/reducers/selectedServer.js @@ -25,7 +25,9 @@ const getServerVersion = memoizeWith(identity, (serverId, health) => health().th export const resetSelectedServer = createAction(RESET_SELECTED_SERVER); -export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => { +export const selectServer = ({ findServerById }, buildShlinkApiClient, loadMercureInfo) => (serverId) => async ( + dispatch +) => { dispatch(resetSelectedServer()); dispatch(resetShortUrlParams()); const selectedServer = findServerById(serverId); @@ -51,6 +53,7 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve printableVersion, }, }); + dispatch(loadMercureInfo()); } catch (e) { dispatch({ type: SELECT_SERVER, diff --git a/src/servers/services/provideServices.js b/src/servers/services/provideServices.js index 0516f665..ce325ffd 100644 --- a/src/servers/services/provideServices.js +++ b/src/servers/services/provideServices.js @@ -47,7 +47,7 @@ const provideServices = (bottle, connect, withRouter) => { bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson'); // Actions - bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient'); + bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient', 'loadMercureInfo'); bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers'); bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers'); bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers'); diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js index fc9d5652..8ba12ff8 100644 --- a/src/utils/services/ShlinkApiClient.js +++ b/src/utils/services/ShlinkApiClient.js @@ -66,6 +66,8 @@ export default class ShlinkApiClient { health = () => this._performRequest('/health', 'GET').then((resp) => resp.data); + mercureInfo = () => this._performRequest('/mercure-info', 'GET').then((resp) => resp.data); + _performRequest = async (url, method = 'GET', query = {}, body = {}) => { try { return await this.axios({ diff --git a/test/servers/reducers/selectedServer.test.js b/test/servers/reducers/selectedServer.test.js index e073f183..2efd8291 100644 --- a/test/servers/reducers/selectedServer.test.js +++ b/test/servers/reducers/selectedServer.test.js @@ -40,6 +40,7 @@ describe('selectedServerReducer', () => { }; const buildApiClient = jest.fn().mockReturnValue(apiClientMock); const dispatch = jest.fn(); + const loadMercureInfo = jest.fn(); afterEach(jest.clearAllMocks); @@ -56,16 +57,17 @@ describe('selectedServerReducer', () => { apiClientMock.health.mockResolvedValue({ version: serverVersion }); - await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch); + await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch); - expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenCalledTimes(4); expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: RESET_SHORT_URL_PARAMS }); expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); + expect(loadMercureInfo).toHaveBeenCalledTimes(1); }); it('invokes dependencies', async () => { - await selectServer(ServersServiceMock, buildApiClient)(uuid())(() => {}); + await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(() => {}); expect(ServersServiceMock.findServerById).toHaveBeenCalledTimes(1); expect(buildApiClient).toHaveBeenCalledTimes(1); @@ -76,10 +78,11 @@ describe('selectedServerReducer', () => { apiClientMock.health.mockRejectedValue({}); - await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch); + await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch); expect(apiClientMock.health).toHaveBeenCalled(); expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); + expect(loadMercureInfo).not.toHaveBeenCalled(); }); it('dispatches error when server is not found', async () => { @@ -87,11 +90,12 @@ describe('selectedServerReducer', () => { ServersServiceMock.findServerById.mockReturnValue(undefined); - await selectServer(ServersServiceMock, buildApiClient)(uuid())(dispatch); + await selectServer(ServersServiceMock, buildApiClient, loadMercureInfo)(uuid())(dispatch); expect(ServersServiceMock.findServerById).toHaveBeenCalled(); expect(apiClientMock.health).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); + expect(loadMercureInfo).not.toHaveBeenCalled(); }); }); }); diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js index 1f35d268..5b41516b 100644 --- a/test/utils/services/ShlinkApiClient.test.js +++ b/test/utils/services/ShlinkApiClient.test.js @@ -209,4 +209,20 @@ describe('ShlinkApiClient', () => { expect(result).toEqual(expectedData); }); }); + + describe('mercureInfo', () => { + it('returns mercure info', async () => { + const expectedData = { + token: 'abc.123.def', + mercureHubUrl: 'http://example.com/.well-known/mercure', + }; + const axiosSpy = jest.fn(createAxiosMock({ data: expectedData })); + const { mercureInfo } = new ShlinkApiClient(axiosSpy); + + const result = await mercureInfo(); + + expect(axiosSpy).toHaveBeenCalled(); + expect(result).toEqual(expectedData); + }); + }); }); From f3129399def21ebef38c033fd65aeddce054d792 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Apr 2020 17:11:52 +0200 Subject: [PATCH 2/9] 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); + }); }); }); From 0f73cb9f8caa2af82f8e0e7cd136a669d70fb885 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Apr 2020 17:39:30 +0200 Subject: [PATCH 3/9] Converted short URLs list in functional component --- src/short-urls/ShortUrlsList.js | 260 ++++++++++++-------------- test/short-urls/ShortUrlsList.test.js | 24 +-- 2 files changed, 130 insertions(+), 154 deletions(-) diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 3010aef1..460106d2 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -1,7 +1,7 @@ import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { head, isEmpty, keys, values } from 'ramda'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import qs from 'qs'; import PropTypes from 'prop-types'; import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; @@ -20,148 +20,130 @@ export const SORTABLE_FIELDS = { visits: 'Visits', }; +const propTypes = { + listShortUrls: PropTypes.func, + resetShortUrlParams: PropTypes.func, + shortUrlsListParams: shortUrlsListParamsType, + match: PropTypes.object, + location: PropTypes.object, + loading: PropTypes.bool, + error: PropTypes.bool, + shortUrlsList: PropTypes.arrayOf(shortUrlType), + selectedServer: serverType, + createNewVisit: PropTypes.func, + mercureInfo: MercureInfoType, +}; + // FIXME Replace with typescript: (ShortUrlsRow component) -const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component { - static propTypes = { - listShortUrls: PropTypes.func, - resetShortUrlParams: PropTypes.func, - shortUrlsListParams: shortUrlsListParamsType, - match: PropTypes.object, - location: PropTypes.object, - loading: PropTypes.bool, - error: PropTypes.bool, - shortUrlsList: PropTypes.arrayOf(shortUrlType), - selectedServer: serverType, - createNewVisit: PropTypes.func, - mercureInfo: MercureInfoType, - }; - - refreshList = (extraParams) => { - const { listShortUrls, shortUrlsListParams } = this.props; - - listShortUrls({ - ...shortUrlsListParams, - ...extraParams, +const ShortUrlsList = (ShortUrlsRow) => { + const ShortUrlsListComp = ({ + listShortUrls, + resetShortUrlParams, + shortUrlsListParams, + match, + location, + loading, + error, + shortUrlsList, + selectedServer, + createNewVisit, + mercureInfo, + }) => { + const { orderBy } = shortUrlsListParams; + const [ order, setOrder ] = useState({ + orderField: orderBy && head(keys(orderBy)), + orderDir: orderBy && head(values(orderBy)), }); - }; - - handleOrderBy = (orderField, orderDir) => { - this.setState({ orderField, orderDir }); - this.refreshList({ orderBy: { [orderField]: orderDir } }); - }; - - orderByColumn = (columnName) => () => - this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir)); - - renderOrderIcon = (field) => { - if (this.state.orderField !== field) { - return null; - } - - if (!this.state.orderDir) { - return null; - } - - return ( - - ); - }; - - constructor(props) { - super(props); - - const { orderBy } = props.shortUrlsListParams; - - this.state = { - orderField: orderBy ? head(keys(orderBy)) : undefined, - orderDir: orderBy ? head(values(orderBy)) : undefined, + const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams }); + const handleOrderBy = (orderField, orderDir) => { + setOrder({ orderField, orderDir }); + refreshList({ orderBy: { [orderField]: orderDir } }); }; - } + const orderByColumn = (columnName) => () => + handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir)); + const renderOrderIcon = (field) => { + if (order.orderField !== field) { + return null; + } - componentDidMount() { - const { match: { params }, location, shortUrlsListParams } = this.props; - const query = qs.parse(location.search, { ignoreQueryPrefix: true }); - const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags; + if (!order.orderDir) { + return null; + } - this.refreshList({ page: params.page, tags }); - } - - 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; - - if (error) { return ( - - Something went wrong while loading short URLs :( - + ); - } + }; + const renderShortUrls = () => { + if (error) { + return ( + + Something went wrong while loading short URLs :( + + ); + } - if (loading) { - return Loading...; - } + if (loading) { + return Loading...; + } - if (!loading && isEmpty(shortUrlsList)) { - return No results found; - } + if (!loading && isEmpty(shortUrlsList)) { + return No results found; + } - return shortUrlsList.map((shortUrl) => ( - - )); - } + return shortUrlsList.map((shortUrl) => ( + + )); + }; + + 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(() => { + const { mercureHubUrl, token, loading, error } = mercureInfo; + + if (loading || error) { + return undefined; + } + + const hubUrl = new URL(mercureHubUrl); + + hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit'); + const es = new EventSource(hubUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + // es.onmessage = pipe(JSON.parse, createNewVisit); + es.onmessage = ({ data }) => createNewVisit(JSON.parse(data)); + + return () => es.close(); + }, [ mercureInfo ]); - render() { return (
@@ -169,42 +151,46 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon - {this.renderShortUrls()} + {renderShortUrls()}
- {this.renderOrderIcon('dateCreated')} + {renderOrderIcon('dateCreated')} Created at - {this.renderOrderIcon('shortCode')} + {renderOrderIcon('shortCode')} Short URL - {this.renderOrderIcon('longUrl')} + {renderOrderIcon('longUrl')} Long URL Tags - {this.renderOrderIcon('visits')} Visits + {renderOrderIcon('visits')} Visits  
); - } + }; + + ShortUrlsListComp.propTypes = propTypes; + + return ShortUrlsListComp; }; export default ShortUrlsList; diff --git a/test/short-urls/ShortUrlsList.test.js b/test/short-urls/ShortUrlsList.test.js index 95017966..050238b9 100644 --- a/test/short-urls/ShortUrlsList.test.js +++ b/test/short-urls/ShortUrlsList.test.js @@ -71,10 +71,6 @@ describe('', () => { }); it('should render 6 table header cells with conditional order by icon', () => { - const orderDirOptionToIconMap = { - ASC: caretUpIcon, - DESC: caretDownIcon, - }; const getThElementForSortableField = (sortableField) => wrapper.find('table') .find('thead') .find('tr') @@ -82,24 +78,18 @@ describe('', () => { .filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField])); Object.keys(SORTABLE_FIELDS).forEach((sortableField) => { - const sortableThElementWrapper = getThElementForSortableField(sortableField); + expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0); - expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0); - - sortableThElementWrapper.simulate('click'); + getThElementForSortableField(sortableField).simulate('click'); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1); - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual( - orderDirOptionToIconMap.ASC, - ); + expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretUpIcon); - sortableThElementWrapper.simulate('click'); + getThElementForSortableField(sortableField).simulate('click'); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1); - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual( - orderDirOptionToIconMap.DESC, - ); + expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretDownIcon); - sortableThElementWrapper.simulate('click'); - expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0); + getThElementForSortableField(sortableField).simulate('click'); + expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0); }); }); }); From a22a1938c13ddb2fc252ead3e5c3c90a8de109bf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Apr 2020 10:50:01 +0200 Subject: [PATCH 4/9] Added automatic refresh on mercure events --- src/mercure/helpers/index.js | 25 +++++++++++++++++++ src/short-urls/ShortUrlsList.js | 24 ++---------------- src/short-urls/helpers/ShortUrlVisitsCount.js | 12 ++++++--- .../helpers/ShortUrlVisitsCount.scss | 9 +++++++ src/short-urls/helpers/ShortUrlsRow.js | 16 ++++++++++-- src/short-urls/helpers/ShortUrlsRow.scss | 5 ++++ src/short-urls/helpers/index.js | 9 +++++++ src/short-urls/reducers/shortUrlsList.js | 13 +++------- src/utils/helpers/hooks.js | 14 ++++++++--- src/utils/services/provideServices.js | 3 ++- src/visits/ShortUrlVisits.js | 10 ++++++++ src/visits/reducers/shortUrlVisits.js | 23 +++++++++++++---- src/visits/services/provideServices.js | 4 +-- .../helpers/ShortUrlVisitsCount.test.js | 4 ++- test/visits/VisitsHeader.test.js | 4 ++- test/visits/reducers/shortUrlVisits.test.js | 11 +++++--- 16 files changed, 132 insertions(+), 54 deletions(-) create mode 100644 src/mercure/helpers/index.js create mode 100644 src/short-urls/helpers/index.js diff --git a/src/mercure/helpers/index.js b/src/mercure/helpers/index.js new file mode 100644 index 00000000..15e20a72 --- /dev/null +++ b/src/mercure/helpers/index.js @@ -0,0 +1,25 @@ +import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; + +export const bindToMercureTopic = (mercureInfo, topic, onMessage) => () => { + 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)); + + // TODO Handle errors and get a new token + es.onerror = () => {}; + + return () => es.close(); +}; diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 460106d2..956dcdf2 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -4,11 +4,11 @@ import { head, isEmpty, keys, values } from 'ramda'; import React, { useState, useEffect } 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 { bindToMercureTopic } from '../mercure/helpers'; import { shortUrlType } from './reducers/shortUrlsList'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import './ShortUrlsList.scss'; @@ -114,27 +114,7 @@ const ShortUrlsList = (ShortUrlsRow) => { return resetShortUrlParams; }, []); - useEffect(() => { - const { mercureHubUrl, token, loading, error } = mercureInfo; - - if (loading || error) { - return undefined; - } - - const hubUrl = new URL(mercureHubUrl); - - hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit'); - const es = new EventSource(hubUrl, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - // es.onmessage = pipe(JSON.parse, createNewVisit); - es.onmessage = ({ data }) => createNewVisit(JSON.parse(data)); - - return () => es.close(); - }, [ mercureInfo ]); + useEffect(bindToMercureTopic(mercureInfo, 'https://shlink.io/new-visit', createNewVisit), [ mercureInfo ]); return ( diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.js b/src/short-urls/helpers/ShortUrlVisitsCount.js index a268c9b5..b058868d 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.js +++ b/src/short-urls/helpers/ShortUrlVisitsCount.js @@ -3,22 +3,28 @@ import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; +import classNames from 'classnames'; import { serverType } from '../../servers/prop-types'; import { shortUrlType } from '../reducers/shortUrlsList'; -import './ShortUrlVisitsCount.scss'; import VisitStatsLink from './VisitStatsLink'; +import './ShortUrlVisitsCount.scss'; const propTypes = { visitsCount: PropTypes.number.isRequired, shortUrl: shortUrlType, 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 visitsLink = ( - {visitsCount} + + {visitsCount} + ); diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.scss b/src/short-urls/helpers/ShortUrlVisitsCount.scss index b27902fd..2910381a 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.scss +++ b/src/short-urls/helpers/ShortUrlVisitsCount.scss @@ -1,3 +1,12 @@ .short-urls-visits-count__max-visits-control { cursor: help; } + +.short-url-visits-count__amount { + transition: transform .3s ease; + display: inline-block; +} + +.short-url-visits-count__amount--big { + transform: scale(1.5); +} diff --git a/src/short-urls/helpers/ShortUrlsRow.js b/src/short-urls/helpers/ShortUrlsRow.js index a058a92a..b6788da2 100644 --- a/src/short-urls/helpers/ShortUrlsRow.js +++ b/src/short-urls/helpers/ShortUrlsRow.js @@ -1,5 +1,5 @@ import { isEmpty } from 'ramda'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import Moment from 'react-moment'; import PropTypes from 'prop-types'; import { ExternalLink } from 'react-external-link'; @@ -26,7 +26,10 @@ const ShortUrlsRow = ( useStateFlagTimeout ) => { 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) => { if (isEmpty(tags)) { return No tags; @@ -44,6 +47,14 @@ const ShortUrlsRow = ( )); }; + useEffect(() => { + if (isFirstRun.current) { + isFirstRun.current = false; + } else { + setActive(true); + } + }, [ shortUrl.visitsCount ]); + return ( @@ -69,6 +80,7 @@ const ShortUrlsRow = ( visitsCount={shortUrl.visitsCount} shortUrl={shortUrl} selectedServer={selectedServer} + active={active} /> diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index 7a888cf3..914826ee 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -35,6 +35,7 @@ } } } + .short-urls-row__cell--break { word-break: break-all; } @@ -43,6 +44,10 @@ position: relative; } +.short-urls-row__cell--big { + transform: scale(1.5); +} + .short-urls-row__copy-btn { cursor: pointer; font-size: 1.2rem; diff --git a/src/short-urls/helpers/index.js b/src/short-urls/helpers/index.js new file mode 100644 index 00000000..32a12ad9 --- /dev/null +++ b/src/short-urls/helpers/index.js @@ -0,0 +1,9 @@ +import { isNil } from 'ramda'; + +export const shortUrlMatches = (shortUrl, shortCode, domain) => { + if (isNil(domain)) { + return shortUrl.shortCode === shortCode && !shortUrl.domain; + } + + return shortUrl.shortCode === shortCode && shortUrl.domain === domain; +}; diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index b8b11aa4..b7d346cc 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -1,7 +1,8 @@ import { handleActions } from 'redux-actions'; -import { assoc, assocPath, isNil, reject } from 'ramda'; +import { assoc, assocPath, reject } from 'ramda'; import PropTypes from 'prop-types'; import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits'; +import { shortUrlMatches } from '../helpers'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; @@ -29,14 +30,6 @@ const initialState = { error: false, }; -const shortUrlMatches = (shortUrl, shortCode, domain) => { - if (isNil(domain)) { - return shortUrl.shortCode === shortCode && !shortUrl.domain; - } - - return shortUrl.shortCode === shortCode && shortUrl.domain === domain; -}; - const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath( [ 'shortUrls', 'data' ], state.shortUrls.data.map( @@ -59,7 +52,7 @@ export default handleActions({ [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), [CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( [ 'shortUrls', 'data' ], - state.shortUrls.data.map( + state.shortUrls && state.shortUrls.data && state.shortUrls.data.map( (shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) ? assoc('visitsCount', visitsCount, shortUrl) : shortUrl diff --git a/src/utils/helpers/hooks.js b/src/utils/helpers/hooks.js index 81a60517..d0852714 100644 --- a/src/utils/helpers/hooks.js +++ b/src/utils/helpers/hooks.js @@ -1,12 +1,18 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; -const DEFAULT_TIMEOUT_DELAY = 2000; +const DEFAULT_DELAY = 2000; -export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => { +export const useStateFlagTimeout = (setTimeout, clearTimeout) => (initialValue = false, delay = DEFAULT_DELAY) => { const [ flag, setFlag ] = useState(initialValue); + const timeout = useRef(undefined); const callback = () => { setFlag(!initialValue); - setTimeout(() => setFlag(initialValue), delay); + + if (timeout.current) { + clearTimeout(timeout.current); + } + + timeout.current = setTimeout(() => setFlag(initialValue), delay); }; return [ flag, callback ]; diff --git a/src/utils/services/provideServices.js b/src/utils/services/provideServices.js index 1760c88c..d4068614 100644 --- a/src/utils/services/provideServices.js +++ b/src/utils/services/provideServices.js @@ -14,8 +14,9 @@ const provideServices = (bottle) => { bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios'); bottle.constant('setTimeout', global.setTimeout); + bottle.constant('clearTimeout', global.clearTimeout); bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout'); - bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout'); + bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout'); }; export default provideServices; diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 0d279ae2..e8e27693 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -10,6 +10,8 @@ import DateRangeRow from '../utils/DateRangeRow'; import Message from '../utils/Message'; import { formatDate } from '../utils/helpers/date'; import { useToggle } from '../utils/helpers/hooks'; +import { MercureInfoType } from '../mercure/reducers/mercureInfo'; +import { bindToMercureTopic } from '../mercure/helpers'; import SortableBarGraph from './SortableBarGraph'; import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import VisitsHeader from './VisitsHeader'; @@ -30,6 +32,8 @@ const propTypes = { shortUrlDetail: shortUrlDetailType, cancelGetShortUrlVisits: PropTypes.func, matchMedia: PropTypes.func, + createNewVisit: PropTypes.func, + mercureInfo: MercureInfoType, }; const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => { @@ -54,6 +58,8 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa getShortUrlDetail, cancelGetShortUrlVisits, matchMedia = window.matchMedia, + createNewVisit, + mercureInfo, }) => { const [ startDate, setStartDate ] = useState(undefined); const [ endDate, setEndDate ] = useState(undefined); @@ -108,6 +114,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa useEffect(() => { loadVisits(); }, [ startDate, endDate ]); + useEffect( + bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit), + [ mercureInfo ], + ); const renderVisitsContent = () => { if (loading) { diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index 9c19d592..f3be7e33 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,6 +1,7 @@ import { createAction, handleActions } from 'redux-actions'; import PropTypes from 'prop-types'; import { flatten, prop, range, splitEvery } from 'ramda'; +import { shortUrlMatches } from '../../short-urls/helpers'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -29,12 +30,16 @@ export const visitType = PropTypes.shape({ export const shortUrlVisitsType = PropTypes.shape({ visits: PropTypes.arrayOf(visitType), + shortCode: PropTypes.string, + domain: PropTypes.string, loading: PropTypes.bool, error: PropTypes.bool, }); const initialState = { visits: [], + shortCode: '', + domain: undefined, loading: false, loadingLarge: false, error: false, @@ -55,8 +60,10 @@ export default handleActions({ error: true, cancelLoad: false, }), - [GET_SHORT_URL_VISITS]: (state, { visits }) => ({ + [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({ visits, + shortCode, + domain, loading: false, loadingLarge: false, error: false, @@ -64,12 +71,18 @@ export default handleActions({ }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), + [CREATE_SHORT_URL_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand + const { shortCode, domain, visits } = state; - // TODO - [CREATE_SHORT_URL_VISIT]: (state) => state, + if (!shortUrlMatches(shortUrl, shortCode, domain)) { + return state; + } + + return { ...state, visits: [ ...visits, visit ] }; + }, }, initialState); -export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => { +export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => { dispatch({ type: GET_SHORT_URL_VISITS_START }); const { getShortUrlVisits } = buildShlinkApiClient(getState); const itemsPerPage = 5000; @@ -122,7 +135,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => try { const visits = await loadVisits(); - dispatch({ visits, type: GET_SHORT_URL_VISITS }); + dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS }); } catch (e) { dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); } diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js index babb432c..e18c981b 100644 --- a/src/visits/services/provideServices.js +++ b/src/visits/services/provideServices.js @@ -11,8 +11,8 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('MapModal', () => MapModal); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn'); bottle.decorator('ShortUrlVisits', connect( - [ 'shortUrlVisits', 'shortUrlDetail' ], - [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ] + [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ], + [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit' ] )); // Services diff --git a/test/short-urls/helpers/ShortUrlVisitsCount.test.js b/test/short-urls/helpers/ShortUrlVisitsCount.test.js index 0e9716f7..f12d3fe3 100644 --- a/test/short-urls/helpers/ShortUrlVisitsCount.test.js +++ b/test/short-urls/helpers/ShortUrlVisitsCount.test.js @@ -20,7 +20,9 @@ describe('', () => { const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); - expect(wrapper.html()).toEqual(`${visitsCount}`); + expect(wrapper.html()).toEqual( + `${visitsCount}` + ); expect(maxVisitsHelper).toHaveLength(0); expect(maxVisitsTooltip).toHaveLength(0); }); diff --git a/test/visits/VisitsHeader.test.js b/test/visits/VisitsHeader.test.js index ba258418..3d4b48dd 100644 --- a/test/visits/VisitsHeader.test.js +++ b/test/visits/VisitsHeader.test.js @@ -26,7 +26,9 @@ describe('', () => { it('shows the amount of visits', () => { const visitsBadge = wrapper.find('.badge'); - expect(visitsBadge.html()).toContain(`Visits: ${shortUrlVisits.visits.length}`); + expect(visitsBadge.html()).toContain( + `Visits: ${shortUrlVisits.visits.length}` + ); }); it('shows when the URL was created', () => { diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js index 9bc285ca..82fdeede 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -72,8 +72,13 @@ describe('shortUrlVisitsReducer', () => { expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); }); - it('dispatches start and success when promise is resolved', async () => { + it.each([ + [ undefined, undefined ], + [{}, undefined ], + [{ domain: 'foobar.com' }, 'foobar.com' ], + ])('dispatches start and success when promise is resolved', async (query, domain) => { const visits = [{}, {}]; + const shortCode = 'abc123'; const ShlinkApiClient = buildApiClientMock(Promise.resolve({ data: visits, pagination: { @@ -82,11 +87,11 @@ describe('shortUrlVisitsReducer', () => { }, })); - await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); + await getShortUrlVisits(() => ShlinkApiClient)(shortCode, query)(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits, shortCode, domain }); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); }); From 91488ae2949ff3f5c8655d430ce31f613c933337 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Apr 2020 11:03:49 +0200 Subject: [PATCH 5/9] Fixed visits count not handling separated tooltiups --- src/short-urls/helpers/ShortUrlVisitsCount.js | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.js b/src/short-urls/helpers/ShortUrlVisitsCount.js index b058868d..e33ae1c0 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.js +++ b/src/short-urls/helpers/ShortUrlVisitsCount.js @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; import classNames from 'classnames'; import { serverType } from '../../servers/prop-types'; +import { prettify } from '../../utils/helpers/numbers'; import { shortUrlType } from '../reducers/shortUrlsList'; import VisitStatsLink from './VisitStatsLink'; import './ShortUrlVisitsCount.scss'; @@ -23,7 +24,7 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = f - {visitsCount} + {prettify(visitsCount)} ); @@ -32,19 +33,27 @@ const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = f return visitsLink; } + const prettifiedMaxVisits = prettify(maxVisits); + const tooltipRef = useRef(); + return ( {visitsLink} - - {' '}/ {maxVisits}{' '} + { + tooltipRef.current = el; + }} + > + {' '}/ {prettifiedMaxVisits}{' '} - - This short URL will not accept more than {maxVisits} visits. + tooltipRef.current} placement="bottom"> + This short URL will not accept more than {prettifiedMaxVisits} visits. ); From ed40b79c8d1718bbc88066c51bf801c433040fc4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Apr 2020 12:09:51 +0200 Subject: [PATCH 6/9] Added more tests covering new use cases --- test/mercure/mercureInfo.test.js | 70 +++++++++++++++++++ .../reducers/shortUrlCreation.test.js | 2 +- .../short-urls/reducers/shortUrlsList.test.js | 34 ++++++++- test/visits/reducers/shortUrlVisits.test.js | 26 +++++++ 4 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 test/mercure/mercureInfo.test.js diff --git a/test/mercure/mercureInfo.test.js b/test/mercure/mercureInfo.test.js new file mode 100644 index 00000000..908480ee --- /dev/null +++ b/test/mercure/mercureInfo.test.js @@ -0,0 +1,70 @@ +import reducer, { + GET_MERCURE_INFO_START, + GET_MERCURE_INFO_ERROR, + GET_MERCURE_INFO, + loadMercureInfo, +} from '../../src/mercure/reducers/mercureInfo.js'; + +describe('mercureInfoReducer', () => { + const mercureInfo = { + mercureHubUrl: 'http://example.com/.well-known/mercure', + token: 'abc.123.def', + }; + + describe('reducer', () => { + it('returns loading on GET_MERCURE_INFO_START', () => { + expect(reducer({}, { type: GET_MERCURE_INFO_START })).toEqual({ + loading: true, + error: false, + }); + }); + + it('returns error on GET_MERCURE_INFO_ERROR', () => { + expect(reducer({}, { type: GET_MERCURE_INFO_ERROR })).toEqual({ + loading: false, + error: true, + }); + }); + + it('returns mercure info on GET_MERCURE_INFO', () => { + expect(reducer({}, { type: GET_MERCURE_INFO, ...mercureInfo })).toEqual({ + ...mercureInfo, + loading: false, + error: false, + }); + }); + }); + + describe('loadMercureInfo', () => { + const createApiClientMock = (result) => ({ + mercureInfo: jest.fn(() => result), + }); + const dispatch = jest.fn(); + const getState = () => ({}); + + afterEach(jest.resetAllMocks); + + it('calls API on success', async () => { + const apiClientMock = createApiClientMock(Promise.resolve(mercureInfo)); + + await loadMercureInfo(() => apiClientMock)()(dispatch, getState()); + + expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO, ...mercureInfo }); + }); + + it('throws error on failure', async () => { + const error = 'Error'; + const apiClientMock = createApiClientMock(Promise.reject(error)); + + await loadMercureInfo(() => apiClientMock)()(dispatch, getState()); + + expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO_ERROR }); + }); + }); +}); diff --git a/test/short-urls/reducers/shortUrlCreation.test.js b/test/short-urls/reducers/shortUrlCreation.test.js index d373f972..0ebacbe5 100644 --- a/test/short-urls/reducers/shortUrlCreation.test.js +++ b/test/short-urls/reducers/shortUrlCreation.test.js @@ -52,7 +52,7 @@ describe('shortUrlCreationReducer', () => { const dispatch = jest.fn(); const getState = () => ({}); - afterEach(() => dispatch.mockReset()); + afterEach(jest.resetAllMocks); it('calls API on success', async () => { const result = 'foo'; diff --git a/test/short-urls/reducers/shortUrlsList.test.js b/test/short-urls/reducers/shortUrlsList.test.js index 00a4e6d8..c8e725c2 100644 --- a/test/short-urls/reducers/shortUrlsList.test.js +++ b/test/short-urls/reducers/shortUrlsList.test.js @@ -7,6 +7,7 @@ import reducer, { import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags'; import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion'; import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta'; +import { CREATE_SHORT_URL_VISIT } from '../../../src/visits/reducers/shortUrlVisits'; describe('shortUrlsListReducer', () => { describe('reducer', () => { @@ -31,7 +32,7 @@ describe('shortUrlsListReducer', () => { error: true, })); - it('Updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => { + it('updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => { const shortCode = 'abc123'; const tags = [ 'foo', 'bar', 'baz' ]; const state = { @@ -55,7 +56,7 @@ describe('shortUrlsListReducer', () => { }); }); - it('Updates meta on matching URL on SHORT_URL_META_EDITED', () => { + it('updates meta on matching URL on SHORT_URL_META_EDITED', () => { const shortCode = 'abc123'; const domain = 'example.com'; const meta = { @@ -83,7 +84,7 @@ describe('shortUrlsListReducer', () => { }); }); - it('Removes matching URL on SHORT_URL_DELETED', () => { + it('removes matching URL on SHORT_URL_DELETED', () => { const shortCode = 'abc123'; const state = { shortUrls: { @@ -101,6 +102,33 @@ describe('shortUrlsListReducer', () => { }, }); }); + + it('updates visits count on CREATE_SHORT_URL_VISIT', () => { + const shortCode = 'abc123'; + const shortUrl = { + shortCode, + visitsCount: 11, + }; + const state = { + shortUrls: { + data: [ + { shortCode, domain: 'example.com', visitsCount: 5 }, + { shortCode, visitsCount: 10 }, + { shortCode: 'foo', visitsCount: 8 }, + ], + }, + }; + + expect(reducer(state, { type: CREATE_SHORT_URL_VISIT, shortUrl })).toEqual({ + shortUrls: { + data: [ + { shortCode, domain: 'example.com', visitsCount: 5 }, + { shortCode, visitsCount: 11 }, + { shortCode: 'foo', visitsCount: 8 }, + ], + }, + }); + }); }); describe('listShortUrls', () => { diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js index 82fdeede..5e2544f9 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -1,11 +1,13 @@ import reducer, { getShortUrlVisits, cancelGetShortUrlVisits, + createNewVisit, GET_SHORT_URL_VISITS_START, GET_SHORT_URL_VISITS_ERROR, GET_SHORT_URL_VISITS, GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_CANCEL, + CREATE_SHORT_URL_VISIT, } from '../../../src/visits/reducers/shortUrlVisits'; describe('shortUrlVisitsReducer', () => { @@ -48,6 +50,23 @@ describe('shortUrlVisitsReducer', () => { expect(error).toEqual(false); expect(visits).toEqual(actionVisits); }); + + it.each([ + [{ shortCode: 'abc123' }, [{}, {}, {}]], + [{ shortCode: 'def456' }, [{}, {}]], + ])('appends a new visit on CREATE_SHORT_URL_VISIT', (state, expectedVisits) => { + const shortUrl = { + shortCode: 'abc123', + }; + const prevState = { + ...state, + visits: [{}, {}], + }; + + const { visits } = reducer(prevState, { type: CREATE_SHORT_URL_VISIT, shortUrl, visit: {} }); + + expect(visits).toEqual(expectedVisits); + }); }); describe('getShortUrlVisits', () => { @@ -119,4 +138,11 @@ describe('shortUrlVisitsReducer', () => { it('just returns the action with proper type', () => expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL })); }); + + describe('createNewVisit', () => { + it('just returns the action with proper type', () => + expect(createNewVisit({ shortUrl: {}, visit: {} })).toEqual( + { type: CREATE_SHORT_URL_VISIT, shortUrl: {}, visit: {} } + )); + }); }); From a485d0b507529b0fe485a3fab0361cf80d249765 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Apr 2020 12:25:47 +0200 Subject: [PATCH 7/9] Added token expired handling to mercure binding --- src/mercure/helpers/index.js | 6 ++---- src/short-urls/ShortUrlsList.js | 7 ++++++- src/short-urls/services/provideServices.js | 2 +- src/visits/ShortUrlVisits.js | 4 +++- src/visits/services/provideServices.js | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/mercure/helpers/index.js b/src/mercure/helpers/index.js index 15e20a72..0cdd367c 100644 --- a/src/mercure/helpers/index.js +++ b/src/mercure/helpers/index.js @@ -1,6 +1,6 @@ import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; -export const bindToMercureTopic = (mercureInfo, topic, onMessage) => () => { +export const bindToMercureTopic = (mercureInfo, topic, onMessage, onTokenExpired) => () => { const { mercureHubUrl, token, loading, error } = mercureInfo; if (loading || error) { @@ -17,9 +17,7 @@ export const bindToMercureTopic = (mercureInfo, topic, onMessage) => () => { }); es.onmessage = ({ data }) => onMessage(JSON.parse(data)); - - // TODO Handle errors and get a new token - es.onerror = () => {}; + es.onerror = ({ status }) => status === 401 && onTokenExpired(); return () => es.close(); }; diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 956dcdf2..9df2250b 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -31,6 +31,7 @@ const propTypes = { shortUrlsList: PropTypes.arrayOf(shortUrlType), selectedServer: serverType, createNewVisit: PropTypes.func, + loadMercureInfo: PropTypes.func, mercureInfo: MercureInfoType, }; @@ -47,6 +48,7 @@ const ShortUrlsList = (ShortUrlsRow) => { shortUrlsList, selectedServer, createNewVisit, + loadMercureInfo, mercureInfo, }) => { const { orderBy } = shortUrlsListParams; @@ -114,7 +116,10 @@ const ShortUrlsList = (ShortUrlsRow) => { return resetShortUrlParams; }, []); - useEffect(bindToMercureTopic(mercureInfo, 'https://shlink.io/new-visit', createNewVisit), [ mercureInfo ]); + useEffect( + bindToMercureTopic(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo), + [ mercureInfo ] + ); return ( diff --git a/src/short-urls/services/provideServices.js b/src/short-urls/services/provideServices.js index 49cb9143..385b0a1c 100644 --- a/src/short-urls/services/provideServices.js +++ b/src/short-urls/services/provideServices.js @@ -32,7 +32,7 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow'); bottle.decorator('ShortUrlsList', connect( [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ], - [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit' ] + [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ] )); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index e8e27693..d253b486 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -33,6 +33,7 @@ const propTypes = { cancelGetShortUrlVisits: PropTypes.func, matchMedia: PropTypes.func, createNewVisit: PropTypes.func, + loadMercureInfo: PropTypes.func, mercureInfo: MercureInfoType, }; @@ -59,6 +60,7 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa cancelGetShortUrlVisits, matchMedia = window.matchMedia, createNewVisit, + loadMercureInfo, mercureInfo, }) => { const [ startDate, setStartDate ] = useState(undefined); @@ -115,7 +117,7 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa loadVisits(); }, [ startDate, endDate ]); useEffect( - bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit), + bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit, loadMercureInfo), [ mercureInfo ], ); diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js index e18c981b..f2363f67 100644 --- a/src/visits/services/provideServices.js +++ b/src/visits/services/provideServices.js @@ -12,7 +12,7 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn'); bottle.decorator('ShortUrlVisits', connect( [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ], - [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit' ] + [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ] )); // Services From d8ae69e861855d525a27e78e48cf90849938b85c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Apr 2020 12:49:03 +0200 Subject: [PATCH 8/9] Added test for mercure info helpers --- test/mercure/helpers/index.test.js | 57 +++++++++++++++++++ .../{ => reducers}/mercureInfo.test.js | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 test/mercure/helpers/index.test.js rename test/mercure/{ => reducers}/mercureInfo.test.js (97%) diff --git a/test/mercure/helpers/index.test.js b/test/mercure/helpers/index.test.js new file mode 100644 index 00000000..1e6fd3df --- /dev/null +++ b/test/mercure/helpers/index.test.js @@ -0,0 +1,57 @@ +import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; +import { bindToMercureTopic } from '../../../src/mercure/helpers'; + +jest.mock('event-source-polyfill'); + +describe('helpers', () => { + afterEach(jest.resetAllMocks); + + describe('bindToMercureTopic', () => { + const onMessage = jest.fn(); + const onTokenExpired = jest.fn(); + + it.each([ + [{ loading: true, error: false }], + [{ loading: false, error: true }], + [{ loading: true, error: true }], + ])('does not bind an EventSource when loading or error', (mercureInfo) => { + bindToMercureTopic(mercureInfo)(); + + expect(EventSource).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + expect(onTokenExpired).not.toHaveBeenCalled(); + }); + + it('binds an EventSource when mercure info is properly loaded', () => { + const token = 'abc.123.efg'; + const mercureHubUrl = 'https://example.com/.well-known/mercure'; + const topic = 'foo'; + const hubUrl = new URL(mercureHubUrl); + + hubUrl.searchParams.append('topic', topic); + + const callback = bindToMercureTopic({ + loading: false, + error: false, + mercureHubUrl, + token, + }, topic, onMessage, onTokenExpired)(); + + expect(EventSource).toHaveBeenCalledWith(hubUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const [ es ] = EventSource.mock.instances; + + es.onmessage({ data: '{"foo": "bar"}' }); + es.onerror({ status: 401 }); + expect(onMessage).toHaveBeenCalledWith({ foo: 'bar' }); + expect(onTokenExpired).toHaveBeenCalled(); + + callback(); + expect(es.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/mercure/mercureInfo.test.js b/test/mercure/reducers/mercureInfo.test.js similarity index 97% rename from test/mercure/mercureInfo.test.js rename to test/mercure/reducers/mercureInfo.test.js index 908480ee..fa93636e 100644 --- a/test/mercure/mercureInfo.test.js +++ b/test/mercure/reducers/mercureInfo.test.js @@ -3,7 +3,7 @@ import reducer, { GET_MERCURE_INFO_ERROR, GET_MERCURE_INFO, loadMercureInfo, -} from '../../src/mercure/reducers/mercureInfo.js'; +} from '../../../src/mercure/reducers/mercureInfo.js'; describe('mercureInfoReducer', () => { const mercureInfo = { From 8a5161c0e808cc823ebebe144440c874c4471c6e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Apr 2020 20:36:49 +0200 Subject: [PATCH 9/9] Updated changelog --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283f67bc..ad9b23ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] + +#### Added + +* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server. + + The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it. + + * If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI. + * If it fails, it will assume it is either not configured or not supported by the Shlink version. + +#### Changed + +* *Nothing* + +#### Deprecated + +* *Nothing* + +#### Removed + +* *Nothing* + +#### Fixed + +* *Nothing* + + ## 2.4.0 - 2020-04-10 #### Added