From 5d6d802d64b5f076c81bd7d1138c21925c299de3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 6 Sep 2020 19:41:15 +0200 Subject: [PATCH 1/7] Moved mercure hub binding from custom hook to HOC --- src/mercure/helpers/boundToMercureHub.tsx | 26 +++++++++++++++++++++++ src/mercure/helpers/index.ts | 16 -------------- src/short-urls/ShortUrlsList.tsx | 12 ++++------- src/tags/TagsList.tsx | 11 +++++----- src/visits/ShortUrlVisits.tsx | 12 ++++------- src/visits/TagVisits.tsx | 13 ++++-------- test/App.test.tsx | 3 +-- test/short-urls/ShortUrlsList.test.tsx | 5 +++-- test/tags/TagsList.test.tsx | 4 +++- test/visits/ShortUrlVisits.test.tsx | 4 +++- test/visits/TagVisits.test.tsx | 4 +++- 11 files changed, 56 insertions(+), 54 deletions(-) create mode 100644 src/mercure/helpers/boundToMercureHub.tsx diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/src/mercure/helpers/boundToMercureHub.tsx new file mode 100644 index 00000000..e4f7ae71 --- /dev/null +++ b/src/mercure/helpers/boundToMercureHub.tsx @@ -0,0 +1,26 @@ +import React, { FC, useEffect } from 'react'; +import { CreateVisit } from '../../visits/types'; +import { MercureInfo } from '../reducers/mercureInfo'; +import { bindToMercureTopic } from './index'; + +export interface MercureBoundProps { + createNewVisit: (visitData: CreateVisit) => void; + loadMercureInfo: Function; + mercureInfo: MercureInfo; +} + +export function boundToMercureHub( + WrappedComponent: FC, + getTopicForProps: (props: T) => string, +) { + return (props: MercureBoundProps & T) => { + const { createNewVisit, loadMercureInfo, mercureInfo } = props; + + useEffect( + bindToMercureTopic(mercureInfo, getTopicForProps(props), createNewVisit, loadMercureInfo), + [ mercureInfo ], + ); + + return ; + }; +} diff --git a/src/mercure/helpers/index.ts b/src/mercure/helpers/index.ts index 4fa89f24..997ecf07 100644 --- a/src/mercure/helpers/index.ts +++ b/src/mercure/helpers/index.ts @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; import { MercureInfo } from '../reducers/mercureInfo'; @@ -23,18 +22,3 @@ export const bindToMercureTopic = (mercureInfo: MercureInfo, topic: string, o return () => es.close(); }; - -export const useMercureTopicBinding = ( - mercureInfo: MercureInfo, - topic: string, - onMessage: (message: T) => void, - onTokenExpired: Function, -) => { - useEffect(bindToMercureTopic(mercureInfo, topic, onMessage, onTokenExpired), [ mercureInfo ]); -}; - -export interface MercureBoundProps { - createNewVisit: (message: any) => void; - loadMercureInfo: Function; - mercureInfo: MercureInfo; -} diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index ccab7bca..6cb2c947 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -6,8 +6,8 @@ import qs from 'qs'; import { RouteComponentProps } from 'react-router'; import SortingDropdown from '../utils/SortingDropdown'; import { determineOrderDir, OrderDir } from '../utils/utils'; -import { MercureBoundProps, useMercureTopicBinding } from '../mercure/helpers'; import { SelectedServer } from '../servers/data'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsRowProps } from './helpers/ShortUrlsRow'; import { ShortUrl } from './data'; @@ -31,14 +31,14 @@ export interface WithList { shortUrlsList: ShortUrl[]; } -export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps, MercureBoundProps { +export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps { selectedServer: SelectedServer; listShortUrls: (params: ShortUrlsListParams) => void; shortUrlsListParams: ShortUrlsListParams; resetShortUrlParams: () => void; } -const ShortUrlsList = (ShortUrlsRow: FC) => ({ +const ShortUrlsList = (ShortUrlsRow: FC) => boundToMercureHub(({ listShortUrls, resetShortUrlParams, shortUrlsListParams, @@ -48,9 +48,6 @@ const ShortUrlsList = (ShortUrlsRow: FC) => ({ error, shortUrlsList, selectedServer, - createNewVisit, - loadMercureInfo, - mercureInfo, }: ShortUrlsListProps & WithList) => { const { orderBy } = shortUrlsListParams; const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({ @@ -116,7 +113,6 @@ const ShortUrlsList = (ShortUrlsRow: FC) => ({ return resetShortUrlParams; }, []); - useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo); return ( @@ -168,6 +164,6 @@ const ShortUrlsList = (ShortUrlsRow: FC) => ({ ); -}; +}, () => 'https://shlink.io/new-visit'); export default ShortUrlsList; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index e73ee4da..e9dd8bb6 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -2,30 +2,29 @@ import React, { FC, useEffect, useState } from 'react'; import { splitEvery } from 'ramda'; import Message from '../utils/Message'; import SearchField from '../utils/SearchField'; -import { MercureBoundProps, useMercureTopicBinding } from '../mercure/helpers'; import { SelectedServer } from '../servers/data'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { TagsList as TagsListState } from './reducers/tagsList'; import { TagCardProps } from './TagCard'; const { ceil } = Math; const TAGS_GROUPS_AMOUNT = 4; -export interface TagsListProps extends MercureBoundProps { +export interface TagsListProps { filterTags: (searchTerm: string) => void; forceListTags: Function; tagsList: TagsListState; selectedServer: SelectedServer; } -const TagsList = (TagCard: FC) => ( - { filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo }: TagsListProps, +const TagsList = (TagCard: FC) => boundToMercureHub(( + { filterTags, forceListTags, tagsList, selectedServer }: TagsListProps, ) => { const [ displayedTag, setDisplayedTag ] = useState(); useEffect(() => { forceListTags(); }, []); - useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo); const renderContent = () => { if (tagsList.loading) { @@ -76,6 +75,6 @@ const TagsList = (TagCard: FC) => ( ); -}; +}, () => 'https://shlink.io/new-visit'); export default TagsList; diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index 2d5f9461..7d6f6e8f 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -1,14 +1,14 @@ import React, { useEffect } from 'react'; import qs from 'qs'; import { RouteComponentProps } from 'react-router'; -import { MercureBoundProps, useMercureTopicBinding } from '../mercure/helpers'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../utils/services/types'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import { ShortUrlDetail } from './reducers/shortUrlDetail'; import VisitsStats from './VisitsStats'; -export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }>, MercureBoundProps { +export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }> { getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: Function; @@ -16,7 +16,7 @@ export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: st cancelGetShortUrlVisits: () => void; } -const ShortUrlVisits = ({ +const ShortUrlVisits = boundToMercureHub(({ history: { goBack }, match, location: { search }, @@ -25,9 +25,6 @@ const ShortUrlVisits = ({ getShortUrlVisits, getShortUrlDetail, cancelGetShortUrlVisits, - createNewVisit, - loadMercureInfo, - mercureInfo, }: ShortUrlVisitsProps) => { const { params } = match; const { shortCode } = params; @@ -38,13 +35,12 @@ const ShortUrlVisits = ({ useEffect(() => { getShortUrlDetail(shortCode, domain); }, []); - useMercureTopicBinding(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit, loadMercureInfo); return ( ); -}; +}, ({ match }) => `https://shlink.io/new-visit/${match.params.shortCode}`); export default ShortUrlVisits; diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index b14c3085..877bcbee 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -1,39 +1,34 @@ import React from 'react'; import { RouteComponentProps } from 'react-router'; -import { MercureBoundProps, useMercureTopicBinding } from '../mercure/helpers'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import ColorGenerator from '../utils/services/ColorGenerator'; import { ShlinkVisitsParams } from '../utils/services/types'; import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import TagVisitsHeader from './TagVisitsHeader'; import VisitsStats from './VisitsStats'; -export interface TagVisitsProps extends RouteComponentProps<{ tag: string }>, MercureBoundProps { +export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> { getTagVisits: (tag: string, query: any) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; } -const TagVisits = (colorGenerator: ColorGenerator) => ({ +const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({ history: { goBack }, match, getTagVisits, tagVisits, cancelGetTagVisits, - createNewVisit, - loadMercureInfo, - mercureInfo, }: TagVisitsProps) => { const { params } = match; const { tag } = params; const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); - useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo); - return ( ); -}; +}, () => 'https://shlink.io/new-visit'); export default TagVisits; diff --git a/test/App.test.tsx b/test/App.test.tsx index fe50f068..9b8d4070 100644 --- a/test/App.test.tsx +++ b/test/App.test.tsx @@ -7,10 +7,9 @@ import appFactory from '../src/App'; describe('', () => { let wrapper: ShallowWrapper; const MainHeader = () => null; - const DummyComponent = () => null; beforeEach(() => { - const App = appFactory(MainHeader, DummyComponent, DummyComponent, DummyComponent, DummyComponent, DummyComponent); + const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, () => null); wrapper = shallow(); }); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 60161780..9895d058 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -5,6 +5,7 @@ import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawe import { Mock } from 'ts-mockery'; import shortUrlsListCreator, { ShortUrlsListProps, SORTABLE_FIELDS } from '../../src/short-urls/ShortUrlsList'; import { ShortUrl } from '../../src/short-urls/data'; +import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; describe('', () => { let wrapper: ShallowWrapper; @@ -18,6 +19,7 @@ describe('', () => { wrapper = shallow( ()} + {...Mock.of({ mercureInfo: { loading: true } })} listShortUrls={listShortUrlsMock} resetShortUrlParams={resetShortUrlParamsMock} shortUrlsListParams={{ @@ -39,9 +41,8 @@ describe('', () => { }), ] } - mercureInfo={{ loading: true } as any} />, - ); + ).dive(); // Dive is needed as this component is wrapped in a HOC }); afterEach(jest.resetAllMocks); diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index 4cfe8790..b72f5dc1 100644 --- a/test/tags/TagsList.test.tsx +++ b/test/tags/TagsList.test.tsx @@ -7,6 +7,7 @@ import Message from '../../src/utils/Message'; import SearchField from '../../src/utils/SearchField'; import { rangeOf } from '../../src/utils/utils'; import { TagsList } from '../../src/tags/reducers/tagsList'; +import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; describe('', () => { let wrapper: ShallowWrapper; @@ -18,11 +19,12 @@ describe('', () => { wrapper = shallow( ()} + {...Mock.all()} forceListTags={identity} filterTags={filterTags} tagsList={Mock.of(tagsList)} />, - ); + ).dive(); // Dive is needed as this component is wrapped in a HOC return wrapper; }; diff --git a/test/visits/ShortUrlVisits.test.tsx b/test/visits/ShortUrlVisits.test.tsx index f3516b4e..8a980daa 100644 --- a/test/visits/ShortUrlVisits.test.tsx +++ b/test/visits/ShortUrlVisits.test.tsx @@ -9,6 +9,7 @@ import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; import { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers/shortUrlVisits'; import { ShortUrlDetail } from '../../src/visits/reducers/shortUrlDetail'; import VisitsStats from '../../src/visits/VisitsStats'; +import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; describe('', () => { let wrapper: ShallowWrapper; @@ -25,6 +26,7 @@ describe('', () => { wrapper = shallow( ()} + {...Mock.all()} getShortUrlDetail={identity} getShortUrlVisits={getShortUrlVisitsMock} match={match} @@ -34,7 +36,7 @@ describe('', () => { shortUrlDetail={Mock.all()} cancelGetShortUrlVisits={() => {}} />, - ); + ).dive(); // Dive is needed as this component is wrapped in a HOC }); afterEach(() => wrapper.unmount()); diff --git a/test/visits/TagVisits.test.tsx b/test/visits/TagVisits.test.tsx index 6c8e8b1c..5cc80f03 100644 --- a/test/visits/TagVisits.test.tsx +++ b/test/visits/TagVisits.test.tsx @@ -8,6 +8,7 @@ import TagVisitsHeader from '../../src/visits/TagVisitsHeader'; import ColorGenerator from '../../src/utils/services/ColorGenerator'; import { TagVisits as TagVisitsStats } from '../../src/visits/reducers/tagVisits'; import VisitsStats from '../../src/visits/VisitsStats'; +import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; describe('', () => { let wrapper: ShallowWrapper; @@ -25,13 +26,14 @@ describe('', () => { wrapper = shallow( ()} + {...Mock.all()} getTagVisits={getTagVisitsMock} match={match} history={history} tagVisits={Mock.of({ loading: true, visits: [] })} cancelGetTagVisits={() => {}} />, - ); + ).dive(); // Dive is needed as this component is wrapped in a HOC }); afterEach(() => wrapper.unmount()); From 9b455136845e4e68e7295cb349db3d1def2b49b8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 9 Sep 2020 19:16:04 +0200 Subject: [PATCH 2/7] Added form controls to set real time updates interval --- src/settings/RealTimeUpdates.tsx | 41 ++++++++++++++++++++---- src/settings/reducers/settings.ts | 14 ++++++-- src/settings/services/provideServices.ts | 7 ++-- src/utils/utils.ts | 4 +++ test/settings/reducers/settings.test.ts | 14 ++++++-- 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/settings/RealTimeUpdates.tsx b/src/settings/RealTimeUpdates.tsx index dc89f1d5..d89f6526 100644 --- a/src/settings/RealTimeUpdates.tsx +++ b/src/settings/RealTimeUpdates.tsx @@ -1,20 +1,49 @@ import React from 'react'; -import { Card, CardBody, CardHeader } from 'reactstrap'; +import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap'; +import classNames from 'classnames'; import ToggleSwitch from '../utils/ToggleSwitch'; import { Settings } from './reducers/settings'; interface RealTimeUpdatesProps { settings: Settings; - setRealTimeUpdates: (enabled: boolean) => void; + toggleRealTimeUpdates: (enabled: boolean) => void; + setRealTimeUpdatesInterval: (interval: number) => void; } -const RealTimeUpdates = ({ settings: { realTimeUpdates }, setRealTimeUpdates }: RealTimeUpdatesProps) => ( +const intervalValue = (interval?: number) => !interval ? '' : `${interval}`; + +const RealTimeUpdates = ( + { settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps, +) => ( Real-time updates - - Enable or disable real-time updates, when using Shlink v2.2.0 or newer. - + + + Enable or disable real-time updates, when using Shlink v2.2.0 or newer. + + + + + setRealTimeUpdatesInterval(Number(e.target.value))} + /> + {realTimeUpdates.enabled && ( + + {realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && + Updates will be reflected in the UI every {realTimeUpdates.interval} minutes. + } + {!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'} + + )} + ); diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index a87455c8..8a21d146 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -1,10 +1,13 @@ import { Action } from 'redux'; +import { mergeDeepRight } from 'ramda'; import { buildReducer } from '../../utils/helpers/redux'; +import { RecursivePartial } from '../../utils/utils'; export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES'; interface RealTimeUpdates { enabled: boolean; + interval?: number; } export interface Settings { @@ -19,11 +22,18 @@ const initialState: Settings = { type SettingsAction = Action & Settings; +type PartialSettingsAction = Action & RecursivePartial; + export default buildReducer({ - [SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => ({ ...state, realTimeUpdates }), + [SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => mergeDeepRight(state, { realTimeUpdates }), }, initialState); -export const setRealTimeUpdates = (enabled: boolean): SettingsAction => ({ +export const toggleRealTimeUpdates = (enabled: boolean): PartialSettingsAction => ({ type: SET_REAL_TIME_UPDATES, realTimeUpdates: { enabled }, }); + +export const setRealTimeUpdatesInterval = (interval: number): PartialSettingsAction => ({ + type: SET_REAL_TIME_UPDATES, + realTimeUpdates: { interval }, +}); diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts index 6db2052d..78d86e47 100644 --- a/src/settings/services/provideServices.ts +++ b/src/settings/services/provideServices.ts @@ -1,7 +1,7 @@ import Bottle from 'bottlejs'; import RealTimeUpdates from '../RealTimeUpdates'; import Settings from '../Settings'; -import { setRealTimeUpdates } from '../reducers/settings'; +import { setRealTimeUpdatesInterval, toggleRealTimeUpdates } from '../reducers/settings'; import { ConnectDecorator } from '../../container/types'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; @@ -13,10 +13,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Services bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates); - bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdates' ])); + bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdatesInterval' ])); // Actions - bottle.serviceFactory('setRealTimeUpdates', () => setRealTimeUpdates); + bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); + bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); }; export default provideServices; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 25c22222..850fd4b6 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -39,3 +39,7 @@ export type Nullable = { type Optional = T | null | undefined; export type OptionalString = Optional; + +export type RecursivePartial = { + [P in keyof T]?: RecursivePartial; +}; diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index c290f4f3..9018b311 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -1,4 +1,4 @@ -import reducer, { SET_REAL_TIME_UPDATES, setRealTimeUpdates } from '../../../src/settings/reducers/settings'; +import reducer, { SET_REAL_TIME_UPDATES, toggleRealTimeUpdates, setRealTimeUpdatesInterval } from '../../../src/settings/reducers/settings'; describe('settingsReducer', () => { const realTimeUpdates = { enabled: true }; @@ -9,11 +9,19 @@ describe('settingsReducer', () => { }); }); - describe('setRealTimeUpdates', () => { + describe('toggleRealTimeUpdates', () => { it.each([[ true ], [ false ]])('updates settings with provided value and then loads updates again', (enabled) => { - const result = setRealTimeUpdates(enabled); + const result = toggleRealTimeUpdates(enabled); expect(result).toEqual({ type: SET_REAL_TIME_UPDATES, realTimeUpdates: { enabled } }); }); }); + + describe('setRealTimeUpdatesInterval', () => { + it.each([[ 0 ], [ 1 ], [ 2 ], [ 10 ]])('updates settings with provided value and then loads updates again', (interval) => { + const result = setRealTimeUpdatesInterval(interval); + + expect(result).toEqual({ type: SET_REAL_TIME_UPDATES, realTimeUpdates: { interval } }); + }); + }); }); From ad437f655e88fb03b8d0e7d8ca3d65f09e0147bc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Sep 2020 08:52:03 +0200 Subject: [PATCH 3/7] Added support to dispatch all UI actions based on mercure bindings on a specific schedule instead of real time --- src/mercure/helpers/boundToMercureHub.tsx | 17 +++++++++++++---- src/mercure/helpers/index.ts | 2 +- src/mercure/reducers/mercureInfo.ts | 9 +++++---- src/settings/RealTimeUpdates.tsx | 2 +- src/settings/services/provideServices.ts | 5 ++++- test/mercure/helpers/index.test.tsx | 4 ++-- test/mercure/reducers/mercureInfo.test.ts | 4 ++-- test/tags/TagsList.test.tsx | 2 +- test/visits/ShortUrlVisits.test.tsx | 2 +- test/visits/TagVisits.test.tsx | 2 +- 10 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/src/mercure/helpers/boundToMercureHub.tsx index e4f7ae71..b71fa421 100644 --- a/src/mercure/helpers/boundToMercureHub.tsx +++ b/src/mercure/helpers/boundToMercureHub.tsx @@ -13,13 +13,22 @@ export function boundToMercureHub( WrappedComponent: FC, getTopicForProps: (props: T) => string, ) { + const pendingUpdates = new Set(); + return (props: MercureBoundProps & T) => { const { createNewVisit, loadMercureInfo, mercureInfo } = props; + const { interval } = mercureInfo; - useEffect( - bindToMercureTopic(mercureInfo, getTopicForProps(props), createNewVisit, loadMercureInfo), - [ mercureInfo ], - ); + useEffect(() => { + const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisit(visit); + + interval && setInterval(() => { + pendingUpdates.forEach(createNewVisit); + pendingUpdates.clear(); + }, interval * 1000 * 60); + + bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo); + }, [ mercureInfo ]); return ; }; diff --git a/src/mercure/helpers/index.ts b/src/mercure/helpers/index.ts index 997ecf07..19c2176c 100644 --- a/src/mercure/helpers/index.ts +++ b/src/mercure/helpers/index.ts @@ -1,7 +1,7 @@ import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; import { MercureInfo } from '../reducers/mercureInfo'; -export const bindToMercureTopic = (mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => () => { // eslint-disable-line max-len +export const bindToMercureTopic = (mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len const { mercureHubUrl, token, loading, error } = mercureInfo; if (loading || error || !mercureHubUrl) { diff --git a/src/mercure/reducers/mercureInfo.ts b/src/mercure/reducers/mercureInfo.ts index 5b08c364..36017c73 100644 --- a/src/mercure/reducers/mercureInfo.ts +++ b/src/mercure/reducers/mercureInfo.ts @@ -13,11 +13,12 @@ export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO'; export interface MercureInfo { token?: string; mercureHubUrl?: string; + interval?: number; loading: boolean; error: boolean; } -export type GetMercureInfoAction = Action & ShlinkMercureInfo; +export type GetMercureInfoAction = Action & ShlinkMercureInfo & { interval?: number }; const initialState: MercureInfo = { loading: true, @@ -27,7 +28,7 @@ const initialState: MercureInfo = { export default buildReducer({ [GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }), [GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }), - [GET_MERCURE_INFO]: (_, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }), + [GET_MERCURE_INFO]: (_, action) => ({ ...action, loading: false, error: false }), }, initialState); export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) => @@ -44,9 +45,9 @@ export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) => } try { - const result = await mercureInfo(); + const info = await mercureInfo(); - dispatch({ type: GET_MERCURE_INFO, ...result }); + dispatch({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info }); } catch (e) { dispatch({ type: GET_MERCURE_INFO_ERROR }); } diff --git a/src/settings/RealTimeUpdates.tsx b/src/settings/RealTimeUpdates.tsx index d89f6526..2e8b18b9 100644 --- a/src/settings/RealTimeUpdates.tsx +++ b/src/settings/RealTimeUpdates.tsx @@ -30,7 +30,7 @@ const RealTimeUpdates = ( setRealTimeUpdatesInterval(Number(e.target.value))} diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts index 78d86e47..5da9eca1 100644 --- a/src/settings/services/provideServices.ts +++ b/src/settings/services/provideServices.ts @@ -13,7 +13,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Services bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates); - bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdatesInterval' ])); + bottle.decorator( + 'RealTimeUpdates', + connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]), + ); // Actions bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); diff --git a/test/mercure/helpers/index.test.tsx b/test/mercure/helpers/index.test.tsx index 3a7f897f..2fb971b4 100644 --- a/test/mercure/helpers/index.test.tsx +++ b/test/mercure/helpers/index.test.tsx @@ -20,7 +20,7 @@ describe('helpers', () => { [ Mock.of({ loading: false, error: false, mercureHubUrl: undefined }) ], [ Mock.of({ loading: true, error: true, mercureHubUrl: undefined }) ], ])('does not bind an EventSource when loading, error or no hub URL', (mercureInfo) => { - bindToMercureTopic(mercureInfo, '', identity, identity)(); + bindToMercureTopic(mercureInfo, '', identity, identity); expect(EventSource).not.toHaveBeenCalled(); expect(onMessage).not.toHaveBeenCalled(); @@ -40,7 +40,7 @@ describe('helpers', () => { error: false, mercureHubUrl, token, - }, topic, onMessage, onTokenExpired)(); + }, topic, onMessage, onTokenExpired); expect(EventSource).toHaveBeenCalledWith(hubUrl, { headers: { diff --git a/test/mercure/reducers/mercureInfo.test.ts b/test/mercure/reducers/mercureInfo.test.ts index 71954823..50d7ba15 100644 --- a/test/mercure/reducers/mercureInfo.test.ts +++ b/test/mercure/reducers/mercureInfo.test.ts @@ -36,11 +36,11 @@ describe('mercureInfoReducer', () => { }); it('returns mercure info on GET_MERCURE_INFO', () => { - expect(reducer(undefined, { type: GET_MERCURE_INFO, ...mercureInfo })).toEqual({ + expect(reducer(undefined, { type: GET_MERCURE_INFO, ...mercureInfo })).toEqual(expect.objectContaining({ ...mercureInfo, loading: false, error: false, - }); + })); }); }); diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index b72f5dc1..e6191451 100644 --- a/test/tags/TagsList.test.tsx +++ b/test/tags/TagsList.test.tsx @@ -19,7 +19,7 @@ describe('', () => { wrapper = shallow( ()} - {...Mock.all()} + {...Mock.of({ mercureInfo: {} })} forceListTags={identity} filterTags={filterTags} tagsList={Mock.of(tagsList)} diff --git a/test/visits/ShortUrlVisits.test.tsx b/test/visits/ShortUrlVisits.test.tsx index 8a980daa..7cb25272 100644 --- a/test/visits/ShortUrlVisits.test.tsx +++ b/test/visits/ShortUrlVisits.test.tsx @@ -26,7 +26,7 @@ describe('', () => { wrapper = shallow( ()} - {...Mock.all()} + {...Mock.of({ mercureInfo: {} })} getShortUrlDetail={identity} getShortUrlVisits={getShortUrlVisitsMock} match={match} diff --git a/test/visits/TagVisits.test.tsx b/test/visits/TagVisits.test.tsx index 5cc80f03..5d87c698 100644 --- a/test/visits/TagVisits.test.tsx +++ b/test/visits/TagVisits.test.tsx @@ -26,7 +26,7 @@ describe('', () => { wrapper = shallow( ()} - {...Mock.all()} + {...Mock.of({ mercureInfo: {} })} getTagVisits={getTagVisitsMock} match={match} history={history} From 6fc4963663d858c877d101b01cad955b66a9a17e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Sep 2020 11:31:44 +0200 Subject: [PATCH 4/7] Replaced redux action to create one visit by action that allows multiple visits at once --- src/mercure/helpers/boundToMercureHub.tsx | 8 +++---- src/short-urls/reducers/shortUrlsList.ts | 21 +++++++++------- src/short-urls/services/provideServices.ts | 2 +- src/tags/helpers/TagsSelector.tsx | 1 + src/tags/reducers/tagsList.ts | 24 ++++++++++++++----- src/tags/services/provideServices.ts | 2 +- src/visits/reducers/shortUrlVisits.ts | 14 +++++------ src/visits/reducers/tagVisits.ts | 15 ++++++------ src/visits/reducers/visitCreation.ts | 13 +++++----- src/visits/services/provideServices.ts | 8 +++---- .../short-urls/reducers/shortUrlsList.test.ts | 4 ++-- test/visits/reducers/shortUrlVisits.test.ts | 4 ++-- test/visits/reducers/tagVisits.test.ts | 4 ++-- test/visits/reducers/visitCreation.test.ts | 8 +++---- 14 files changed, 73 insertions(+), 55 deletions(-) diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/src/mercure/helpers/boundToMercureHub.tsx index b71fa421..1960aec3 100644 --- a/src/mercure/helpers/boundToMercureHub.tsx +++ b/src/mercure/helpers/boundToMercureHub.tsx @@ -4,7 +4,7 @@ import { MercureInfo } from '../reducers/mercureInfo'; import { bindToMercureTopic } from './index'; export interface MercureBoundProps { - createNewVisit: (visitData: CreateVisit) => void; + createNewVisits: (createdVisits: CreateVisit[]) => void; loadMercureInfo: Function; mercureInfo: MercureInfo; } @@ -16,14 +16,14 @@ export function boundToMercureHub( const pendingUpdates = new Set(); return (props: MercureBoundProps & T) => { - const { createNewVisit, loadMercureInfo, mercureInfo } = props; + const { createNewVisits, loadMercureInfo, mercureInfo } = props; const { interval } = mercureInfo; useEffect(() => { - const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisit(visit); + const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]); interval && setInterval(() => { - pendingUpdates.forEach(createNewVisit); + createNewVisits([ ...pendingUpdates ]); pendingUpdates.clear(); }, interval * 1000 * 60); diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 4370fb20..a07e50b4 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -1,7 +1,7 @@ -import { assoc, assocPath, reject } from 'ramda'; +import { assoc, assocPath, last, reject } from 'ramda'; import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../helpers'; -import { CREATE_VISIT, CreateVisitAction } from '../../visits/reducers/visitCreation'; +import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation'; import { ShortUrl, ShortUrlIdentifier } from '../data'; import { buildReducer } from '../../utils/helpers/redux'; import { GetState } from '../../container/types'; @@ -31,7 +31,7 @@ export interface ListShortUrlsAction extends Action { } export type ListShortUrlsCombinedAction = ( - ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitAction + ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction ); const initialState: ShortUrlsList = { @@ -63,12 +63,17 @@ export default buildReducer({ [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), - [CREATE_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( + [CREATE_VISITS]: (state, { createdVisits }) => assocPath( [ 'shortUrls', 'data' ], - state.shortUrls && state.shortUrls.data && state.shortUrls.data.map( - (shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) - ? assoc('visitsCount', visitsCount, shortUrl) - : shortUrl, + state.shortUrls?.data?.map( + (currentShortUrl) => { + // Find the last of the new visit for this short URL, and pick the amount of visits from it + const lastVisit = last( + createdVisits.filter(({ shortUrl }) => shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain)), + ); + + return lastVisit ? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl) : currentShortUrl; + }, ), state, ), diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 8e86e7c9..b7ee0102 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -35,7 +35,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow'); bottle.decorator('ShortUrlsList', connect( [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ], - [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ], + [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ], )); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index 63da0deb..361f4737 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/src/tags/helpers/TagsSelector.tsx @@ -56,6 +56,7 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( )} onSuggestionsFetchRequested={() => {}} + onSuggestionsClearRequested={() => {}} onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData) => { addTag(suggestion); }} diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index ea6a6711..e33ba2ff 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -1,11 +1,12 @@ import { isEmpty, reject } from 'ramda'; import { Action, Dispatch } from 'redux'; -import { CREATE_VISIT, CreateVisitAction } from '../../visits/reducers/visitCreation'; +import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkTags } from '../../utils/services/types'; import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder'; import { TagStats } from '../data'; +import { CreateVisit, Stats } from '../../visits/types'; import { DeleteTagAction, TAG_DELETED } from './tagDelete'; import { EditTagAction, TAG_EDITED } from './tagEdit'; @@ -35,7 +36,7 @@ interface FilterTagsAction extends Action { searchTerm: string; } -type ListTagsCombinedAction = ListTagsAction & DeleteTagAction & CreateVisitAction & EditTagAction & FilterTagsAction; +type ListTagsCombinedAction = ListTagsAction & DeleteTagAction & CreateVisitsAction & EditTagAction & FilterTagsAction; const initialState = { tags: [], @@ -45,20 +46,31 @@ const initialState = { error: false, }; +type TagIncrease = [string, number]; + const renameTag = (oldName: string, newName: string) => (tag: string) => tag === oldName ? newName : tag; const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags); -const increaseVisitsForTags = (tags: string[], stats: TagsStatsMap) => tags.reduce((stats, tag) => { +const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags.reduce((stats, [ tag, increase ]) => { if (!stats[tag]) { return stats; } const tagStats = stats[tag]; - tagStats.visitsCount = tagStats.visitsCount + 1; + tagStats.visitsCount = tagStats.visitsCount + increase; stats[tag] = tagStats; return stats; }, { ...stats }); +const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries( + createdVisits.reduce((acc, { shortUrl }) => { + shortUrl.tags.forEach((tag) => { + acc[tag] = (acc[tag] || 0) + 1; + }); + + return acc; + }, {} as Stats), +); export default buildReducer({ [LIST_TAGS_START]: () => ({ ...initialState, loading: true }), @@ -78,9 +90,9 @@ export default buildReducer({ ...state, filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)), }), - [CREATE_VISIT]: (state, { shortUrl }) => ({ + [CREATE_VISITS]: (state, { createdVisits }) => ({ ...state, - stats: increaseVisitsForTags(shortUrl.tags, state.stats), + stats: increaseVisitsForTags(calculateVisitsPerTag(createdVisits), state.stats), }), }, initialState); diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index be38b20d..3e71c32f 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -32,7 +32,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('TagsList', TagsList, 'TagCard'); bottle.decorator('TagsList', connect( [ 'tagsList', 'selectedServer', 'mercureInfo' ], - [ 'forceListTags', 'filterTags', 'createNewVisit', 'loadMercureInfo' ], + [ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ], )); // Actions diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 5f56121e..a7ee4407 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -7,7 +7,7 @@ import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuil import { GetState } from '../../container/types'; import { OptionalString } from '../../utils/utils'; import { getVisitsWithLoader } from './common'; -import { CREATE_VISIT, CreateVisitAction } from './visitCreation'; +import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -24,7 +24,7 @@ interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { visits: Visit[]; } -type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & CreateVisitAction; +type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction; const initialState: ShortUrlVisits = { visits: [], @@ -49,14 +49,14 @@ export default buildReducer({ [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), - [CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand + [CREATE_VISITS]: (state, { createdVisits }) => { // eslint-disable-line object-shorthand const { shortCode, domain, visits } = state; - if (!shortUrlMatches(shortUrl, shortCode, domain)) { - return state; - } + const newVisits = createdVisits + .filter(({ shortUrl }) => shortUrlMatches(shortUrl, shortCode, domain)) + .map(({ visit }) => visit); - return { ...state, visits: [ ...visits, visit ] }; + return { ...state, visits: [ ...visits, ...newVisits ] }; }, }, initialState); diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 76491994..04fcc498 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -4,7 +4,7 @@ import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { getVisitsWithLoader } from './common'; -import { CREATE_VISIT, CreateVisitAction } from './visitCreation'; +import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START'; @@ -34,21 +34,20 @@ const initialState: TagVisits = { progress: 0, }; -export default buildReducer({ +export default buildReducer({ [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_TAG_VISITS_ERROR]: () => ({ ...initialState, error: true }), [GET_TAG_VISITS]: (_, { visits, tag }) => ({ ...initialState, visits, tag }), [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), - [CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand + [CREATE_VISITS]: (state, { createdVisits }) => { // eslint-disable-line object-shorthand const { tag, visits } = state; + const newVisits = createdVisits + .filter(({ shortUrl }) => shortUrl.tags.includes(tag)) + .map(({ visit }) => visit); - if (!shortUrl.tags.includes(tag)) { - return state; - } - - return { ...state, visits: [ ...visits, visit ] }; + return { ...state, visits: [ ...visits, ...newVisits ] }; }, }, initialState); diff --git a/src/visits/reducers/visitCreation.ts b/src/visits/reducers/visitCreation.ts index 3b89318d..e2335fc4 100644 --- a/src/visits/reducers/visitCreation.ts +++ b/src/visits/reducers/visitCreation.ts @@ -1,12 +1,13 @@ import { Action } from 'redux'; import { CreateVisit } from '../types'; -export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT'; +export const CREATE_VISITS = 'shlink/visitCreation/CREATE_VISITS'; -export type CreateVisitAction = Action & CreateVisit; +export interface CreateVisitsAction extends Action { + createdVisits: CreateVisit[]; +} -export const createNewVisit = ({ shortUrl, visit }: CreateVisit): CreateVisitAction => ({ - type: CREATE_VISIT, - shortUrl, - visit, +export const createNewVisits = (createdVisits: CreateVisit[]): CreateVisitsAction => ({ + type: CREATE_VISITS, + createdVisits, }); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 80372ebc..5f51c685 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -3,7 +3,7 @@ import ShortUrlVisits from '../ShortUrlVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import MapModal from '../helpers/MapModal'; -import { createNewVisit } from '../reducers/visitCreation'; +import { createNewVisits } from '../reducers/visitCreation'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import TagVisits from '../TagVisits'; import { ConnectDecorator } from '../../container/types'; @@ -15,12 +15,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits); bottle.decorator('ShortUrlVisits', connect( [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ], - [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ], + [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], )); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator'); bottle.decorator('TagVisits', connect( [ 'tagVisits', 'mercureInfo' ], - [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ], + [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], )); // Services @@ -34,7 +34,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); - bottle.serviceFactory('createNewVisit', () => createNewVisit); + bottle.serviceFactory('createNewVisits', () => createNewVisits); }; export default provideServices; diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index ea77b662..e677b352 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -8,7 +8,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_VISIT } from '../../../src/visits/reducers/visitCreation'; +import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; import { ShlinkShortUrlsResponse } from '../../../src/utils/services/types'; @@ -135,7 +135,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: CREATE_VISIT, shortUrl } as any)).toEqual({ + expect(reducer(state, { type: CREATE_VISITS, createdVisits: [{ shortUrl }] } as any)).toEqual({ shortUrls: { data: [ { shortCode, domain: 'example.com', visitsCount: 5 }, diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index e71bc38c..8863f4f1 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -10,7 +10,7 @@ import reducer, { GET_SHORT_URL_VISITS_PROGRESS_CHANGED, ShortUrlVisits, } from '../../../src/visits/reducers/shortUrlVisits'; -import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; +import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { rangeOf } from '../../../src/utils/utils'; import { Visit } from '../../../src/visits/types'; import { ShlinkVisits } from '../../../src/utils/services/types'; @@ -77,7 +77,7 @@ describe('shortUrlVisitsReducer', () => { visits: visitsMocks, }); - const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} } as any); + const { visits } = reducer(prevState, { type: CREATE_VISITS, createdVisits: [{ shortUrl, visit: {} }] } as any); expect(visits).toEqual(expectedVisits); }); diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index d7fc93c6..caf300bb 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -10,7 +10,7 @@ import reducer, { GET_TAG_VISITS_PROGRESS_CHANGED, TagVisits, } from '../../../src/visits/reducers/tagVisits'; -import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; +import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { rangeOf } from '../../../src/utils/utils'; import { Visit } from '../../../src/visits/types'; import { ShlinkVisits } from '../../../src/utils/services/types'; @@ -77,7 +77,7 @@ describe('tagVisitsReducer', () => { visits: visitsMocks, }); - const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} } as any); + const { visits } = reducer(prevState, { type: CREATE_VISITS, createdVisits: [{ shortUrl, visit: {} }] } as any); expect(visits).toEqual(expectedVisits); }); diff --git a/test/visits/reducers/visitCreation.test.ts b/test/visits/reducers/visitCreation.test.ts index 4149f99c..ecfb1dd5 100644 --- a/test/visits/reducers/visitCreation.test.ts +++ b/test/visits/reducers/visitCreation.test.ts @@ -1,16 +1,16 @@ import { Mock } from 'ts-mockery'; -import { CREATE_VISIT, createNewVisit } from '../../../src/visits/reducers/visitCreation'; +import { CREATE_VISITS, createNewVisits } from '../../../src/visits/reducers/visitCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import { Visit } from '../../../src/visits/types'; describe('visitCreationReducer', () => { - describe('createNewVisit', () => { + describe('createNewVisits', () => { const shortUrl = Mock.all(); const visit = Mock.all(); it('just returns the action with proper type', () => - expect(createNewVisit({ shortUrl, visit })).toEqual( - { type: CREATE_VISIT, shortUrl, visit }, + expect(createNewVisits([{ shortUrl, visit }])).toEqual( + { type: CREATE_VISITS, createdVisits: [{ shortUrl, visit }] }, )); }); }); From fa074f91be7a90c304f2822dba6087c5cc4072f0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Sep 2020 11:35:12 +0200 Subject: [PATCH 5/7] Updated changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3566328d..d49c2084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#289](https://github.com/shlinkio/shlink-web-client/issues/289) Client and server version constraints are now links to the corresponding project release notes. * [#293](https://github.com/shlinkio/shlink-web-client/issues/293) Shlink versions are now always displayed in footer, hiding the server version when there's no connected server. +* [#250](https://github.com/shlinkio/shlink-web-client/issues/250) Added support to group real time updates in fixed intervals. + + The settings page now allows to provide the interval in which the UI should get updated, making that happen at once, with all the updates that have happened during that interval. + + By default updates are immediately applied if real-time updates are enabled, to keep the behavior as it was. #### Changed From cfb165d24056255cdc12839c0696c67b5a33ca5b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Sep 2020 11:55:49 +0200 Subject: [PATCH 6/7] Fixed boundToMercureHub HOC so that it clears updates intervals when unmounted --- src/mercure/helpers/boundToMercureHub.tsx | 10 ++++++++-- src/settings/RealTimeUpdates.tsx | 8 +++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/src/mercure/helpers/boundToMercureHub.tsx index 1960aec3..b910404c 100644 --- a/src/mercure/helpers/boundToMercureHub.tsx +++ b/src/mercure/helpers/boundToMercureHub.tsx @@ -22,12 +22,18 @@ export function boundToMercureHub( useEffect(() => { const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]); - interval && setInterval(() => { + bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo); + + if (!interval) { + return undefined; + } + + const timer = setInterval(() => { createNewVisits([ ...pendingUpdates ]); pendingUpdates.clear(); }, interval * 1000 * 60); - bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo); + return () => clearInterval(timer); }, [ mercureInfo ]); return ; diff --git a/src/settings/RealTimeUpdates.tsx b/src/settings/RealTimeUpdates.tsx index 2e8b18b9..58195138 100644 --- a/src/settings/RealTimeUpdates.tsx +++ b/src/settings/RealTimeUpdates.tsx @@ -37,9 +37,11 @@ const RealTimeUpdates = ( /> {realTimeUpdates.enabled && ( - {realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && - Updates will be reflected in the UI every {realTimeUpdates.interval} minutes. - } + {realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && ( + + Updates will be reflected in the UI every {realTimeUpdates.interval} minute{realTimeUpdates.interval > 1 && 's'}. + + )} {!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'} )} From fe3e08de0f3a1c93cacade15892058438a0a61d8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Sep 2020 12:06:53 +0200 Subject: [PATCH 7/7] Fixed event source not being properly closed with new boundToMercureHub HOC --- src/mercure/helpers/boundToMercureHub.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/src/mercure/helpers/boundToMercureHub.tsx index b910404c..e952165f 100644 --- a/src/mercure/helpers/boundToMercureHub.tsx +++ b/src/mercure/helpers/boundToMercureHub.tsx @@ -1,4 +1,5 @@ import React, { FC, useEffect } from 'react'; +import { pipe } from 'ramda'; import { CreateVisit } from '../../visits/types'; import { MercureInfo } from '../reducers/mercureInfo'; import { bindToMercureTopic } from './index'; @@ -21,11 +22,10 @@ export function boundToMercureHub( useEffect(() => { const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]); - - bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo); + const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo); if (!interval) { - return undefined; + return closeEventSource; } const timer = setInterval(() => { @@ -33,7 +33,7 @@ export function boundToMercureHub( pendingUpdates.clear(); }, interval * 1000 * 60); - return () => clearInterval(timer); + return pipe(() => clearInterval(timer), () => closeEventSource?.()); }, [ mercureInfo ]); return ;