Merge pull request #299 from acelaya-forks/feature/updates-interval

Feature/updates interval
This commit is contained in:
Alejandro Celaya 2020-09-12 13:18:23 +02:00 committed by GitHub
commit bd88e56331
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 226 additions and 128 deletions

View file

@ -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

View file

@ -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<T = {}>(
WrappedComponent: FC<MercureBoundProps & T>,
getTopicForProps: (props: T) => string,
) {
const pendingUpdates = new Set<CreateVisit>();
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 <WrappedComponent {...props} />;
};
}

View file

@ -1,8 +1,7 @@
import { useEffect } from 'react';
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
import { MercureInfo } from '../reducers/mercureInfo';
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => () => { // eslint-disable-line max-len
export const bindToMercureTopic = <T>(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 = <T>(mercureInfo: MercureInfo, topic: string, o
return () => es.close();
};
export const useMercureTopicBinding = <T>(
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;
}

View file

@ -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<string> & ShlinkMercureInfo;
export type GetMercureInfoAction = Action<string> & ShlinkMercureInfo & { interval?: number };
const initialState: MercureInfo = {
loading: true,
@ -27,7 +28,7 @@ const initialState: MercureInfo = {
export default buildReducer<MercureInfo, GetMercureInfoAction>({
[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<GetMercureInfoAction>({ type: GET_MERCURE_INFO, ...result });
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info });
} catch (e) {
dispatch({ type: GET_MERCURE_INFO_ERROR });
}

View file

@ -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,
) => (
<Card>
<CardHeader>Real-time updates</CardHeader>
<CardBody>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={setRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</ToggleSwitch>
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
)}
</FormGroup>
</CardBody>
</Card>
);

View file

@ -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<Settings>;
export default buildReducer<Settings, SettingsAction>({
[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 },
});

View file

@ -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;

View file

@ -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<RouteParams>, MercureBoundProps {
export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps<RouteParams> {
selectedServer: SelectedServer;
listShortUrls: (params: ShortUrlsListParams) => void;
shortUrlsListParams: ShortUrlsListParams;
resetShortUrlParams: () => void;
}
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub(({
listShortUrls,
resetShortUrlParams,
shortUrlsListParams,
@ -48,9 +48,6 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
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<ShortUrlsRowProps>) => ({
return resetShortUrlParams;
}, []);
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
return (
<React.Fragment>
@ -168,6 +164,6 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
</table>
</React.Fragment>
);
};
}, () => 'https://shlink.io/new-visit');
export default ShortUrlsList;

View file

@ -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<string> {
}
export type ListShortUrlsCombinedAction = (
ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitAction
ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction
);
const initialState: ShortUrlsList = {
@ -63,12 +63,17 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlEditedAction>('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,
),

View file

@ -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');

View file

@ -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<TagCardProps>) => (
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo }: TagsListProps,
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
) => {
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
useEffect(() => {
forceListTags();
}, []);
useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo);
const renderContent = () => {
if (tagsList.loading) {
@ -76,6 +75,6 @@ const TagsList = (TagCard: FC<TagCardProps>) => (
</div>
</React.Fragment>
);
};
}, () => 'https://shlink.io/new-visit');
export default TagsList;

View file

@ -56,6 +56,7 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
</React.Fragment>
)}
onSuggestionsFetchRequested={() => {}}
onSuggestionsClearRequested={() => {}}
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
addTag(suggestion);
}}

View file

@ -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<string> {
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<TagsList, ListTagsCombinedAction>({
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
@ -78,9 +90,9 @@ export default buildReducer<TagsList, ListTagsCombinedAction>({
...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);

View file

@ -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

View file

@ -39,3 +39,7 @@ export type Nullable<T> = {
type Optional<T> = T | null | undefined;
export type OptionalString = Optional<string>;
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};

View file

@ -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 (
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits}>
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats>
);
};
}, ({ match }) => `https://shlink.io/new-visit/${match.params.shortCode}`);
export default ShortUrlVisits;

View file

@ -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 (
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits}>
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats>
);
};
}, () => 'https://shlink.io/new-visit');
export default TagVisits;

View file

@ -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<string>, ShortUrlIdentifier {
visits: Visit[];
}
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & CreateVisitAction;
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction;
const initialState: ShortUrlVisits = {
visits: [],
@ -49,14 +49,14 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
[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);

View file

@ -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<TagVisits, TagVisitsAction & VisitsLoadProgressChangedAction & CreateVisitAction>({
export default buildReducer<TagVisits, TagVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction>({
[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);

View file

@ -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<typeof CREATE_VISIT> & CreateVisit;
export interface CreateVisitsAction extends Action<typeof CREATE_VISITS> {
createdVisits: CreateVisit[];
}
export const createNewVisit = ({ shortUrl, visit }: CreateVisit): CreateVisitAction => ({
type: CREATE_VISIT,
shortUrl,
visit,
export const createNewVisits = (createdVisits: CreateVisit[]): CreateVisitsAction => ({
type: CREATE_VISITS,
createdVisits,
});

View file

@ -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;

View file

@ -7,10 +7,9 @@ import appFactory from '../src/App';
describe('<App />', () => {
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(<App fetchServers={identity} servers={{}} />);
});

View file

@ -20,7 +20,7 @@ describe('helpers', () => {
[ Mock.of<MercureInfo>({ loading: false, error: false, mercureHubUrl: undefined }) ],
[ Mock.of<MercureInfo>({ 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: {

View file

@ -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,
});
}));
});
});

View file

@ -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 } });
});
});
});

View file

@ -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('<ShortUrlsList />', () => {
let wrapper: ShallowWrapper;
@ -18,6 +19,7 @@ describe('<ShortUrlsList />', () => {
wrapper = shallow(
<ShortUrlsList
{...Mock.all<ShortUrlsListProps>()}
{...Mock.of<MercureBoundProps>({ mercureInfo: { loading: true } })}
listShortUrls={listShortUrlsMock}
resetShortUrlParams={resetShortUrlParamsMock}
shortUrlsListParams={{
@ -39,9 +41,8 @@ describe('<ShortUrlsList />', () => {
}),
]
}
mercureInfo={{ loading: true } as any}
/>,
);
).dive(); // Dive is needed as this component is wrapped in a HOC
});
afterEach(jest.resetAllMocks);

View file

@ -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 },

View file

@ -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('<TagsList />', () => {
let wrapper: ShallowWrapper;
@ -18,11 +19,12 @@ describe('<TagsList />', () => {
wrapper = shallow(
<TagsListComp
{...Mock.all<TagsListProps>()}
{...Mock.of<MercureBoundProps>({ mercureInfo: {} })}
forceListTags={identity}
filterTags={filterTags}
tagsList={Mock.of<TagsList>(tagsList)}
/>,
);
).dive(); // Dive is needed as this component is wrapped in a HOC
return wrapper;
};

View file

@ -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('<ShortUrlVisits />', () => {
let wrapper: ShallowWrapper;
@ -25,6 +26,7 @@ describe('<ShortUrlVisits />', () => {
wrapper = shallow(
<ShortUrlVisits
{...Mock.all<ShortUrlVisitsProps>()}
{...Mock.of<MercureBoundProps>({ mercureInfo: {} })}
getShortUrlDetail={identity}
getShortUrlVisits={getShortUrlVisitsMock}
match={match}
@ -34,7 +36,7 @@ describe('<ShortUrlVisits />', () => {
shortUrlDetail={Mock.all<ShortUrlDetail>()}
cancelGetShortUrlVisits={() => {}}
/>,
);
).dive(); // Dive is needed as this component is wrapped in a HOC
});
afterEach(() => wrapper.unmount());

View file

@ -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('<TagVisits />', () => {
let wrapper: ShallowWrapper;
@ -25,13 +26,14 @@ describe('<TagVisits />', () => {
wrapper = shallow(
<TagVisits
{...Mock.all<TagVisitsProps>()}
{...Mock.of<MercureBoundProps>({ mercureInfo: {} })}
getTagVisits={getTagVisitsMock}
match={match}
history={history}
tagVisits={Mock.of<TagVisitsStats>({ loading: true, visits: [] })}
cancelGetTagVisits={() => {}}
/>,
);
).dive(); // Dive is needed as this component is wrapped in a HOC
});
afterEach(() => wrapper.unmount());

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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<ShortUrl>();
const visit = Mock.all<Visit>();
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 }] },
));
});
});