Added automatic refresh on mercure events

This commit is contained in:
Alejandro Celaya 2020-04-18 10:50:01 +02:00
parent 0f73cb9f8c
commit a22a1938c1
16 changed files with 132 additions and 54 deletions

View file

@ -0,0 +1,25 @@
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
export const bindToMercureTopic = (mercureInfo, topic, onMessage) => () => {
const { mercureHubUrl, token, loading, error } = mercureInfo;
if (loading || error) {
return undefined;
}
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', topic);
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
es.onmessage = ({ data }) => onMessage(JSON.parse(data));
// TODO Handle errors and get a new token
es.onerror = () => {};
return () => es.close();
};

View file

@ -4,11 +4,11 @@ import { head, isEmpty, keys, values } from 'ramda';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import qs from 'qs'; import qs from 'qs';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import SortingDropdown from '../utils/SortingDropdown'; import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils'; import { determineOrderDir } from '../utils/utils';
import { MercureInfoType } from '../mercure/reducers/mercureInfo'; import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { bindToMercureTopic } from '../mercure/helpers';
import { shortUrlType } from './reducers/shortUrlsList'; import { shortUrlType } from './reducers/shortUrlsList';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss'; import './ShortUrlsList.scss';
@ -114,27 +114,7 @@ const ShortUrlsList = (ShortUrlsRow) => {
return resetShortUrlParams; return resetShortUrlParams;
}, []); }, []);
useEffect(() => { useEffect(bindToMercureTopic(mercureInfo, 'https://shlink.io/new-visit', createNewVisit), [ mercureInfo ]);
const { mercureHubUrl, token, loading, error } = mercureInfo;
if (loading || error) {
return undefined;
}
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit');
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
// es.onmessage = pipe(JSON.parse, createNewVisit);
es.onmessage = ({ data }) => createNewVisit(JSON.parse(data));
return () => es.close();
}, [ mercureInfo ]);
return ( return (
<React.Fragment> <React.Fragment>

View file

@ -3,22 +3,28 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import classNames from 'classnames';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import './ShortUrlVisitsCount.scss';
import VisitStatsLink from './VisitStatsLink'; import VisitStatsLink from './VisitStatsLink';
import './ShortUrlVisitsCount.scss';
const propTypes = { const propTypes = {
visitsCount: PropTypes.number.isRequired, visitsCount: PropTypes.number.isRequired,
shortUrl: shortUrlType, shortUrl: shortUrlType,
selectedServer: serverType, selectedServer: serverType,
active: PropTypes.bool,
}; };
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => { const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }) => {
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits; const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
const visitsLink = ( const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}> <VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
<strong>{visitsCount}</strong> <strong
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
>
{visitsCount}
</strong>
</VisitStatsLink> </VisitStatsLink>
); );

View file

@ -1,3 +1,12 @@
.short-urls-visits-count__max-visits-control { .short-urls-visits-count__max-visits-control {
cursor: help; cursor: help;
} }
.short-url-visits-count__amount {
transition: transform .3s ease;
display: inline-block;
}
.short-url-visits-count__amount--big {
transform: scale(1.5);
}

View file

@ -1,5 +1,5 @@
import { isEmpty } from 'ramda'; import { isEmpty } from 'ramda';
import React from 'react'; import React, { useEffect, useRef } from 'react';
import Moment from 'react-moment'; import Moment from 'react-moment';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
@ -26,7 +26,10 @@ const ShortUrlsRow = (
useStateFlagTimeout useStateFlagTimeout
) => { ) => {
const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => { const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(false); const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout();
const [ active, setActive ] = useStateFlagTimeout(false, 500);
const isFirstRun = useRef(true);
const renderTags = (tags) => { const renderTags = (tags) => {
if (isEmpty(tags)) { if (isEmpty(tags)) {
return <i className="indivisible"><small>No tags</small></i>; return <i className="indivisible"><small>No tags</small></i>;
@ -44,6 +47,14 @@ const ShortUrlsRow = (
)); ));
}; };
useEffect(() => {
if (isFirstRun.current) {
isFirstRun.current = false;
} else {
setActive(true);
}
}, [ shortUrl.visitsCount ]);
return ( return (
<tr className="short-urls-row"> <tr className="short-urls-row">
<td className="indivisible short-urls-row__cell" data-th="Created at: "> <td className="indivisible short-urls-row__cell" data-th="Created at: ">
@ -69,6 +80,7 @@ const ShortUrlsRow = (
visitsCount={shortUrl.visitsCount} visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
active={active}
/> />
</td> </td>
<td className="short-urls-row__cell"> <td className="short-urls-row__cell">

View file

@ -35,6 +35,7 @@
} }
} }
} }
.short-urls-row__cell--break { .short-urls-row__cell--break {
word-break: break-all; word-break: break-all;
} }
@ -43,6 +44,10 @@
position: relative; position: relative;
} }
.short-urls-row__cell--big {
transform: scale(1.5);
}
.short-urls-row__copy-btn { .short-urls-row__copy-btn {
cursor: pointer; cursor: pointer;
font-size: 1.2rem; font-size: 1.2rem;

View file

@ -0,0 +1,9 @@
import { isNil } from 'ramda';
export const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};

View file

@ -1,7 +1,8 @@
import { handleActions } from 'redux-actions'; import { handleActions } from 'redux-actions';
import { assoc, assocPath, isNil, reject } from 'ramda'; import { assoc, assocPath, reject } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits'; import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits';
import { shortUrlMatches } from '../helpers';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
@ -29,14 +30,6 @@ const initialState = {
error: false, error: false,
}; };
const shortUrlMatches = (shortUrl, shortCode, domain) => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath( const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
state.shortUrls.data.map( state.shortUrls.data.map(
@ -59,7 +52,7 @@ export default handleActions({
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
[CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( [CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
state.shortUrls.data.map( state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) (shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
? assoc('visitsCount', visitsCount, shortUrl) ? assoc('visitsCount', visitsCount, shortUrl)
: shortUrl : shortUrl

View file

@ -1,12 +1,18 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
const DEFAULT_TIMEOUT_DELAY = 2000; const DEFAULT_DELAY = 2000;
export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => { export const useStateFlagTimeout = (setTimeout, clearTimeout) => (initialValue = false, delay = DEFAULT_DELAY) => {
const [ flag, setFlag ] = useState(initialValue); const [ flag, setFlag ] = useState(initialValue);
const timeout = useRef(undefined);
const callback = () => { const callback = () => {
setFlag(!initialValue); setFlag(!initialValue);
setTimeout(() => setFlag(initialValue), delay);
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => setFlag(initialValue), delay);
}; };
return [ flag, callback ]; return [ flag, callback ];

View file

@ -14,8 +14,9 @@ const provideServices = (bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios'); bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
bottle.constant('setTimeout', global.setTimeout); bottle.constant('setTimeout', global.setTimeout);
bottle.constant('clearTimeout', global.clearTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout'); bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout'); bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout');
}; };
export default provideServices; export default provideServices;

View file

@ -10,6 +10,8 @@ import DateRangeRow from '../utils/DateRangeRow';
import Message from '../utils/Message'; import Message from '../utils/Message';
import { formatDate } from '../utils/helpers/date'; import { formatDate } from '../utils/helpers/date';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { bindToMercureTopic } from '../mercure/helpers';
import SortableBarGraph from './SortableBarGraph'; import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader'; import VisitsHeader from './VisitsHeader';
@ -30,6 +32,8 @@ const propTypes = {
shortUrlDetail: shortUrlDetailType, shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func, cancelGetShortUrlVisits: PropTypes.func,
matchMedia: PropTypes.func, matchMedia: PropTypes.func,
createNewVisit: PropTypes.func,
mercureInfo: MercureInfoType,
}; };
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => { const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
@ -54,6 +58,8 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
getShortUrlDetail, getShortUrlDetail,
cancelGetShortUrlVisits, cancelGetShortUrlVisits,
matchMedia = window.matchMedia, matchMedia = window.matchMedia,
createNewVisit,
mercureInfo,
}) => { }) => {
const [ startDate, setStartDate ] = useState(undefined); const [ startDate, setStartDate ] = useState(undefined);
const [ endDate, setEndDate ] = useState(undefined); const [ endDate, setEndDate ] = useState(undefined);
@ -108,6 +114,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
useEffect(() => { useEffect(() => {
loadVisits(); loadVisits();
}, [ startDate, endDate ]); }, [ startDate, endDate ]);
useEffect(
bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit),
[ mercureInfo ],
);
const renderVisitsContent = () => { const renderVisitsContent = () => {
if (loading) { if (loading) {

View file

@ -1,6 +1,7 @@
import { createAction, handleActions } from 'redux-actions'; import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { flatten, prop, range, splitEvery } from 'ramda'; import { flatten, prop, range, splitEvery } from 'ramda';
import { shortUrlMatches } from '../../short-urls/helpers';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
@ -29,12 +30,16 @@ export const visitType = PropTypes.shape({
export const shortUrlVisitsType = PropTypes.shape({ export const shortUrlVisitsType = PropTypes.shape({
visits: PropTypes.arrayOf(visitType), visits: PropTypes.arrayOf(visitType),
shortCode: PropTypes.string,
domain: PropTypes.string,
loading: PropTypes.bool, loading: PropTypes.bool,
error: PropTypes.bool, error: PropTypes.bool,
}); });
const initialState = { const initialState = {
visits: [], visits: [],
shortCode: '',
domain: undefined,
loading: false, loading: false,
loadingLarge: false, loadingLarge: false,
error: false, error: false,
@ -55,8 +60,10 @@ export default handleActions({
error: true, error: true,
cancelLoad: false, cancelLoad: false,
}), }),
[GET_SHORT_URL_VISITS]: (state, { visits }) => ({ [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({
visits, visits,
shortCode,
domain,
loading: false, loading: false,
loadingLarge: false, loadingLarge: false,
error: false, error: false,
@ -64,12 +71,18 @@ export default handleActions({
}), }),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[CREATE_SHORT_URL_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand
const { shortCode, domain, visits } = state;
// TODO if (!shortUrlMatches(shortUrl, shortCode, domain)) {
[CREATE_SHORT_URL_VISIT]: (state) => state, return state;
}
return { ...state, visits: [ ...visits, visit ] };
},
}, initialState); }, initialState);
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => { export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START }); dispatch({ type: GET_SHORT_URL_VISITS_START });
const { getShortUrlVisits } = buildShlinkApiClient(getState); const { getShortUrlVisits } = buildShlinkApiClient(getState);
const itemsPerPage = 5000; const itemsPerPage = 5000;
@ -122,7 +135,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) =>
try { try {
const visits = await loadVisits(); const visits = await loadVisits();
dispatch({ visits, type: GET_SHORT_URL_VISITS }); dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS });
} catch (e) { } catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
} }

View file

@ -11,8 +11,8 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('MapModal', () => MapModal); bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn'); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
bottle.decorator('ShortUrlVisits', connect( bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail' ], [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ] [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit' ]
)); ));
// Services // Services

View file

@ -20,7 +20,9 @@ describe('<ShortUrlVisitsCount />', () => {
const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
expect(wrapper.html()).toEqual(`<span><strong>${visitsCount}</strong></span>`); expect(wrapper.html()).toEqual(
`<span><strong class="short-url-visits-count__amount">${visitsCount}</strong></span>`
);
expect(maxVisitsHelper).toHaveLength(0); expect(maxVisitsHelper).toHaveLength(0);
expect(maxVisitsTooltip).toHaveLength(0); expect(maxVisitsTooltip).toHaveLength(0);
}); });

View file

@ -26,7 +26,9 @@ describe('<VisitsHeader />', () => {
it('shows the amount of visits', () => { it('shows the amount of visits', () => {
const visitsBadge = wrapper.find('.badge'); const visitsBadge = wrapper.find('.badge');
expect(visitsBadge.html()).toContain(`Visits: <span><strong>${shortUrlVisits.visits.length}</strong></span>`); expect(visitsBadge.html()).toContain(
`Visits: <span><strong class="short-url-visits-count__amount">${shortUrlVisits.visits.length}</strong></span>`
);
}); });
it('shows when the URL was created', () => { it('shows when the URL was created', () => {

View file

@ -72,8 +72,13 @@ describe('shortUrlVisitsReducer', () => {
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1);
}); });
it('dispatches start and success when promise is resolved', async () => { it.each([
[ undefined, undefined ],
[{}, undefined ],
[{ domain: 'foobar.com' }, 'foobar.com' ],
])('dispatches start and success when promise is resolved', async (query, domain) => {
const visits = [{}, {}]; const visits = [{}, {}];
const shortCode = 'abc123';
const ShlinkApiClient = buildApiClientMock(Promise.resolve({ const ShlinkApiClient = buildApiClientMock(Promise.resolve({
data: visits, data: visits,
pagination: { pagination: {
@ -82,11 +87,11 @@ describe('shortUrlVisitsReducer', () => {
}, },
})); }));
await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); await getShortUrlVisits(() => ShlinkApiClient)(shortCode, query)(dispatchMock, getState);
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits }); expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits, shortCode, domain });
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1);
}); });