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 diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/src/mercure/helpers/boundToMercureHub.tsx new file mode 100644 index 00000000..e952165f --- /dev/null +++ b/src/mercure/helpers/boundToMercureHub.tsx @@ -0,0 +1,41 @@ +import React, { FC, useEffect } from 'react'; +import { pipe } from 'ramda'; +import { CreateVisit } from '../../visits/types'; +import { MercureInfo } from '../reducers/mercureInfo'; +import { bindToMercureTopic } from './index'; + +export interface MercureBoundProps { + createNewVisits: (createdVisits: CreateVisit[]) => void; + loadMercureInfo: Function; + mercureInfo: MercureInfo; +} + +export function boundToMercureHub( + WrappedComponent: FC, + getTopicForProps: (props: T) => string, +) { + const pendingUpdates = new Set(); + + return (props: MercureBoundProps & T) => { + const { createNewVisits, loadMercureInfo, mercureInfo } = props; + const { interval } = mercureInfo; + + useEffect(() => { + const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]); + const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo); + + if (!interval) { + return closeEventSource; + } + + const timer = setInterval(() => { + createNewVisits([ ...pendingUpdates ]); + pendingUpdates.clear(); + }, interval * 1000 * 60); + + return pipe(() => clearInterval(timer), () => closeEventSource?.()); + }, [ mercureInfo ]); + + return ; + }; +} diff --git a/src/mercure/helpers/index.ts b/src/mercure/helpers/index.ts index 4fa89f24..19c2176c 100644 --- a/src/mercure/helpers/index.ts +++ b/src/mercure/helpers/index.ts @@ -1,8 +1,7 @@ -import { useEffect } from 'react'; 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) { @@ -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/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 dc89f1d5..58195138 100644 --- a/src/settings/RealTimeUpdates.tsx +++ b/src/settings/RealTimeUpdates.tsx @@ -1,20 +1,51 @@ 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} minute{realTimeUpdates.interval > 1 && 's'}. + + )} + {!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..5da9eca1 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,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Services bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates); - bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdates' ])); + bottle.decorator( + 'RealTimeUpdates', + connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]), + ); // Actions - bottle.serviceFactory('setRealTimeUpdates', () => setRealTimeUpdates); + bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); + bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); }; export default provideServices; 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/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/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/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/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/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/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/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/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/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 } }); + }); + }); }); 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/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/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index 4cfe8790..e6191451 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.of({ mercureInfo: {} })} 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..7cb25272 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.of({ mercureInfo: {} })} 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..5d87c698 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.of({ mercureInfo: {} })} 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()); 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 }] }, )); }); });