From 18e18f533babaca82fb8875e92a3ca0b5e59c727 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 17:49:55 +0200 Subject: [PATCH 01/14] Extracted visits charts elements into reusable component --- src/common/MenuLayout.js | 1 + src/tags/TagCard.js | 2 +- src/visits/ShortUrlVisits.js | 207 +---------------------- src/visits/VisitsStats.js | 224 +++++++++++++++++++++++++ src/visits/reducers/shortUrlVisits.js | 21 +-- src/visits/services/provideServices.js | 4 +- src/visits/types/index.js | 23 +++ test/tags/TagCard.test.js | 2 +- test/visits/ShortUrlVisits.test.js | 84 ++-------- test/visits/VisitsStats.test.js | 95 +++++++++++ 10 files changed, 368 insertions(+), 295 deletions(-) create mode 100644 src/visits/VisitsStats.js create mode 100644 src/visits/types/index.js create mode 100644 test/visits/VisitsStats.test.js diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index 15682458..5d42fda4 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -61,6 +61,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi + {/* */} List short URLs} diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js index 3f66bf5b..9ec68982 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -60,7 +60,7 @@ const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGen {prettify(tagStats.shortUrlsCount)} Visits diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 12f5deb3..35fb4728 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -1,24 +1,12 @@ -import { isEmpty, propEq, values } from 'ramda'; -import React, { useState, useEffect, useMemo } from 'react'; -import { Button, Card, Collapse } from 'reactstrap'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import qs from 'qs'; -import classNames from 'classnames'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons'; -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 { SettingsType } from '../settings/reducers/settings'; -import SortableBarGraph from './SortableBarGraph'; import { shortUrlVisitsType } from './reducers/shortUrlVisits'; import VisitsHeader from './VisitsHeader'; -import GraphCard from './GraphCard'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; -import VisitsTable from './VisitsTable'; const propTypes = { history: PropTypes.shape({ @@ -35,26 +23,13 @@ const propTypes = { getShortUrlDetail: PropTypes.func, shortUrlDetail: shortUrlDetailType, cancelGetShortUrlVisits: PropTypes.func, - matchMedia: PropTypes.func, createNewVisit: PropTypes.func, loadMercureInfo: PropTypes.func, mercureInfo: MercureInfoType, settings: SettingsType, }; -const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => { - if (!acc[highlightedVisit[prop]]) { - acc[highlightedVisit[prop]] = 0; - } - - acc[highlightedVisit[prop]] += 1; - - return acc; -}, {}); -const format = formatDate(); -let selectedBar; - -const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => { +const ShortUrlVisits = (VisitsStats) => { const ShortUrlVisitsComp = ({ history, match, @@ -64,65 +39,21 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa getShortUrlVisits, getShortUrlDetail, cancelGetShortUrlVisits, - matchMedia = window.matchMedia, createNewVisit, loadMercureInfo, mercureInfo, settings: { realTimeUpdates }, }) => { - const [ startDate, setStartDate ] = useState(undefined); - const [ endDate, setEndDate ] = useState(undefined); - const [ showTable, toggleTable ] = useToggle(); - const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle(); - const [ highlightedVisits, setHighlightedVisits ] = useState([]); - const [ isMobileDevice, setIsMobileDevice ] = useState(false); - const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); - const setSelectedVisits = (selectedVisits) => { - selectedBar = undefined; - setHighlightedVisits(selectedVisits); - }; - const highlightVisitsForProp = (prop) => (value) => { - const newSelectedBar = `${prop}_${value}`; - - if (selectedBar === newSelectedBar) { - setHighlightedVisits([]); - selectedBar = undefined; - } else { - setHighlightedVisits(normalizedVisits.filter(propEq(prop, value))); - selectedBar = newSelectedBar; - } - }; - const { params } = match; const { shortCode } = params; const { search } = location; const { domain } = qs.parse(search, { ignoreQueryPrefix: true }); - const { visits, loading, loadingLarge, error } = shortUrlVisits; - const showTableControls = !loading && visits.length > 0; - const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); - const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( - () => processStatsFromVisits(normalizedVisits), - [ normalizedVisits ] - ); - const mapLocations = values(citiesForMap); - - const loadVisits = () => - getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain }); + const loadVisits = (dates) => getShortUrlVisits(shortCode, { ...dates, domain }); useEffect(() => { getShortUrlDetail(shortCode, domain); - determineIsMobileDevice(); - window.addEventListener('resize', determineIsMobileDevice); - - return () => { - cancelGetShortUrlVisits(); - window.removeEventListener('resize', determineIsMobileDevice); - }; }, []); - useEffect(() => { - loadVisits(); - }, [ startDate, endDate ]); useEffect( bindToMercureTopic( mercureInfo, @@ -134,138 +65,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa [ mercureInfo ], ); - const renderVisitsContent = () => { - if (loading) { - const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; - - return {message}; - } - - if (error) { - return ( - - An error occurred while loading visits :( - - ); - } - - if (isEmpty(visits)) { - return There are no visits matching current filter :(; - } - - return ( -
-
- -
-
- -
-
- -
-
- -
-
- - mapLocations.length > 0 && - - } - sortingItems={{ - name: 'City name', - amount: 'Visits amount', - }} - onClick={highlightVisitsForProp('city')} - /> -
-
- ); - }; - return ( - + - -
-
-
- -
-
- {showTableControls && ( - - - - - - - - - )} -
-
-
- - {showTableControls && ( - - - - )} - -
- {renderVisitsContent()} -
-
+ ); }; diff --git a/src/visits/VisitsStats.js b/src/visits/VisitsStats.js new file mode 100644 index 00000000..50cd2d2c --- /dev/null +++ b/src/visits/VisitsStats.js @@ -0,0 +1,224 @@ +import { isEmpty, propEq, values } from 'ramda'; +import React, { useState, useEffect, useMemo } from 'react'; +import { Button, Card, Collapse } from 'reactstrap'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons'; +import DateRangeRow from '../utils/DateRangeRow'; +import Message from '../utils/Message'; +import { formatDate } from '../utils/helpers/date'; +import { useToggle } from '../utils/helpers/hooks'; +import SortableBarGraph from './SortableBarGraph'; +import GraphCard from './GraphCard'; +import VisitsTable from './VisitsTable'; +import { VisitsInfoType } from './types'; + +const propTypes = { + children: PropTypes.node, + getVisits: PropTypes.func, + visitsInfo: VisitsInfoType, // TODO VisitsInfo type + cancelGetVisits: PropTypes.func, + matchMedia: PropTypes.func, +}; + +const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => { + if (!acc[highlightedVisit[prop]]) { + acc[highlightedVisit[prop]] = 0; + } + + acc[highlightedVisit[prop]] += 1; + + return acc; +}, {}); +const format = formatDate(); +let selectedBar; + +const VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => { + const VisitsStatsComp = ({ children, visitsInfo, getVisits, cancelGetVisits, matchMedia = window.matchMedia }) => { + const [ startDate, setStartDate ] = useState(undefined); + const [ endDate, setEndDate ] = useState(undefined); + const [ showTable, toggleTable ] = useToggle(); + const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle(); + const [ highlightedVisits, setHighlightedVisits ] = useState([]); + const [ isMobileDevice, setIsMobileDevice ] = useState(false); + const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); + const setSelectedVisits = (selectedVisits) => { + selectedBar = undefined; + setHighlightedVisits(selectedVisits); + }; + const highlightVisitsForProp = (prop) => (value) => { + const newSelectedBar = `${prop}_${value}`; + + if (selectedBar === newSelectedBar) { + setHighlightedVisits([]); + selectedBar = undefined; + } else { + setHighlightedVisits(normalizedVisits.filter(propEq(prop, value))); + selectedBar = newSelectedBar; + } + }; + + const { visits, loading, loadingLarge, error } = visitsInfo; + const showTableControls = !loading && visits.length > 0; + const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); + const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( + () => processStatsFromVisits(normalizedVisits), + [ normalizedVisits ] + ); + const mapLocations = values(citiesForMap); + + useEffect(() => { + determineIsMobileDevice(); + window.addEventListener('resize', determineIsMobileDevice); + + return () => { + cancelGetVisits(); + window.removeEventListener('resize', determineIsMobileDevice); + }; + }, []); + useEffect(() => { + getVisits({ startDate: format(startDate), endDate: format(endDate) }); + }, [ startDate, endDate ]); + + const renderVisitsContent = () => { + if (loading) { + const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; + + return {message}; + } + + if (error) { + return ( + + An error occurred while loading visits :( + + ); + } + + if (isEmpty(visits)) { + return There are no visits matching current filter :(; + } + + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + mapLocations.length > 0 && + + } + sortingItems={{ + name: 'City name', + amount: 'Visits amount', + }} + onClick={highlightVisitsForProp('city')} + /> +
+
+ ); + }; + + return ( + + {children} + +
+
+
+ +
+
+ {showTableControls && ( + + + + + + + + + )} +
+
+
+ + {showTableControls && ( + + + + )} + +
+ {renderVisitsContent()} +
+
+ ); + }; + + VisitsStatsComp.propTypes = propTypes; + + return VisitsStatsComp; +}; + +export default VisitsStats; diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index f3be7e33..a52a4587 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -2,6 +2,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'; +import { VisitType } from '../types'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -12,24 +13,8 @@ export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_ export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT'; /* eslint-enable padding-line-between-statements */ -export const visitType = PropTypes.shape({ - referer: PropTypes.string, - date: PropTypes.string, - userAgent: PropTypes.string, - visitLocations: PropTypes.shape({ - countryCode: PropTypes.string, - countryName: PropTypes.string, - regionName: PropTypes.string, - cityName: PropTypes.string, - latitude: PropTypes.number, - longitude: PropTypes.number, - timezone: PropTypes.string, - isEmpty: PropTypes.bool, - }), -}); - -export const shortUrlVisitsType = PropTypes.shape({ - visits: PropTypes.arrayOf(visitType), +export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType + visits: PropTypes.arrayOf(VisitType), shortCode: PropTypes.string, domain: PropTypes.string, loading: PropTypes.bool, diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js index 404bc4a1..9522f6e7 100644 --- a/src/visits/services/provideServices.js +++ b/src/visits/services/provideServices.js @@ -3,13 +3,15 @@ import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../r import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import OpenMapModalBtn from '../helpers/OpenMapModalBtn'; import MapModal from '../helpers/MapModal'; +import VisitsStats from '../VisitsStats'; import * as visitsParser from './VisitsParser'; const provideServices = (bottle, connect) => { // Components bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal'); bottle.serviceFactory('MapModal', () => MapModal); - bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn'); + bottle.serviceFactory('VisitsStats', VisitsStats, 'VisitsParser', 'OpenMapModalBtn'); + bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsStats'); bottle.decorator('ShortUrlVisits', connect( [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ] diff --git a/src/visits/types/index.js b/src/visits/types/index.js new file mode 100644 index 00000000..941b4d61 --- /dev/null +++ b/src/visits/types/index.js @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types'; + +export const VisitType = PropTypes.shape({ + referer: PropTypes.string, + date: PropTypes.string, + userAgent: PropTypes.string, + visitLocations: PropTypes.shape({ + countryCode: PropTypes.string, + countryName: PropTypes.string, + regionName: PropTypes.string, + cityName: PropTypes.string, + latitude: PropTypes.number, + longitude: PropTypes.number, + timezone: PropTypes.string, + isEmpty: PropTypes.bool, + }), +}); + +export const VisitsInfoType = PropTypes.shape({ + visits: PropTypes.arrayOf(VisitType), + loading: PropTypes.bool, + error: PropTypes.bool, +}); diff --git a/test/tags/TagCard.test.js b/test/tags/TagCard.test.js index 49fee1c8..6d806752 100644 --- a/test/tags/TagCard.test.js +++ b/test/tags/TagCard.test.js @@ -51,7 +51,7 @@ describe('', () => { expect(links.at(1).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr'); expect(links.at(1).text()).toContain('48'); - expect(links.at(2).prop('to')).toEqual('/server/1/tags/ssr/visits'); + expect(links.at(2).prop('to')).toEqual('/server/1/tag/ssr/visits'); expect(links.at(2).text()).toContain('23,257'); }); }); diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 76cf0b28..72d2c06b 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -1,18 +1,11 @@ import React from 'react'; import { shallow } from 'enzyme'; import { identity } from 'ramda'; -import { Card } from 'reactstrap'; import createShortUrlVisits from '../../src/visits/ShortUrlVisits'; -import Message from '../../src/utils/Message'; -import GraphCard from '../../src/visits/GraphCard'; -import SortableBarGraph from '../../src/visits/SortableBarGraph'; -import DateRangeRow from '../../src/utils/DateRangeRow'; +import VisitsHeader from '../../src/visits/VisitsHeader'; describe('', () => { let wrapper; - const processStatsFromVisits = () => ( - { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } - ); const getShortUrlVisitsMock = jest.fn(); const match = { params: { shortCode: 'abc123' }, @@ -22,9 +15,10 @@ describe('', () => { goBack: jest.fn(), }; const realTimeUpdates = { enabled: true }; + const VisitsStats = jest.fn(); - const createComponent = (shortUrlVisits) => { - const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits, normalizeVisits: identity }, () => ''); + beforeEach(() => { + const ShortUrlVisits = createShortUrlVisits(VisitsStats); wrapper = shallow( ', () => { match={match} location={location} history={history} - shortUrlVisits={shortUrlVisits} + shortUrlVisits={{ loading: true, visits: [] }} shortUrlDetail={{}} cancelGetShortUrlVisits={identity} matchMedia={() => ({ matches: false })} settings={{ realTimeUpdates }} /> ); - - return wrapper; - }; - - afterEach(() => wrapper && wrapper.unmount()); - - it('renders a preloader when visits are loading', () => { - const wrapper = createComponent({ loading: true, visits: [] }); - const loadingMessage = wrapper.find(Message); - - expect(loadingMessage).toHaveLength(1); - expect(loadingMessage.html()).toContain('Loading...'); }); - it('renders a warning when loading large amounts of visits', () => { - const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [] }); - const loadingMessage = wrapper.find(Message); + afterEach(() => wrapper.unmount()); + afterEach(jest.resetAllMocks); - expect(loadingMessage).toHaveLength(1); - expect(loadingMessage.html()).toContain('This is going to take a while... :S'); - }); + it('renders visit stats and visits header', () => { + const visitStats = wrapper.find(VisitsStats); + const visitHeader = wrapper.find(VisitsHeader); - it('renders an error message when visits could not be loaded', () => { - const wrapper = createComponent({ loading: false, error: true, visits: [] }); - const errorMessage = wrapper.find(Card); - - expect(errorMessage).toHaveLength(1); - expect(errorMessage.html()).toContain('An error occurred while loading visits :('); - }); - - it('renders a message when visits are loaded but the list is empty', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [] }); - const message = wrapper.find(Message); - - expect(message).toHaveLength(1); - expect(message.html()).toContain('There are no visits matching current filter :('); - }); - - it('renders all graphics when visits are properly loaded', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); - const graphs = wrapper.find(GraphCard); - const sortableBarGraphs = wrapper.find(SortableBarGraph); - - expect(graphs.length + sortableBarGraphs.length).toEqual(5); - }); - - it('reloads visits when selected dates change', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); - const dateRange = wrapper.find(DateRangeRow); - - dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00'); - dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00'); - dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00'); - - expect(wrapper.find(DateRangeRow).prop('startDate')).toEqual('2016-01-01T00:00:00+01:00'); - expect(wrapper.find(DateRangeRow).prop('endDate')).toEqual('2016-01-03T00:00:00+01:00'); - }); - - it('holds the map button content generator on cities graph extraHeaderContent', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); - const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]'); - const extraHeaderContent = citiesGraph.prop('extraHeaderContent'); - - expect(extraHeaderContent).toHaveLength(1); - expect(typeof extraHeaderContent).toEqual('function'); + expect(visitStats).toHaveLength(1); + expect(visitHeader).toHaveLength(1); }); }); diff --git a/test/visits/VisitsStats.test.js b/test/visits/VisitsStats.test.js new file mode 100644 index 00000000..225317cd --- /dev/null +++ b/test/visits/VisitsStats.test.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { identity } from 'ramda'; +import { Card } from 'reactstrap'; +import createVisitStats from '../../src/visits/VisitsStats'; +import Message from '../../src/utils/Message'; +import GraphCard from '../../src/visits/GraphCard'; +import SortableBarGraph from '../../src/visits/SortableBarGraph'; +import DateRangeRow from '../../src/utils/DateRangeRow'; + +describe('', () => { + let wrapper; + const processStatsFromVisits = () => ( + { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } + ); + const getVisitsMock = jest.fn(); + + const createComponent = (visitsInfo) => { + const VisitStats = createVisitStats({ processStatsFromVisits, normalizeVisits: identity }, () => ''); + + wrapper = shallow( + ({ matches: false })} + /> + ); + + return wrapper; + }; + + afterEach(() => wrapper && wrapper.unmount()); + + it('renders a preloader when visits are loading', () => { + const wrapper = createComponent({ loading: true, visits: [] }); + const loadingMessage = wrapper.find(Message); + + expect(loadingMessage).toHaveLength(1); + expect(loadingMessage.html()).toContain('Loading...'); + }); + + it('renders a warning when loading large amounts of visits', () => { + const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [] }); + const loadingMessage = wrapper.find(Message); + + expect(loadingMessage).toHaveLength(1); + expect(loadingMessage.html()).toContain('This is going to take a while... :S'); + }); + + it('renders an error message when visits could not be loaded', () => { + const wrapper = createComponent({ loading: false, error: true, visits: [] }); + const errorMessage = wrapper.find(Card); + + expect(errorMessage).toHaveLength(1); + expect(errorMessage.html()).toContain('An error occurred while loading visits :('); + }); + + it('renders a message when visits are loaded but the list is empty', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [] }); + const message = wrapper.find(Message); + + expect(message).toHaveLength(1); + expect(message.html()).toContain('There are no visits matching current filter :('); + }); + + it('renders all graphics when visits are properly loaded', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const graphs = wrapper.find(GraphCard); + const sortableBarGraphs = wrapper.find(SortableBarGraph); + + expect(graphs.length + sortableBarGraphs.length).toEqual(5); + }); + + it('reloads visits when selected dates change', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const dateRange = wrapper.find(DateRangeRow); + + dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00'); + dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00'); + dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00'); + + expect(wrapper.find(DateRangeRow).prop('startDate')).toEqual('2016-01-01T00:00:00+01:00'); + expect(wrapper.find(DateRangeRow).prop('endDate')).toEqual('2016-01-03T00:00:00+01:00'); + }); + + it('holds the map button content generator on cities graph extraHeaderContent', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]'); + const extraHeaderContent = citiesGraph.prop('extraHeaderContent'); + + expect(extraHeaderContent).toHaveLength(1); + expect(typeof extraHeaderContent).toEqual('function'); + }); +}); From bfbb21e1cc215ffe8adb23c64dd36570cfd58367 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 19:02:58 +0200 Subject: [PATCH 02/14] Created page for tag visit stats --- src/common/MenuLayout.js | 13 ++- src/common/services/provideServices.js | 1 + src/reducers/index.js | 2 + src/short-urls/reducers/shortUrlsList.js | 4 +- src/utils/services/ShlinkApiClient.js | 4 + src/visits/TagVisits.js | 65 +++++++++++++++ src/visits/VisitsStats.js | 2 +- src/visits/reducers/common.js | 56 +++++++++++++ src/visits/reducers/shortUrlVisits.js | 72 +++-------------- src/visits/reducers/tagVisits.js | 81 +++++++++++++++++++ src/visits/reducers/visitCreation.js | 3 + src/visits/services/provideServices.js | 14 +++- src/visits/types/index.js | 1 + .../short-urls/reducers/shortUrlsList.test.js | 6 +- test/utils/services/ShlinkApiClient.test.js | 22 +++++ test/visits/reducers/shortUrlVisits.test.js | 14 +--- test/visits/reducers/visitCreation.test.js | 10 +++ 17 files changed, 291 insertions(+), 79 deletions(-) create mode 100644 src/visits/TagVisits.js create mode 100644 src/visits/reducers/common.js create mode 100644 src/visits/reducers/tagVisits.js create mode 100644 src/visits/reducers/visitCreation.js create mode 100644 test/visits/reducers/visitCreation.test.js diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index 5d42fda4..ce6e7eca 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -17,7 +17,16 @@ const propTypes = { selectedServer: serverType, }; -const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => { +const MenuLayout = ( + TagsList, + ShortUrls, + AsideMenu, + CreateShortUrl, + ShortUrlVisits, + TagVisits, + ShlinkVersions, + ServerError +) => { const MenuLayoutComp = ({ match, location, selectedServer }) => { const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); const { params: { serverId } } = match; @@ -61,7 +70,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi - {/* */} + List short URLs} diff --git a/src/common/services/provideServices.js b/src/common/services/provideServices.js index a8c842b6..1d4b288b 100644 --- a/src/common/services/provideServices.js +++ b/src/common/services/provideServices.js @@ -27,6 +27,7 @@ const provideServices = (bottle, connect, withRouter) => { 'AsideMenu', 'CreateShortUrl', 'ShortUrlVisits', + 'TagVisits', 'ShlinkVersions', 'ServerError' ); diff --git a/src/reducers/index.js b/src/reducers/index.js index 51b95d8c..1cdf34fd 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -9,6 +9,7 @@ import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; +import tagVisitsReducer from '../visits/reducers/tagVisits'; import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; @@ -27,6 +28,7 @@ export default combineReducers({ shortUrlMeta: shortUrlMetaReducer, shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, + tagVisits: tagVisitsReducer, shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index b7d346cc..9953e4e2 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -1,8 +1,8 @@ import { handleActions } from 'redux-actions'; 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 { CREATE_VISIT } from '../../visits/reducers/visitCreation'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; @@ -50,7 +50,7 @@ export default handleActions({ [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), - [CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( + [CREATE_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( [ 'shortUrls', 'data' ], state.shortUrls && state.shortUrls.data && state.shortUrls.data.map( (shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js index 76d1cd56..05a9c5bf 100644 --- a/src/utils/services/ShlinkApiClient.js +++ b/src/utils/services/ShlinkApiClient.js @@ -36,6 +36,10 @@ export default class ShlinkApiClient { this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query) .then((resp) => resp.data.visits); + getTagVisits = (tag, query) => + this._performRequest(`/tags/${tag}/visits`, 'GET', query) + .then((resp) => resp.data.visits); + getShortUrl = (shortCode, domain) => this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain }) .then((resp) => resp.data); diff --git a/src/visits/TagVisits.js b/src/visits/TagVisits.js new file mode 100644 index 00000000..5d9c32ef --- /dev/null +++ b/src/visits/TagVisits.js @@ -0,0 +1,65 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { MercureInfoType } from '../mercure/reducers/mercureInfo'; +import { SettingsType } from '../settings/reducers/settings'; +import { bindToMercureTopic } from '../mercure/helpers'; +import { TagVisitsType } from './reducers/tagVisits'; + +const propTypes = { + history: PropTypes.shape({ + goBack: PropTypes.func, + }), + match: PropTypes.shape({ + params: PropTypes.object, + }), + getTagVisits: PropTypes.func, + tagVisits: TagVisitsType, + cancelGetTagVisits: PropTypes.func, + createNewVisit: PropTypes.func, + loadMercureInfo: PropTypes.func, + mercureInfo: MercureInfoType, + settings: SettingsType, +}; + +const TagVisits = (VisitsStats) => { + const TagVisitsComp = ({ + history, + match, + getTagVisits, + tagVisits, + cancelGetTagVisits, + createNewVisit, + loadMercureInfo, + mercureInfo, + settings: { realTimeUpdates }, + }) => { + const { params } = match; + const { tag } = params; + const loadVisits = (dates) => getTagVisits(tag, dates); + + console.log(history); + + useEffect( + bindToMercureTopic( + mercureInfo, + realTimeUpdates, + 'https://shlink.io/new-visit', + createNewVisit, + loadMercureInfo + ), + [ mercureInfo ], + ); + + return ( + + {tag} - {tagVisits.visits.length} + + ); + }; + + TagVisitsComp.propTypes = propTypes; + + return TagVisitsComp; +}; + +export default TagVisits; diff --git a/src/visits/VisitsStats.js b/src/visits/VisitsStats.js index 50cd2d2c..6747926d 100644 --- a/src/visits/VisitsStats.js +++ b/src/visits/VisitsStats.js @@ -17,7 +17,7 @@ import { VisitsInfoType } from './types'; const propTypes = { children: PropTypes.node, getVisits: PropTypes.func, - visitsInfo: VisitsInfoType, // TODO VisitsInfo type + visitsInfo: VisitsInfoType, cancelGetVisits: PropTypes.func, matchMedia: PropTypes.func, }; diff --git a/src/visits/reducers/common.js b/src/visits/reducers/common.js new file mode 100644 index 00000000..022b4283 --- /dev/null +++ b/src/visits/reducers/common.js @@ -0,0 +1,56 @@ +import { flatten, prop, range, splitEvery } from 'ramda'; + +const ITEMS_PER_PAGE = 5000; +const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; + +export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, actionMap, dispatch, getState) => { + dispatch({ type: actionMap.start }); + + const loadVisits = async (page = 1) => { + const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE); + + // If pagination was not returned, then this is an old shlink version. Just return data + if (!pagination || isLastPage(pagination)) { + return data; + } + + // If there are more pages, make requests in blocks of 4 + const parallelRequestsCount = 4; + const parallelStartingPage = 2; + const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1); + const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange); + + if (pagination.pagesCount - 1 > parallelRequestsCount) { + dispatch({ type: actionMap.large }); + } + + return data.concat(await loadPagesBlocks(pagesBlocks)); + }; + + const loadPagesBlocks = async (pagesBlocks, index = 0) => { + const { shortUrlVisits: { cancelLoad } } = getState(); + + if (cancelLoad) { + return []; + } + + const data = await loadVisitsInParallel(pagesBlocks[index]); + + if (index < pagesBlocks.length - 1) { + return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); + } + + return data; + }; + + const loadVisitsInParallel = (pages) => + Promise.all(pages.map((page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); + + try { + const visits = await loadVisits(); + + dispatch({ ...extraFinishActionData, visits, type: actionMap.finish }); + } catch (e) { + dispatch({ type: actionMap.error }); + } +}; diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index a52a4587..58b5d49c 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,8 +1,9 @@ import { createAction, handleActions } from 'redux-actions'; import PropTypes from 'prop-types'; -import { flatten, prop, range, splitEvery } from 'ramda'; import { shortUrlMatches } from '../../short-urls/helpers'; import { VisitType } from '../types'; +import { getVisitsWithLoader } from './common'; +import { CREATE_VISIT } from './visitCreation'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -10,7 +11,6 @@ export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_V export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE'; export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL'; -export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT'; /* eslint-enable padding-line-between-statements */ export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType @@ -18,6 +18,7 @@ export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from V shortCode: PropTypes.string, domain: PropTypes.string, loading: PropTypes.bool, + loadingLarge: PropTypes.bool, error: PropTypes.bool, }); @@ -56,7 +57,7 @@ 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 + [CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand const { shortCode, domain, visits } = state; if (!shortUrlMatches(shortUrl, shortCode, domain)) { @@ -67,65 +68,18 @@ export default handleActions({ }, }, initialState); -export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => { - dispatch({ type: GET_SHORT_URL_VISITS_START }); +export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => (dispatch, getState) => { const { getShortUrlVisits } = buildShlinkApiClient(getState); - const itemsPerPage = 5000; - const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; - - const loadVisits = async (page = 1) => { - const { pagination, data } = await getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }); - - // If pagination was not returned, then this is an older shlink version. Just return data - if (!pagination || isLastPage(pagination)) { - return data; - } - - // If there are more pages, make requests in blocks of 4 - const parallelRequestsCount = 4; - const parallelStartingPage = 2; - const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1); - const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange); - - if (pagination.pagesCount - 1 > parallelRequestsCount) { - dispatch({ type: GET_SHORT_URL_VISITS_LARGE }); - } - - return data.concat(await loadPagesBlocks(pagesBlocks)); + const visitsLoader = (page, itemsPerPage) => getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }); + const extraFinishActionData = { shortCode, domain: query.domain }; + const actionMap = { + start: GET_SHORT_URL_VISITS_START, + large: GET_SHORT_URL_VISITS_LARGE, + finish: GET_SHORT_URL_VISITS, + error: GET_SHORT_URL_VISITS_ERROR, }; - const loadPagesBlocks = async (pagesBlocks, index = 0) => { - const { shortUrlVisits: { cancelLoad } } = getState(); - - if (cancelLoad) { - return []; - } - - const data = await loadVisitsInParallel(pagesBlocks[index]); - - if (index < pagesBlocks.length - 1) { - return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); - } - - return data; - }; - - const loadVisitsInParallel = (pages) => - Promise.all(pages.map( - (page) => - getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }) - .then(prop('data')) - )).then(flatten); - - try { - const visits = await loadVisits(); - - dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS }); - } catch (e) { - dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); - } + return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); }; export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL); - -export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_SHORT_URL_VISIT }); diff --git a/src/visits/reducers/tagVisits.js b/src/visits/reducers/tagVisits.js new file mode 100644 index 00000000..c59facf2 --- /dev/null +++ b/src/visits/reducers/tagVisits.js @@ -0,0 +1,81 @@ +import { createAction, handleActions } from 'redux-actions'; +import PropTypes from 'prop-types'; +import { VisitType } from '../types'; +import { getVisitsWithLoader } from './common'; +import { CREATE_VISIT } from './visitCreation'; + +/* eslint-disable padding-line-between-statements */ +export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START'; +export const GET_TAG_VISITS_ERROR = 'shlink/tagVisits/GET_TAG_VISITS_ERROR'; +export const GET_TAG_VISITS = 'shlink/tagVisits/GET_TAG_VISITS'; +export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE'; +export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL'; +/* eslint-enable padding-line-between-statements */ + +export const TagVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType + visits: PropTypes.arrayOf(VisitType), + tag: PropTypes.string, + loading: PropTypes.bool, + loadingLarge: PropTypes.bool, + error: PropTypes.bool, +}); + +const initialState = { + visits: [], + tag: '', + loading: false, + loadingLarge: false, + error: false, + cancelLoad: false, +}; + +export default handleActions({ + [GET_TAG_VISITS_START]: (state) => ({ + ...state, + loading: true, + loadingLarge: false, + cancelLoad: false, + }), + [GET_TAG_VISITS_ERROR]: (state) => ({ + ...state, + loading: false, + loadingLarge: false, + error: true, + cancelLoad: false, + }), + [GET_TAG_VISITS]: (state, { visits, tag }) => ({ + visits, + tag, + loading: false, + loadingLarge: false, + error: false, + cancelLoad: false, + }), + [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), + [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), + [CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand + const { tag, visits } = state; + + if (!shortUrl.tags.includes(tag)) { + return state; + } + + return { ...state, visits: [ ...visits, visit ] }; + }, +}, initialState); + +export const getTagVisits = (buildShlinkApiClient) => (tag, query = {}) => (dispatch, getState) => { + const { getTagVisits } = buildShlinkApiClient(getState); + const visitsLoader = (page, itemsPerPage) => getTagVisits(tag, { ...query, page, itemsPerPage }); + const extraFinishActionData = { tag }; + const actionMap = { + start: GET_TAG_VISITS_START, + large: GET_TAG_VISITS_LARGE, + finish: GET_TAG_VISITS, + error: GET_TAG_VISITS_ERROR, + }; + + return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); +}; + +export const cancelGetTagVisits = createAction(GET_TAG_VISITS_CANCEL); diff --git a/src/visits/reducers/visitCreation.js b/src/visits/reducers/visitCreation.js new file mode 100644 index 00000000..e37890a1 --- /dev/null +++ b/src/visits/reducers/visitCreation.js @@ -0,0 +1,3 @@ +export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT'; + +export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_VISIT }); diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js index 9522f6e7..1f766785 100644 --- a/src/visits/services/provideServices.js +++ b/src/visits/services/provideServices.js @@ -1,9 +1,12 @@ import ShortUrlVisits from '../ShortUrlVisits'; -import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../reducers/shortUrlVisits'; +import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import OpenMapModalBtn from '../helpers/OpenMapModalBtn'; import MapModal from '../helpers/MapModal'; import VisitsStats from '../VisitsStats'; +import { createNewVisit } from '../reducers/visitCreation'; +import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; +import TagVisits from '../TagVisits'; import * as visitsParser from './VisitsParser'; const provideServices = (bottle, connect) => { @@ -16,6 +19,11 @@ const provideServices = (bottle, connect) => { [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ] )); + bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats'); + bottle.decorator('TagVisits', connect( + [ 'tagVisits', 'mercureInfo', 'settings' ], + [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ] + )); // Services bottle.serviceFactory('VisitsParser', () => visitsParser); @@ -24,6 +32,10 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits); + + bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); + bottle.serviceFactory('createNewVisit', () => createNewVisit); }; diff --git a/src/visits/types/index.js b/src/visits/types/index.js index 941b4d61..b1464124 100644 --- a/src/visits/types/index.js +++ b/src/visits/types/index.js @@ -19,5 +19,6 @@ export const VisitType = PropTypes.shape({ export const VisitsInfoType = PropTypes.shape({ visits: PropTypes.arrayOf(VisitType), loading: PropTypes.bool, + loadingLarge: PropTypes.bool, error: PropTypes.bool, }); diff --git a/test/short-urls/reducers/shortUrlsList.test.js b/test/short-urls/reducers/shortUrlsList.test.js index c8e725c2..08bb0a82 100644 --- a/test/short-urls/reducers/shortUrlsList.test.js +++ b/test/short-urls/reducers/shortUrlsList.test.js @@ -7,7 +7,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_SHORT_URL_VISIT } from '../../../src/visits/reducers/shortUrlVisits'; +import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; describe('shortUrlsListReducer', () => { describe('reducer', () => { @@ -103,7 +103,7 @@ describe('shortUrlsListReducer', () => { }); }); - it('updates visits count on CREATE_SHORT_URL_VISIT', () => { + it('updates visits count on CREATE_VISIT', () => { const shortCode = 'abc123'; const shortUrl = { shortCode, @@ -119,7 +119,7 @@ describe('shortUrlsListReducer', () => { }, }; - expect(reducer(state, { type: CREATE_SHORT_URL_VISIT, shortUrl })).toEqual({ + expect(reducer(state, { type: CREATE_VISIT, shortUrl })).toEqual({ shortUrls: { data: [ { shortCode, domain: 'example.com', visitsCount: 5 }, diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js index 07f93a66..c1889f5d 100644 --- a/test/utils/services/ShlinkApiClient.test.js +++ b/test/utils/services/ShlinkApiClient.test.js @@ -71,6 +71,28 @@ describe('ShlinkApiClient', () => { }); }); + describe('getTagVisits', () => { + it('properly returns tag visits', async () => { + const expectedVisits = [ 'foo', 'bar' ]; + const axiosSpy = jest.fn(createAxiosMock({ + data: { + visits: { + data: expectedVisits, + }, + }, + })); + const { getTagVisits } = new ShlinkApiClient(axiosSpy); + + const actualVisits = await getTagVisits('foo', {}); + + expect({ data: expectedVisits }).toEqual(actualVisits); + expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: '/tags/foo/visits', + method: 'GET', + })); + }); + }); + describe('getShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => { const expectedShortUrl = { foo: 'bar' }; diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js index 5e2544f9..5bb27dfe 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -1,14 +1,13 @@ import reducer, { getShortUrlVisits, cancelGetShortUrlVisits, - createNewVisit, GET_SHORT_URL_VISITS_START, GET_SHORT_URL_VISITS_ERROR, GET_SHORT_URL_VISITS, GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_CANCEL, - CREATE_SHORT_URL_VISIT, } from '../../../src/visits/reducers/shortUrlVisits'; +import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; describe('shortUrlVisitsReducer', () => { describe('reducer', () => { @@ -54,7 +53,7 @@ describe('shortUrlVisitsReducer', () => { it.each([ [{ shortCode: 'abc123' }, [{}, {}, {}]], [{ shortCode: 'def456' }, [{}, {}]], - ])('appends a new visit on CREATE_SHORT_URL_VISIT', (state, expectedVisits) => { + ])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => { const shortUrl = { shortCode: 'abc123', }; @@ -63,7 +62,7 @@ describe('shortUrlVisitsReducer', () => { visits: [{}, {}], }; - const { visits } = reducer(prevState, { type: CREATE_SHORT_URL_VISIT, shortUrl, visit: {} }); + const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} }); expect(visits).toEqual(expectedVisits); }); @@ -138,11 +137,4 @@ describe('shortUrlVisitsReducer', () => { it('just returns the action with proper type', () => expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL })); }); - - describe('createNewVisit', () => { - it('just returns the action with proper type', () => - expect(createNewVisit({ shortUrl: {}, visit: {} })).toEqual( - { type: CREATE_SHORT_URL_VISIT, shortUrl: {}, visit: {} } - )); - }); }); diff --git a/test/visits/reducers/visitCreation.test.js b/test/visits/reducers/visitCreation.test.js new file mode 100644 index 00000000..e010255e --- /dev/null +++ b/test/visits/reducers/visitCreation.test.js @@ -0,0 +1,10 @@ +import { CREATE_VISIT, createNewVisit } from '../../../src/visits/reducers/visitCreation'; + +describe('visitCreationReducer', () => { + describe('createNewVisit', () => { + it('just returns the action with proper type', () => + expect(createNewVisit({ shortUrl: {}, visit: {} })).toEqual( + { type: CREATE_VISIT, shortUrl: {}, visit: {} } + )); + }); +}); From f856bc218a425e6d71961fa57d8b54efa064d712 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 19:12:18 +0200 Subject: [PATCH 03/14] Created tagVisits reducer test --- test/visits/reducers/tagVisits.test.js | 120 +++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 test/visits/reducers/tagVisits.test.js diff --git a/test/visits/reducers/tagVisits.test.js b/test/visits/reducers/tagVisits.test.js new file mode 100644 index 00000000..a9b45b00 --- /dev/null +++ b/test/visits/reducers/tagVisits.test.js @@ -0,0 +1,120 @@ +import reducer, { + getTagVisits, + cancelGetTagVisits, + GET_TAG_VISITS_START, + GET_TAG_VISITS_ERROR, + GET_TAG_VISITS, + GET_TAG_VISITS_LARGE, + GET_TAG_VISITS_CANCEL, +} from '../../../src/visits/reducers/tagVisits'; +import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; + +describe('tagVisitsReducer', () => { + describe('reducer', () => { + it('returns loading on GET_TAG_VISITS_START', () => { + const state = reducer({ loading: false }, { type: GET_TAG_VISITS_START }); + const { loading } = state; + + expect(loading).toEqual(true); + }); + + it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => { + const state = reducer({ loadingLarge: false }, { type: GET_TAG_VISITS_LARGE }); + const { loadingLarge } = state; + + expect(loadingLarge).toEqual(true); + }); + + it('returns cancelLoad on GET_TAG_VISITS_CANCEL', () => { + const state = reducer({ cancelLoad: false }, { type: GET_TAG_VISITS_CANCEL }); + const { cancelLoad } = state; + + expect(cancelLoad).toEqual(true); + }); + + it('stops loading and returns error on GET_TAG_VISITS_ERROR', () => { + const state = reducer({ loading: true, error: false }, { type: GET_TAG_VISITS_ERROR }); + const { loading, error } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(true); + }); + + it('return visits on GET_TAG_VISITS', () => { + const actionVisits = [{}, {}]; + const state = reducer({ loading: true, error: false }, { type: GET_TAG_VISITS, visits: actionVisits }); + const { loading, error, visits } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(false); + expect(visits).toEqual(actionVisits); + }); + + it.each([ + [{ tag: 'foo' }, [{}, {}, {}]], + [{ tag: 'bar' }, [{}, {}]], + ])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => { + const shortUrl = { + tags: [ 'foo', 'baz' ], + }; + const prevState = { + ...state, + visits: [{}, {}], + }; + + const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} }); + + expect(visits).toEqual(expectedVisits); + }); + }); + + describe('getTagVisits', () => { + const buildApiClientMock = (returned) => ({ + getTagVisits: jest.fn(typeof returned === 'function' ? returned : () => returned), + }); + const dispatchMock = jest.fn(); + const getState = () => ({ + tagVisits: { cancelVisits: false }, + }); + + beforeEach(jest.resetAllMocks); + + it('dispatches start and error when promise is rejected', async () => { + const ShlinkApiClient = buildApiClientMock(Promise.reject()); + + await getTagVisits(() => ShlinkApiClient)('foo')(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS_ERROR }); + expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ undefined ], + [{}], + ])('dispatches start and success when promise is resolved', async (query) => { + const visits = [{}, {}]; + const tag = 'foo'; + const ShlinkApiClient = buildApiClientMock(Promise.resolve({ + data: visits, + pagination: { + currentPage: 1, + pagesCount: 1, + }, + })); + + await getTagVisits(() => ShlinkApiClient)(tag, query)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag }); + expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); + }); + }); + + describe('cancelGetTagVisits', () => { + it('just returns the action with proper type', () => + expect(cancelGetTagVisits()).toEqual({ type: GET_TAG_VISITS_CANCEL })); + }); +}); From 7a94b1730daefeca97462dc089aa0c086ee207b6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 19:37:00 +0200 Subject: [PATCH 04/14] Created common component for visits header --- src/visits/ShortUrlVisits.js | 4 +- src/visits/ShortUrlVisitsHeader.js | 52 +++++++++++++++ src/visits/ShortUrlVisitsHeader.scss | 3 + src/visits/VisitsHeader.js | 84 ++++++++---------------- src/visits/VisitsHeader.scss | 3 - test/visits/ShortUrlVisits.test.js | 4 +- test/visits/ShortUrlVisitsHeader.test.js | 40 +++++++++++ test/visits/VisitsHeader.test.js | 37 ++++------- 8 files changed, 141 insertions(+), 86 deletions(-) create mode 100644 src/visits/ShortUrlVisitsHeader.js create mode 100644 src/visits/ShortUrlVisitsHeader.scss delete mode 100644 src/visits/VisitsHeader.scss create mode 100644 test/visits/ShortUrlVisitsHeader.test.js diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 35fb4728..c3eac779 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -5,7 +5,7 @@ import { MercureInfoType } from '../mercure/reducers/mercureInfo'; import { bindToMercureTopic } from '../mercure/helpers'; import { SettingsType } from '../settings/reducers/settings'; import { shortUrlVisitsType } from './reducers/shortUrlVisits'; -import VisitsHeader from './VisitsHeader'; +import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; const propTypes = { @@ -67,7 +67,7 @@ const ShortUrlVisits = (VisitsStats) => { return ( - + ); }; diff --git a/src/visits/ShortUrlVisitsHeader.js b/src/visits/ShortUrlVisitsHeader.js new file mode 100644 index 00000000..13b381a0 --- /dev/null +++ b/src/visits/ShortUrlVisitsHeader.js @@ -0,0 +1,52 @@ +import { UncontrolledTooltip } from 'reactstrap'; +import Moment from 'react-moment'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { ExternalLink } from 'react-external-link'; +import { shortUrlDetailType } from './reducers/shortUrlDetail'; +import { shortUrlVisitsType } from './reducers/shortUrlVisits'; +import VisitsHeader from './VisitsHeader'; +import './ShortUrlVisitsHeader.scss'; + +const propTypes = { + shortUrlDetail: shortUrlDetailType.isRequired, + shortUrlVisits: shortUrlVisitsType.isRequired, + goBack: PropTypes.func.isRequired, +}; + +export default function ShortUrlVisitsHeader({ shortUrlDetail, shortUrlVisits, goBack }) { + const { shortUrl, loading } = shortUrlDetail; + const { visits } = shortUrlVisits; + const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; + const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : ''; + + const renderDate = () => ( + + + {shortUrl.dateCreated} + + + {shortUrl.dateCreated} + + + ); + const visitsStatsTitle = ( + + Visits for + + ); + + return ( + +
+
Created: {renderDate()}
+
+ Long URL:{' '} + {loading && Loading...} + {!loading && } +
+
+ ); +} + +ShortUrlVisitsHeader.propTypes = propTypes; diff --git a/src/visits/ShortUrlVisitsHeader.scss b/src/visits/ShortUrlVisitsHeader.scss new file mode 100644 index 00000000..cb223b60 --- /dev/null +++ b/src/visits/ShortUrlVisitsHeader.scss @@ -0,0 +1,3 @@ +.short-url-visits-header__created-at { + cursor: default; +} diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index 84752c1c..9c6d1c78 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -1,67 +1,41 @@ -import { Button, Card, UncontrolledTooltip } from 'reactstrap'; -import Moment from 'react-moment'; +import { Button, Card } from 'reactstrap'; import React from 'react'; import PropTypes from 'prop-types'; -import { ExternalLink } from 'react-external-link'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount'; -import { shortUrlDetailType } from './reducers/shortUrlDetail'; -import { shortUrlVisitsType } from './reducers/shortUrlVisits'; -import './VisitsHeader.scss'; +import { shortUrlType } from '../short-urls/reducers/shortUrlsList'; +import { VisitType } from './types'; const propTypes = { - shortUrlDetail: shortUrlDetailType.isRequired, - shortUrlVisits: shortUrlVisitsType.isRequired, + visits: PropTypes.arrayOf(VisitType).isRequired, goBack: PropTypes.func.isRequired, + title: PropTypes.node.isRequired, + children: PropTypes.node, + shortUrl: shortUrlType, }; -export default function VisitsHeader({ shortUrlDetail, shortUrlVisits, goBack }) { - const { shortUrl, loading } = shortUrlDetail; - const { visits } = shortUrlVisits; - const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; - const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : ''; - - const renderDate = () => ( - - {shortUrl.dateCreated} - - {shortUrl.dateCreated} - - - ); - const visitsStatsTitle = ( - - Visit stats for - - ); - - return ( -
- -

- - - {visitsStatsTitle} - - - Visits:{' '} - - -

-

{visitsStatsTitle}

-
-
Created: {renderDate()}
-
- Long URL:{' '} - {loading && Loading...} - {!loading && } -
-
-
- ); -} +const VisitsHeader = ({ visits, goBack, shortUrl, children, title }) => ( +
+ +

+ + + {title} + + + Visits:{' '} + + +

+

{title}

+ {children} +
+
+); VisitsHeader.propTypes = propTypes; + +export default VisitsHeader; diff --git a/src/visits/VisitsHeader.scss b/src/visits/VisitsHeader.scss deleted file mode 100644 index 51dcc29f..00000000 --- a/src/visits/VisitsHeader.scss +++ /dev/null @@ -1,3 +0,0 @@ -.visits-header__created-at { - cursor: default; -} diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 72d2c06b..615db013 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { identity } from 'ramda'; import createShortUrlVisits from '../../src/visits/ShortUrlVisits'; -import VisitsHeader from '../../src/visits/VisitsHeader'; +import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; describe('', () => { let wrapper; @@ -41,7 +41,7 @@ describe('', () => { it('renders visit stats and visits header', () => { const visitStats = wrapper.find(VisitsStats); - const visitHeader = wrapper.find(VisitsHeader); + const visitHeader = wrapper.find(ShortUrlVisitsHeader); expect(visitStats).toHaveLength(1); expect(visitHeader).toHaveLength(1); diff --git a/test/visits/ShortUrlVisitsHeader.test.js b/test/visits/ShortUrlVisitsHeader.test.js new file mode 100644 index 00000000..763efc57 --- /dev/null +++ b/test/visits/ShortUrlVisitsHeader.test.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Moment from 'react-moment'; +import { ExternalLink } from 'react-external-link'; +import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; + +describe('', () => { + let wrapper; + const shortUrlDetail = { + shortUrl: { + shortUrl: 'https://doma.in/abc123', + longUrl: 'https://foo.bar/bar/foo', + dateCreated: '2018-01-01T10:00:00+01:00', + }, + loading: false, + }; + const shortUrlVisits = { + visits: [{}, {}, {}], + }; + const goBack = jest.fn(); + + beforeEach(() => { + wrapper = shallow( + + ); + }); + afterEach(() => wrapper.unmount()); + + it('shows when the URL was created', () => { + const moment = wrapper.find(Moment).first(); + + expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl.dateCreated); + }); + + it('shows the long URL', () => { + const longUrlLink = wrapper.find(ExternalLink).last(); + + expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl.longUrl); + }); +}); diff --git a/test/visits/VisitsHeader.test.js b/test/visits/VisitsHeader.test.js index 8c151c4b..ade980e8 100644 --- a/test/visits/VisitsHeader.test.js +++ b/test/visits/VisitsHeader.test.js @@ -1,46 +1,35 @@ import React from 'react'; import { shallow } from 'enzyme'; -import Moment from 'react-moment'; -import { ExternalLink } from 'react-external-link'; import VisitsHeader from '../../src/visits/VisitsHeader'; describe('', () => { let wrapper; - const shortUrlDetail = { - shortUrl: { - shortUrl: 'https://doma.in/abc123', - longUrl: 'https://foo.bar/bar/foo', - dateCreated: '2018-01-01T10:00:00+01:00', - }, - loading: false, - }; - const shortUrlVisits = { - visits: [{}, {}, {}], - }; + const visits = [{}, {}, {}]; + const title = 'My header title'; const goBack = jest.fn(); beforeEach(() => { - wrapper = shallow(); + wrapper = shallow( + + ); }); + afterEach(() => wrapper.unmount()); + afterEach(jest.resetAllMocks); it('shows the amount of visits', () => { const visitsBadge = wrapper.find('.badge'); expect(visitsBadge.html()).toContain( - `Visits: ${shortUrlVisits.visits.length}` + `Visits: ${visits.length}` ); }); - it('shows when the URL was created', () => { - const moment = wrapper.find(Moment).first(); + it('shows the title in two places', () => { + const titles = wrapper.find('.text-center'); - expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl.dateCreated); - }); - - it('shows the long URL', () => { - const longUrlLink = wrapper.find(ExternalLink).last(); - - expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl.longUrl); + expect(titles).toHaveLength(2); + expect(titles.at(0).html()).toContain(title); + expect(titles.at(1).html()).toContain(title); }); }); From fb0f14fc1609883d735ccab20539af3f4c5d1fcb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 19:49:58 +0200 Subject: [PATCH 05/14] Created header for visits by tag section --- src/visits/ShortUrlVisitsHeader.js | 6 ++++-- src/visits/TagVisits.js | 5 ++--- src/visits/TagVisitsHeader.js | 26 ++++++++++++++++++++++++++ src/visits/VisitsHeader.js | 11 +++++++---- 4 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 src/visits/TagVisitsHeader.js diff --git a/src/visits/ShortUrlVisitsHeader.js b/src/visits/ShortUrlVisitsHeader.js index 13b381a0..a3b100a3 100644 --- a/src/visits/ShortUrlVisitsHeader.js +++ b/src/visits/ShortUrlVisitsHeader.js @@ -14,7 +14,7 @@ const propTypes = { goBack: PropTypes.func.isRequired, }; -export default function ShortUrlVisitsHeader({ shortUrlDetail, shortUrlVisits, goBack }) { +const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }) => { const { shortUrl, loading } = shortUrlDetail; const { visits } = shortUrlVisits; const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; @@ -47,6 +47,8 @@ export default function ShortUrlVisitsHeader({ shortUrlDetail, shortUrlVisits, g ); -} +}; ShortUrlVisitsHeader.propTypes = propTypes; + +export default ShortUrlVisitsHeader; diff --git a/src/visits/TagVisits.js b/src/visits/TagVisits.js index 5d9c32ef..70becba1 100644 --- a/src/visits/TagVisits.js +++ b/src/visits/TagVisits.js @@ -4,6 +4,7 @@ import { MercureInfoType } from '../mercure/reducers/mercureInfo'; import { SettingsType } from '../settings/reducers/settings'; import { bindToMercureTopic } from '../mercure/helpers'; import { TagVisitsType } from './reducers/tagVisits'; +import TagVisitsHeader from './TagVisitsHeader'; const propTypes = { history: PropTypes.shape({ @@ -37,8 +38,6 @@ const TagVisits = (VisitsStats) => { const { tag } = params; const loadVisits = (dates) => getTagVisits(tag, dates); - console.log(history); - useEffect( bindToMercureTopic( mercureInfo, @@ -52,7 +51,7 @@ const TagVisits = (VisitsStats) => { return ( - {tag} - {tagVisits.visits.length} + ); }; diff --git a/src/visits/TagVisitsHeader.js b/src/visits/TagVisitsHeader.js new file mode 100644 index 00000000..e37801ff --- /dev/null +++ b/src/visits/TagVisitsHeader.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import VisitsHeader from './VisitsHeader'; +import { TagVisitsType } from './reducers/tagVisits'; +import './ShortUrlVisitsHeader.scss'; + +const propTypes = { + tagVisits: TagVisitsType.isRequired, + goBack: PropTypes.func.isRequired, +}; + +const TagVisitsHeader = ({ tagVisits, goBack }) => { + const { visits, tag } = tagVisits; + + const visitsStatsTitle = ( + + Visits for {tag} + + ); + + return ; +}; + +TagVisitsHeader.propTypes = propTypes; + +export default TagVisitsHeader; diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index 9c6d1c78..89225027 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -18,20 +18,23 @@ const propTypes = { const VisitsHeader = ({ visits, goBack, shortUrl, children, title }) => (
-

+

- {title} + {title} Visits:{' '}

-

{title}

- {children} +

+ {title} +

+ + {children &&
{children}
}
); From 3a53298417f4542899e650853418ceacff2486d3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 20:17:17 +0200 Subject: [PATCH 06/14] Improved visits pages titles --- src/tags/helpers/Tag.js | 8 ++++---- src/tags/helpers/Tag.scss | 1 - src/visits/TagVisits.js | 4 ++-- src/visits/TagVisitsHeader.js | 12 ++++++++---- src/visits/VisitsHeader.js | 2 +- src/visits/services/provideServices.js | 2 +- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/tags/helpers/Tag.js b/src/tags/helpers/Tag.js index 29515af5..e2a29e9a 100644 --- a/src/tags/helpers/Tag.js +++ b/src/tags/helpers/Tag.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import './Tag.scss'; import { colorGeneratorType } from '../../utils/services/ColorGenerator'; +import './Tag.scss'; const propTypes = { text: PropTypes.string, @@ -17,12 +17,12 @@ const Tag = ({ children, clearable, colorGenerator, - onClick = () => {}, - onClose = () => {}, + onClick, + onClose, }) => ( {children || text} diff --git a/src/tags/helpers/Tag.scss b/src/tags/helpers/Tag.scss index 757abd36..a3a5ecec 100644 --- a/src/tags/helpers/Tag.scss +++ b/src/tags/helpers/Tag.scss @@ -1,6 +1,5 @@ .tag { color: #fff; - cursor: pointer; } .tag:not(:last-child) { diff --git a/src/visits/TagVisits.js b/src/visits/TagVisits.js index 70becba1..ae17aacd 100644 --- a/src/visits/TagVisits.js +++ b/src/visits/TagVisits.js @@ -22,7 +22,7 @@ const propTypes = { settings: SettingsType, }; -const TagVisits = (VisitsStats) => { +const TagVisits = (VisitsStats, colorGenerator) => { const TagVisitsComp = ({ history, match, @@ -51,7 +51,7 @@ const TagVisits = (VisitsStats) => { return ( - + ); }; diff --git a/src/visits/TagVisitsHeader.js b/src/visits/TagVisitsHeader.js index e37801ff..48c8bf3c 100644 --- a/src/visits/TagVisitsHeader.js +++ b/src/visits/TagVisitsHeader.js @@ -1,5 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Tag from '../tags/helpers/Tag'; +import { colorGeneratorType } from '../utils/services/ColorGenerator'; import VisitsHeader from './VisitsHeader'; import { TagVisitsType } from './reducers/tagVisits'; import './ShortUrlVisitsHeader.scss'; @@ -7,15 +9,17 @@ import './ShortUrlVisitsHeader.scss'; const propTypes = { tagVisits: TagVisitsType.isRequired, goBack: PropTypes.func.isRequired, + colorGenerator: colorGeneratorType, }; -const TagVisitsHeader = ({ tagVisits, goBack }) => { +const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }) => { const { visits, tag } = tagVisits; const visitsStatsTitle = ( - - Visits for {tag} - + + Visits for + + ); return ; diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index 89225027..fa59beee 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -30,7 +30,7 @@ const VisitsHeader = ({ visits, goBack, shortUrl, children, title }) => ( -

+

{title}

diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js index 1f766785..f0701a05 100644 --- a/src/visits/services/provideServices.js +++ b/src/visits/services/provideServices.js @@ -19,7 +19,7 @@ const provideServices = (bottle, connect) => { [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ] )); - bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats'); + bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats', 'ColorGenerator'); bottle.decorator('TagVisits', connect( [ 'tagVisits', 'mercureInfo', 'settings' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ] From 52631e629edb5607e6167e36a80fffd14238ba4c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 20:23:45 +0200 Subject: [PATCH 07/14] Created TagVisitsHeader test --- test/visits/TagVisitsHeader.test.js | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 test/visits/TagVisitsHeader.test.js diff --git a/test/visits/TagVisitsHeader.test.js b/test/visits/TagVisitsHeader.test.js new file mode 100644 index 00000000..d35d3b50 --- /dev/null +++ b/test/visits/TagVisitsHeader.test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Tag from '../../src/tags/helpers/Tag'; +import TagVisitsHeader from '../../src/visits/TagVisitsHeader'; + +describe('', () => { + let wrapper; + const tagVisits = { + tag: 'foo', + visits: [{}, {}, {}], + }; + const goBack = jest.fn(); + + beforeEach(() => { + wrapper = shallow( + + ); + }); + afterEach(() => wrapper.unmount()); + + it('shows expected visits', () => { + expect(wrapper.prop('visits')).toEqual(tagVisits.visits); + }); + + it('shows title for tag', () => { + const title = shallow(wrapper.prop('title')); + const tag = title.find(Tag).first(); + + expect(tag.prop('text')).toEqual(tagVisits.tag); + + title.unmount(); + }); +}); From 4e483dc5d4a93c157861b5903601ec02cd1e1a79 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 20:30:19 +0200 Subject: [PATCH 08/14] Created TagVisits test --- test/visits/TagVisits.test.js | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/visits/TagVisits.test.js diff --git a/test/visits/TagVisits.test.js b/test/visits/TagVisits.test.js new file mode 100644 index 00000000..8a871dae --- /dev/null +++ b/test/visits/TagVisits.test.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { identity } from 'ramda'; +import createTagVisits from '../../src/visits/TagVisits'; +import TagVisitsHeader from '../../src/visits/TagVisitsHeader'; + +describe('', () => { + let wrapper; + const getTagVisitsMock = jest.fn(); + const match = { + params: { tag: 'foo' }, + }; + const history = { + goBack: jest.fn(), + }; + const realTimeUpdates = { enabled: true }; + const VisitsStats = jest.fn(); + + beforeEach(() => { + const TagVisits = createTagVisits(VisitsStats, {}); + + wrapper = shallow( + + ); + }); + + afterEach(() => wrapper.unmount()); + afterEach(jest.resetAllMocks); + + it('renders visit stats and visits header', () => { + const visitStats = wrapper.find(VisitsStats); + const visitHeader = wrapper.find(TagVisitsHeader); + + expect(visitStats).toHaveLength(1); + expect(visitHeader).toHaveLength(1); + }); +}); From 02a4380f7c16b95d5ac11f8e324f94fc86d97179 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 May 2020 20:36:03 +0200 Subject: [PATCH 09/14] Updated changelog --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df595121..40ae208b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI. * If it fails, it will assume it is either not configured or not supported by the Shlink version. -* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app. - * [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag. This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same. +* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag. + + This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag. + +* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app. + #### Changed * [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu. From 9bdbe9071648f7fcca3eedda5ba5d9542018079f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 11 May 2020 18:55:35 +0200 Subject: [PATCH 10/14] Ensured state is properly reset when starting, finisihing or failing to load visits --- src/visits/reducers/shortUrlDetail.js | 6 +++--- src/visits/reducers/shortUrlVisits.js | 20 +++----------------- src/visits/reducers/tagVisits.js | 24 +++--------------------- 3 files changed, 9 insertions(+), 41 deletions(-) diff --git a/src/visits/reducers/shortUrlDetail.js b/src/visits/reducers/shortUrlDetail.js index 477464f5..7612b7ab 100644 --- a/src/visits/reducers/shortUrlDetail.js +++ b/src/visits/reducers/shortUrlDetail.js @@ -21,9 +21,9 @@ const initialState = { }; export default handleActions({ - [GET_SHORT_URL_DETAIL_START]: (state) => ({ ...state, loading: true }), - [GET_SHORT_URL_DETAIL_ERROR]: (state) => ({ ...state, loading: false, error: true }), - [GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }), + [GET_SHORT_URL_DETAIL_START]: () => ({ ...initialState, loading: true }), + [GET_SHORT_URL_DETAIL_ERROR]: () => ({ ...initialState, loading: false, error: true }), + [GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ ...initialState, shortUrl }), }, initialState); export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => { diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index 58b5d49c..7b785c79 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -33,27 +33,13 @@ const initialState = { }; export default handleActions({ - [GET_SHORT_URL_VISITS_START]: (state) => ({ - ...state, - loading: true, - loadingLarge: false, - cancelLoad: false, - }), - [GET_SHORT_URL_VISITS_ERROR]: (state) => ({ - ...state, - loading: false, - loadingLarge: false, - error: true, - cancelLoad: false, - }), + [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), + [GET_SHORT_URL_VISITS_ERROR]: () => ({ ...initialState, error: true }), [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({ + ...initialState, visits, shortCode, domain, - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), diff --git a/src/visits/reducers/tagVisits.js b/src/visits/reducers/tagVisits.js index c59facf2..453b6db5 100644 --- a/src/visits/reducers/tagVisits.js +++ b/src/visits/reducers/tagVisits.js @@ -30,27 +30,9 @@ const initialState = { }; export default handleActions({ - [GET_TAG_VISITS_START]: (state) => ({ - ...state, - loading: true, - loadingLarge: false, - cancelLoad: false, - }), - [GET_TAG_VISITS_ERROR]: (state) => ({ - ...state, - loading: false, - loadingLarge: false, - error: true, - cancelLoad: false, - }), - [GET_TAG_VISITS]: (state, { visits, tag }) => ({ - visits, - tag, - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, - }), + [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), + [GET_TAG_VISITS_ERROR]: () => ({ ...initialState, error: true }), + [GET_TAG_VISITS]: (state, { visits, tag }) => ({ ...initialState, visits, tag }), [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand From 07d3567244da70b0f5793d1fc323fe5ea4733ee4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 11 May 2020 19:32:42 +0200 Subject: [PATCH 11/14] Added progress bar to visits page when loading a lot of visits --- src/index.scss | 4 ++++ src/visits/VisitsStats.js | 17 ++++++++++++----- src/visits/reducers/common.js | 14 +++++++++----- src/visits/reducers/shortUrlVisits.js | 5 +++++ src/visits/reducers/tagVisits.js | 5 +++++ src/visits/types/index.js | 1 + test/visits/VisitsStats.test.js | 11 ++++++++--- test/visits/reducers/shortUrlVisits.test.js | 9 ++++++++- test/visits/reducers/tagVisits.test.js | 7 +++++++ 9 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/index.scss b/src/index.scss index 77df9f09..be46288b 100644 --- a/src/index.scss +++ b/src/index.scss @@ -61,3 +61,7 @@ body, background-color: darken($mainColor, 12%); } } + +.progress-bar { + background-color: $mainColor; +} diff --git a/src/visits/VisitsStats.js b/src/visits/VisitsStats.js index 6747926d..6b584b75 100644 --- a/src/visits/VisitsStats.js +++ b/src/visits/VisitsStats.js @@ -1,6 +1,6 @@ import { isEmpty, propEq, values } from 'ramda'; import React, { useState, useEffect, useMemo } from 'react'; -import { Button, Card, Collapse } from 'reactstrap'; +import { Button, Card, Collapse, Progress } from 'reactstrap'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -59,7 +59,7 @@ const VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBt } }; - const { visits, loading, loadingLarge, error } = visitsInfo; + const { visits, loading, loadingLarge, error, progress } = visitsInfo; const showTableControls = !loading && visits.length > 0; const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( @@ -82,10 +82,17 @@ const VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBt }, [ startDate, endDate ]); const renderVisitsContent = () => { - if (loading) { - const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; + if (loadingLarge) { + return ( + + This is going to take a while... :S + + + ); + } - return {message}; + if (loading) { + return ; } if (error) { diff --git a/src/visits/reducers/common.js b/src/visits/reducers/common.js index 022b4283..3b150dcc 100644 --- a/src/visits/reducers/common.js +++ b/src/visits/reducers/common.js @@ -1,7 +1,11 @@ import { flatten, prop, range, splitEvery } from 'ramda'; const ITEMS_PER_PAGE = 5000; +const PARALLEL_REQUESTS_COUNT = 4; +const PARALLEL_STARTING_PAGE = 2; + const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; +const calcProgress = (total, current) => current * 100 / total; export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, actionMap, dispatch, getState) => { dispatch({ type: actionMap.start }); @@ -15,12 +19,10 @@ export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, a } // If there are more pages, make requests in blocks of 4 - const parallelRequestsCount = 4; - const parallelStartingPage = 2; - const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1); - const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange); + const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1); + const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange); - if (pagination.pagesCount - 1 > parallelRequestsCount) { + if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { dispatch({ type: actionMap.large }); } @@ -36,6 +38,8 @@ export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, a const data = await loadVisitsInParallel(pagesBlocks[index]); + dispatch({ type: actionMap.progress, progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE) }); + if (index < pagesBlocks.length - 1) { return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); } diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index 7b785c79..cbbdd081 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -11,6 +11,7 @@ export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_V export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE'; export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL'; +export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED'; /* eslint-enable padding-line-between-statements */ export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType @@ -20,6 +21,7 @@ export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from V loading: PropTypes.bool, loadingLarge: PropTypes.bool, error: PropTypes.bool, + progress: PropTypes.number, }); const initialState = { @@ -30,6 +32,7 @@ const initialState = { loadingLarge: false, error: false, cancelLoad: false, + progress: 0, }; export default handleActions({ @@ -43,6 +46,7 @@ export default handleActions({ }), [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 const { shortCode, domain, visits } = state; @@ -63,6 +67,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = { large: GET_SHORT_URL_VISITS_LARGE, finish: GET_SHORT_URL_VISITS, error: GET_SHORT_URL_VISITS_ERROR, + progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, }; return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); diff --git a/src/visits/reducers/tagVisits.js b/src/visits/reducers/tagVisits.js index 453b6db5..d149322b 100644 --- a/src/visits/reducers/tagVisits.js +++ b/src/visits/reducers/tagVisits.js @@ -10,6 +10,7 @@ export const GET_TAG_VISITS_ERROR = 'shlink/tagVisits/GET_TAG_VISITS_ERROR'; export const GET_TAG_VISITS = 'shlink/tagVisits/GET_TAG_VISITS'; export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE'; export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL'; +export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED'; /* eslint-enable padding-line-between-statements */ export const TagVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType @@ -18,6 +19,7 @@ export const TagVisitsType = PropTypes.shape({ // TODO Should extend from VisitI loading: PropTypes.bool, loadingLarge: PropTypes.bool, error: PropTypes.bool, + progress: PropTypes.number, }); const initialState = { @@ -27,6 +29,7 @@ const initialState = { loadingLarge: false, error: false, cancelLoad: false, + progress: 0, }; export default handleActions({ @@ -35,6 +38,7 @@ export default handleActions({ [GET_TAG_VISITS]: (state, { 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 const { tag, visits } = state; @@ -55,6 +59,7 @@ export const getTagVisits = (buildShlinkApiClient) => (tag, query = {}) => (disp large: GET_TAG_VISITS_LARGE, finish: GET_TAG_VISITS, error: GET_TAG_VISITS_ERROR, + progress: GET_TAG_VISITS_PROGRESS_CHANGED, }; return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); diff --git a/src/visits/types/index.js b/src/visits/types/index.js index b1464124..bd6d1383 100644 --- a/src/visits/types/index.js +++ b/src/visits/types/index.js @@ -21,4 +21,5 @@ export const VisitsInfoType = PropTypes.shape({ loading: PropTypes.bool, loadingLarge: PropTypes.bool, error: PropTypes.bool, + progress: PropTypes.number, }); diff --git a/test/visits/VisitsStats.test.js b/test/visits/VisitsStats.test.js index 225317cd..212127ad 100644 --- a/test/visits/VisitsStats.test.js +++ b/test/visits/VisitsStats.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { identity } from 'ramda'; -import { Card } from 'reactstrap'; +import { Card, Progress } from 'reactstrap'; import createVisitStats from '../../src/visits/VisitsStats'; import Message from '../../src/utils/Message'; import GraphCard from '../../src/visits/GraphCard'; @@ -35,17 +35,22 @@ describe('', () => { it('renders a preloader when visits are loading', () => { const wrapper = createComponent({ loading: true, visits: [] }); const loadingMessage = wrapper.find(Message); + const progress = wrapper.find(Progress); expect(loadingMessage).toHaveLength(1); expect(loadingMessage.html()).toContain('Loading...'); + expect(progress).toHaveLength(0); }); - it('renders a warning when loading large amounts of visits', () => { - const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [] }); + it('renders a warning and progress bar when loading large amounts of visits', () => { + const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [], progress: 25 }); const loadingMessage = wrapper.find(Message); + const progress = wrapper.find(Progress); expect(loadingMessage).toHaveLength(1); expect(loadingMessage.html()).toContain('This is going to take a while... :S'); + expect(progress).toHaveLength(1); + expect(progress.prop('value')).toEqual(25); }); it('renders an error message when visits could not be loaded', () => { diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js index 5bb27dfe..0c295a12 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -6,6 +6,7 @@ import reducer, { GET_SHORT_URL_VISITS, GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_CANCEL, + GET_SHORT_URL_VISITS_PROGRESS_CHANGED, } from '../../../src/visits/reducers/shortUrlVisits'; import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; @@ -66,6 +67,12 @@ describe('shortUrlVisitsReducer', () => { expect(visits).toEqual(expectedVisits); }); + + it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => { + const state = reducer({}, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, progress: 85 }); + + expect(state).toEqual({ progress: 85 }); + }); }); describe('getShortUrlVisits', () => { @@ -127,7 +134,7 @@ describe('shortUrlVisitsReducer', () => { await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests); - expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ visits: [{}, {}, {}, {}, {}, {}], })); }); diff --git a/test/visits/reducers/tagVisits.test.js b/test/visits/reducers/tagVisits.test.js index a9b45b00..b39444eb 100644 --- a/test/visits/reducers/tagVisits.test.js +++ b/test/visits/reducers/tagVisits.test.js @@ -6,6 +6,7 @@ import reducer, { GET_TAG_VISITS, GET_TAG_VISITS_LARGE, GET_TAG_VISITS_CANCEL, + GET_TAG_VISITS_PROGRESS_CHANGED, } from '../../../src/visits/reducers/tagVisits'; import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; @@ -66,6 +67,12 @@ describe('tagVisitsReducer', () => { expect(visits).toEqual(expectedVisits); }); + + it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => { + const state = reducer({}, { type: GET_TAG_VISITS_PROGRESS_CHANGED, progress: 85 }); + + expect(state).toEqual({ progress: 85 }); + }); }); describe('getTagVisits', () => { From 09e2c69e461f20f9489cbd5767cecc38de7bd1d3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 11 May 2020 19:40:19 +0200 Subject: [PATCH 12/14] Ensured visits by tag route does not work for old Shlink servers --- src/common/MenuLayout.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index ce6e7eca..428eeb1a 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -8,6 +8,7 @@ import * as PropTypes from 'prop-types'; import { serverType } from '../servers/prop-types'; import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { useToggle } from '../utils/helpers/hooks'; +import { versionMatch } from '../utils/helpers/version'; import NotFound from './NotFound'; import './MenuLayout.scss'; @@ -37,6 +38,7 @@ const MenuLayout = ( return ; } + const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' }); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible, }); @@ -70,7 +72,7 @@ const MenuLayout = ( - + {addTagsVisitsRoute && } List short URLs} From e47dfaf36fc8270fd64b66410d0c9fa38140edf1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 11 May 2020 19:44:27 +0200 Subject: [PATCH 13/14] Changed paginable charts so that they use 50 items per page by default --- src/visits/SortableBarGraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/visits/SortableBarGraph.js b/src/visits/SortableBarGraph.js index a19e9f4f..3d849fa6 100644 --- a/src/visits/SortableBarGraph.js +++ b/src/visits/SortableBarGraph.js @@ -28,7 +28,7 @@ export default class SortableBarGraph extends React.Component { orderField: undefined, orderDir: undefined, currentPage: 1, - itemsPerPage: Infinity, + itemsPerPage: 50, }; getSortedPairsForStats(stats, sortingItems) { From 1d26cd93fb1a4b8362d4015a0f9bd28115d67fe2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 13 May 2020 18:32:27 +0200 Subject: [PATCH 14/14] Added real time updates to tags list page --- src/tags/TagsList.js | 16 ++++++++++++- src/tags/reducers/tagsList.js | 35 +++++++++++++++++++++------- src/tags/services/provideServices.js | 5 +++- test/tags/TagsList.test.js | 2 +- test/tags/reducers/tagsList.test.js | 8 +++---- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/tags/TagsList.js b/src/tags/TagsList.js index 54b18c97..f0e70874 100644 --- a/src/tags/TagsList.js +++ b/src/tags/TagsList.js @@ -4,6 +4,9 @@ import PropTypes from 'prop-types'; import Message from '../utils/Message'; import SearchField from '../utils/SearchField'; import { serverType } from '../servers/prop-types'; +import { MercureInfoType } from '../mercure/reducers/mercureInfo'; +import { SettingsType } from '../settings/reducers/settings'; +import { bindToMercureTopic } from '../mercure/helpers'; import { TagsListType } from './reducers/tagsList'; const { ceil } = Math; @@ -14,15 +17,26 @@ const propTypes = { forceListTags: PropTypes.func, tagsList: TagsListType, selectedServer: serverType, + createNewVisit: PropTypes.func, + loadMercureInfo: PropTypes.func, + mercureInfo: MercureInfoType, + settings: SettingsType, }; const TagsList = (TagCard) => { - const TagListComp = ({ filterTags, forceListTags, tagsList, selectedServer }) => { + const TagListComp = ( + { filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo, settings } + ) => { + const { realTimeUpdates } = settings; const [ displayedTag, setDisplayedTag ] = useState(); useEffect(() => { forceListTags(); }, []); + useEffect( + bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo), + [ mercureInfo ] + ); const renderContent = () => { if (tagsList.loading) { diff --git a/src/tags/reducers/tagsList.js b/src/tags/reducers/tagsList.js index e810f2c7..643b3a94 100644 --- a/src/tags/reducers/tagsList.js +++ b/src/tags/reducers/tagsList.js @@ -1,6 +1,7 @@ import { handleActions } from 'redux-actions'; import { isEmpty, reject } from 'ramda'; import PropTypes from 'prop-types'; +import { CREATE_VISIT } from '../../visits/reducers/visitCreation'; import { TAG_DELETED } from './tagDelete'; import { TAG_EDITED } from './tagEdit'; @@ -11,10 +12,15 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS'; export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; /* eslint-enable padding-line-between-statements */ +const TagStatsType = PropTypes.shape({ + shortUrlsCount: PropTypes.number, + visitsCount: PropTypes.number, +}); + export const TagsListType = PropTypes.shape({ tags: PropTypes.arrayOf(PropTypes.string), filteredTags: PropTypes.arrayOf(PropTypes.string), - stats: PropTypes.object, // Record + stats: PropTypes.objectOf(TagStatsType), // Record loading: PropTypes.bool, error: PropTypes.bool, }); @@ -29,11 +35,23 @@ const initialState = { const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag; const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags); +const increaseVisitsForTags = (tags, stats) => tags.reduce((stats, tag) => { + if (!stats[tag]) { + return stats; + } + + const tagStats = stats[tag]; + + tagStats.visitsCount = tagStats.visitsCount + 1; + stats[tag] = tagStats; + + return stats; +}, { ...stats }); export default handleActions({ - [LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }), - [LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }), - [LIST_TAGS]: (state, { tags, stats }) => ({ stats, tags, filteredTags: tags, loading: false, error: false }), + [LIST_TAGS_START]: () => ({ ...initialState, loading: true }), + [LIST_TAGS_ERROR]: () => ({ ...initialState, error: true }), + [LIST_TAGS]: (state, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }), [TAG_DELETED]: (state, { tag }) => ({ ...state, tags: rejectTag(state.tags, tag), @@ -48,6 +66,10 @@ export default handleActions({ ...state, filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)), }), + [CREATE_VISIT]: (state, { shortUrl }) => ({ + ...state, + stats: increaseVisitsForTags(shortUrl.tags, state.stats), + }), }, initialState); export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => { @@ -74,7 +96,4 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis } }; -export const filterTags = (searchTerm) => ({ - type: FILTER_TAGS, - searchTerm, -}); +export const filterTags = (searchTerm) => ({ type: FILTER_TAGS, searchTerm }); diff --git a/src/tags/services/provideServices.js b/src/tags/services/provideServices.js index 566791ea..7917d068 100644 --- a/src/tags/services/provideServices.js +++ b/src/tags/services/provideServices.js @@ -28,7 +28,10 @@ const provideServices = (bottle, connect) => { bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ])); bottle.serviceFactory('TagsList', TagsList, 'TagCard'); - bottle.decorator('TagsList', connect([ 'tagsList', 'selectedServer' ], [ 'forceListTags', 'filterTags' ])); + bottle.decorator('TagsList', connect( + [ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ], + [ 'forceListTags', 'filterTags', 'createNewVisit', 'loadMercureInfo' ] + )); // Actions const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force); diff --git a/test/tags/TagsList.test.js b/test/tags/TagsList.test.js index c3bd3393..b4ebcafa 100644 --- a/test/tags/TagsList.test.js +++ b/test/tags/TagsList.test.js @@ -15,7 +15,7 @@ describe('', () => { const TagsList = createTagsList(TagCard); wrapper = shallow( - + ); return wrapper; diff --git a/test/tags/reducers/tagsList.test.js b/test/tags/reducers/tagsList.test.js index c312fea8..da86b564 100644 --- a/test/tags/reducers/tagsList.test.js +++ b/test/tags/reducers/tagsList.test.js @@ -11,17 +11,17 @@ import { TAG_EDITED } from '../../../src/tags/reducers/tagEdit'; describe('tagsListReducer', () => { describe('reducer', () => { it('returns loading on LIST_TAGS_START', () => { - expect(reducer({}, { type: LIST_TAGS_START })).toEqual({ + expect(reducer({}, { type: LIST_TAGS_START })).toEqual(expect.objectContaining({ loading: true, error: false, - }); + })); }); it('returns error on LIST_TAGS_ERROR', () => { - expect(reducer({}, { type: LIST_TAGS_ERROR })).toEqual({ + expect(reducer({}, { type: LIST_TAGS_ERROR })).toEqual(expect.objectContaining({ loading: false, error: true, - }); + })); }); it('returns provided tags as filtered and regular tags on LIST_TAGS', () => {