diff --git a/src/mercure/helpers/index.js b/src/mercure/helpers/index.js new file mode 100644 index 00000000..15e20a72 --- /dev/null +++ b/src/mercure/helpers/index.js @@ -0,0 +1,25 @@ +import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; + +export const bindToMercureTopic = (mercureInfo, topic, onMessage) => () => { + const { mercureHubUrl, token, loading, error } = mercureInfo; + + if (loading || error) { + return undefined; + } + + const hubUrl = new URL(mercureHubUrl); + + hubUrl.searchParams.append('topic', topic); + const es = new EventSource(hubUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + es.onmessage = ({ data }) => onMessage(JSON.parse(data)); + + // TODO Handle errors and get a new token + es.onerror = () => {}; + + return () => es.close(); +}; diff --git a/src/short-urls/ShortUrlsList.js b/src/short-urls/ShortUrlsList.js index 460106d2..956dcdf2 100644 --- a/src/short-urls/ShortUrlsList.js +++ b/src/short-urls/ShortUrlsList.js @@ -4,11 +4,11 @@ import { head, isEmpty, keys, values } from 'ramda'; import React, { useState, useEffect } from 'react'; import qs from 'qs'; import PropTypes from 'prop-types'; -import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; import { serverType } from '../servers/prop-types'; import SortingDropdown from '../utils/SortingDropdown'; import { determineOrderDir } from '../utils/utils'; import { MercureInfoType } from '../mercure/reducers/mercureInfo'; +import { bindToMercureTopic } from '../mercure/helpers'; import { shortUrlType } from './reducers/shortUrlsList'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import './ShortUrlsList.scss'; @@ -114,27 +114,7 @@ const ShortUrlsList = (ShortUrlsRow) => { return resetShortUrlParams; }, []); - useEffect(() => { - const { mercureHubUrl, token, loading, error } = mercureInfo; - - if (loading || error) { - return undefined; - } - - const hubUrl = new URL(mercureHubUrl); - - hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit'); - const es = new EventSource(hubUrl, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - // es.onmessage = pipe(JSON.parse, createNewVisit); - es.onmessage = ({ data }) => createNewVisit(JSON.parse(data)); - - return () => es.close(); - }, [ mercureInfo ]); + useEffect(bindToMercureTopic(mercureInfo, 'https://shlink.io/new-visit', createNewVisit), [ mercureInfo ]); return ( diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.js b/src/short-urls/helpers/ShortUrlVisitsCount.js index a268c9b5..b058868d 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.js +++ b/src/short-urls/helpers/ShortUrlVisitsCount.js @@ -3,22 +3,28 @@ import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; +import classNames from 'classnames'; import { serverType } from '../../servers/prop-types'; import { shortUrlType } from '../reducers/shortUrlsList'; -import './ShortUrlVisitsCount.scss'; import VisitStatsLink from './VisitStatsLink'; +import './ShortUrlVisitsCount.scss'; const propTypes = { visitsCount: PropTypes.number.isRequired, shortUrl: shortUrlType, selectedServer: serverType, + active: PropTypes.bool, }; -const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => { +const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }) => { const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits; const visitsLink = ( - {visitsCount} + + {visitsCount} + ); diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.scss b/src/short-urls/helpers/ShortUrlVisitsCount.scss index b27902fd..2910381a 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.scss +++ b/src/short-urls/helpers/ShortUrlVisitsCount.scss @@ -1,3 +1,12 @@ .short-urls-visits-count__max-visits-control { cursor: help; } + +.short-url-visits-count__amount { + transition: transform .3s ease; + display: inline-block; +} + +.short-url-visits-count__amount--big { + transform: scale(1.5); +} diff --git a/src/short-urls/helpers/ShortUrlsRow.js b/src/short-urls/helpers/ShortUrlsRow.js index a058a92a..b6788da2 100644 --- a/src/short-urls/helpers/ShortUrlsRow.js +++ b/src/short-urls/helpers/ShortUrlsRow.js @@ -1,5 +1,5 @@ import { isEmpty } from 'ramda'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import Moment from 'react-moment'; import PropTypes from 'prop-types'; import { ExternalLink } from 'react-external-link'; @@ -26,7 +26,10 @@ const ShortUrlsRow = ( useStateFlagTimeout ) => { const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => { - const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(false); + const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout(); + const [ active, setActive ] = useStateFlagTimeout(false, 500); + const isFirstRun = useRef(true); + const renderTags = (tags) => { if (isEmpty(tags)) { return No tags; @@ -44,6 +47,14 @@ const ShortUrlsRow = ( )); }; + useEffect(() => { + if (isFirstRun.current) { + isFirstRun.current = false; + } else { + setActive(true); + } + }, [ shortUrl.visitsCount ]); + return ( @@ -69,6 +80,7 @@ const ShortUrlsRow = ( visitsCount={shortUrl.visitsCount} shortUrl={shortUrl} selectedServer={selectedServer} + active={active} /> diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index 7a888cf3..914826ee 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -35,6 +35,7 @@ } } } + .short-urls-row__cell--break { word-break: break-all; } @@ -43,6 +44,10 @@ position: relative; } +.short-urls-row__cell--big { + transform: scale(1.5); +} + .short-urls-row__copy-btn { cursor: pointer; font-size: 1.2rem; diff --git a/src/short-urls/helpers/index.js b/src/short-urls/helpers/index.js new file mode 100644 index 00000000..32a12ad9 --- /dev/null +++ b/src/short-urls/helpers/index.js @@ -0,0 +1,9 @@ +import { isNil } from 'ramda'; + +export const shortUrlMatches = (shortUrl, shortCode, domain) => { + if (isNil(domain)) { + return shortUrl.shortCode === shortCode && !shortUrl.domain; + } + + return shortUrl.shortCode === shortCode && shortUrl.domain === domain; +}; diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index b8b11aa4..b7d346cc 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -1,7 +1,8 @@ import { handleActions } from 'redux-actions'; -import { assoc, assocPath, isNil, reject } from 'ramda'; +import { assoc, assocPath, reject } from 'ramda'; import PropTypes from 'prop-types'; import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits'; +import { shortUrlMatches } from '../helpers'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; @@ -29,14 +30,6 @@ const initialState = { error: false, }; -const shortUrlMatches = (shortUrl, shortCode, domain) => { - if (isNil(domain)) { - return shortUrl.shortCode === shortCode && !shortUrl.domain; - } - - return shortUrl.shortCode === shortCode && shortUrl.domain === domain; -}; - const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath( [ 'shortUrls', 'data' ], state.shortUrls.data.map( @@ -59,7 +52,7 @@ export default handleActions({ [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), [CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( [ 'shortUrls', 'data' ], - state.shortUrls.data.map( + state.shortUrls && state.shortUrls.data && state.shortUrls.data.map( (shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) ? assoc('visitsCount', visitsCount, shortUrl) : shortUrl diff --git a/src/utils/helpers/hooks.js b/src/utils/helpers/hooks.js index 81a60517..d0852714 100644 --- a/src/utils/helpers/hooks.js +++ b/src/utils/helpers/hooks.js @@ -1,12 +1,18 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; -const DEFAULT_TIMEOUT_DELAY = 2000; +const DEFAULT_DELAY = 2000; -export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => { +export const useStateFlagTimeout = (setTimeout, clearTimeout) => (initialValue = false, delay = DEFAULT_DELAY) => { const [ flag, setFlag ] = useState(initialValue); + const timeout = useRef(undefined); const callback = () => { setFlag(!initialValue); - setTimeout(() => setFlag(initialValue), delay); + + if (timeout.current) { + clearTimeout(timeout.current); + } + + timeout.current = setTimeout(() => setFlag(initialValue), delay); }; return [ flag, callback ]; diff --git a/src/utils/services/provideServices.js b/src/utils/services/provideServices.js index 1760c88c..d4068614 100644 --- a/src/utils/services/provideServices.js +++ b/src/utils/services/provideServices.js @@ -14,8 +14,9 @@ const provideServices = (bottle) => { bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios'); bottle.constant('setTimeout', global.setTimeout); + bottle.constant('clearTimeout', global.clearTimeout); bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout'); - bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout'); + bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout'); }; export default provideServices; diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 0d279ae2..e8e27693 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -10,6 +10,8 @@ import DateRangeRow from '../utils/DateRangeRow'; import Message from '../utils/Message'; import { formatDate } from '../utils/helpers/date'; import { useToggle } from '../utils/helpers/hooks'; +import { MercureInfoType } from '../mercure/reducers/mercureInfo'; +import { bindToMercureTopic } from '../mercure/helpers'; import SortableBarGraph from './SortableBarGraph'; import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import VisitsHeader from './VisitsHeader'; @@ -30,6 +32,8 @@ const propTypes = { shortUrlDetail: shortUrlDetailType, cancelGetShortUrlVisits: PropTypes.func, matchMedia: PropTypes.func, + createNewVisit: PropTypes.func, + mercureInfo: MercureInfoType, }; const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => { @@ -54,6 +58,8 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa getShortUrlDetail, cancelGetShortUrlVisits, matchMedia = window.matchMedia, + createNewVisit, + mercureInfo, }) => { const [ startDate, setStartDate ] = useState(undefined); const [ endDate, setEndDate ] = useState(undefined); @@ -108,6 +114,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa useEffect(() => { loadVisits(); }, [ startDate, endDate ]); + useEffect( + bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit), + [ mercureInfo ], + ); const renderVisitsContent = () => { if (loading) { diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index 9c19d592..f3be7e33 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,6 +1,7 @@ import { createAction, handleActions } from 'redux-actions'; import PropTypes from 'prop-types'; import { flatten, prop, range, splitEvery } from 'ramda'; +import { shortUrlMatches } from '../../short-urls/helpers'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -29,12 +30,16 @@ export const visitType = PropTypes.shape({ export const shortUrlVisitsType = PropTypes.shape({ visits: PropTypes.arrayOf(visitType), + shortCode: PropTypes.string, + domain: PropTypes.string, loading: PropTypes.bool, error: PropTypes.bool, }); const initialState = { visits: [], + shortCode: '', + domain: undefined, loading: false, loadingLarge: false, error: false, @@ -55,8 +60,10 @@ export default handleActions({ error: true, cancelLoad: false, }), - [GET_SHORT_URL_VISITS]: (state, { visits }) => ({ + [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({ visits, + shortCode, + domain, loading: false, loadingLarge: false, error: false, @@ -64,12 +71,18 @@ export default handleActions({ }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), + [CREATE_SHORT_URL_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand + const { shortCode, domain, visits } = state; - // TODO - [CREATE_SHORT_URL_VISIT]: (state) => state, + if (!shortUrlMatches(shortUrl, shortCode, domain)) { + return state; + } + + return { ...state, visits: [ ...visits, visit ] }; + }, }, initialState); -export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => { +export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => { dispatch({ type: GET_SHORT_URL_VISITS_START }); const { getShortUrlVisits } = buildShlinkApiClient(getState); const itemsPerPage = 5000; @@ -122,7 +135,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => try { const visits = await loadVisits(); - dispatch({ visits, type: GET_SHORT_URL_VISITS }); + dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS }); } catch (e) { dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); } diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js index babb432c..e18c981b 100644 --- a/src/visits/services/provideServices.js +++ b/src/visits/services/provideServices.js @@ -11,8 +11,8 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('MapModal', () => MapModal); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn'); bottle.decorator('ShortUrlVisits', connect( - [ 'shortUrlVisits', 'shortUrlDetail' ], - [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits' ] + [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ], + [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit' ] )); // Services diff --git a/test/short-urls/helpers/ShortUrlVisitsCount.test.js b/test/short-urls/helpers/ShortUrlVisitsCount.test.js index 0e9716f7..f12d3fe3 100644 --- a/test/short-urls/helpers/ShortUrlVisitsCount.test.js +++ b/test/short-urls/helpers/ShortUrlVisitsCount.test.js @@ -20,7 +20,9 @@ describe('', () => { const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); - expect(wrapper.html()).toEqual(`${visitsCount}`); + expect(wrapper.html()).toEqual( + `${visitsCount}` + ); expect(maxVisitsHelper).toHaveLength(0); expect(maxVisitsTooltip).toHaveLength(0); }); diff --git a/test/visits/VisitsHeader.test.js b/test/visits/VisitsHeader.test.js index ba258418..3d4b48dd 100644 --- a/test/visits/VisitsHeader.test.js +++ b/test/visits/VisitsHeader.test.js @@ -26,7 +26,9 @@ describe('', () => { it('shows the amount of visits', () => { const visitsBadge = wrapper.find('.badge'); - expect(visitsBadge.html()).toContain(`Visits: ${shortUrlVisits.visits.length}`); + expect(visitsBadge.html()).toContain( + `Visits: ${shortUrlVisits.visits.length}` + ); }); it('shows when the URL was created', () => { diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js index 9bc285ca..82fdeede 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -72,8 +72,13 @@ describe('shortUrlVisitsReducer', () => { expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); }); - it('dispatches start and success when promise is resolved', async () => { + it.each([ + [ undefined, undefined ], + [{}, undefined ], + [{ domain: 'foobar.com' }, 'foobar.com' ], + ])('dispatches start and success when promise is resolved', async (query, domain) => { const visits = [{}, {}]; + const shortCode = 'abc123'; const ShlinkApiClient = buildApiClientMock(Promise.resolve({ data: visits, pagination: { @@ -82,11 +87,11 @@ describe('shortUrlVisitsReducer', () => { }, })); - await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); + await getShortUrlVisits(() => ShlinkApiClient)(shortCode, query)(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits, shortCode, domain }); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); });