Created page for tag visit stats

This commit is contained in:
Alejandro Celaya 2020-05-10 19:02:58 +02:00
parent 18e18f533b
commit bfbb21e1cc
17 changed files with 291 additions and 79 deletions

View file

@ -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
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{/* <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} /> */}
<Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}

View file

@ -27,6 +27,7 @@ const provideServices = (bottle, connect, withRouter) => {
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'ShlinkVersions',
'ServerError'
);

View file

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

View file

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

View file

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

65
src/visits/TagVisits.js Normal file
View file

@ -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 (
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits}>
<span>{tag} - {tagVisits.visits.length}</span>
</VisitsStats>
);
};
TagVisitsComp.propTypes = propTypes;
return TagVisitsComp;
};
export default TagVisits;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT';
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_VISIT });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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