diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.js b/src/short-urls/helpers/ShortUrlVisitsCount.js index 6a5c9359..a268c9b5 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.js +++ b/src/short-urls/helpers/ShortUrlVisitsCount.js @@ -3,25 +3,33 @@ import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; -import { shortUrlMetaType } from '../reducers/shortUrlMeta'; +import { serverType } from '../../servers/prop-types'; +import { shortUrlType } from '../reducers/shortUrlsList'; import './ShortUrlVisitsCount.scss'; +import VisitStatsLink from './VisitStatsLink'; const propTypes = { visitsCount: PropTypes.number.isRequired, - meta: shortUrlMetaType, + shortUrl: shortUrlType, + selectedServer: serverType, }; -const ShortUrlVisitsCount = ({ visitsCount, meta }) => { - const maxVisits = meta && meta.maxVisits; +const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => { + const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits; + const visitsLink = ( + + {visitsCount} + + ); if (!maxVisits) { - return {visitsCount}; + return visitsLink; } return ( - {visitsCount} + {visitsLink} {' '}/ {maxVisits}{' '} diff --git a/src/short-urls/helpers/ShortUrlsRow.js b/src/short-urls/helpers/ShortUrlsRow.js index 43d4205f..a0cbf4a8 100644 --- a/src/short-urls/helpers/ShortUrlsRow.js +++ b/src/short-urls/helpers/ShortUrlsRow.js @@ -58,7 +58,11 @@ const ShortUrlsRow = ( {this.renderTags(shortUrl.tags)} - +   - + Visit stats diff --git a/src/short-urls/helpers/VisitStatsLink.js b/src/short-urls/helpers/VisitStatsLink.js new file mode 100644 index 00000000..f500e580 --- /dev/null +++ b/src/short-urls/helpers/VisitStatsLink.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { serverType } from '../../servers/prop-types'; +import { shortUrlType } from '../reducers/shortUrlsList'; + +const propTypes = { + shortUrl: shortUrlType, + selectedServer: serverType, + children: PropTypes.node.isRequired, +}; + +const buildVisitsUrl = ({ id }, { shortCode, domain }) => { + const query = domain ? `?domain=${domain}` : ''; + + return `/server/${id}/short-code/${shortCode}/visits${query}`; +}; + +const VisitStatsLink = ({ selectedServer, shortUrl, children, ...rest }) => { + if (!selectedServer || !shortUrl) { + return {children}; + } + + return {children}; +}; + +VisitStatsLink.propTypes = propTypes; + +export default VisitStatsLink; diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index 6eadeca2..f43c264b 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -18,6 +18,7 @@ export const shortUrlType = PropTypes.shape({ visitsCount: PropTypes.number, meta: shortUrlMetaType, tags: PropTypes.arrayOf(PropTypes.string), + domain: PropTypes.string, }); const initialState = { diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js index fe9ce69a..3fdfddfc 100644 --- a/src/utils/services/ShlinkApiClient.js +++ b/src/utils/services/ShlinkApiClient.js @@ -12,6 +12,7 @@ export const apiErrorType = PropTypes.shape({ }); const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : ''; +const rejectNilProps = (options = {}) => reject(isNil, options); export default class ShlinkApiClient { constructor(axios, baseUrl, apiKey) { @@ -22,7 +23,7 @@ export default class ShlinkApiClient { } listShortUrls = pipe( - (options = {}) => reject(isNil, options), + rejectNilProps, (options) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls) ); @@ -37,20 +38,20 @@ export default class ShlinkApiClient { this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query) .then((resp) => resp.data.visits); - getShortUrl = (shortCode) => - this._performRequest(`/short-urls/${shortCode}`, 'GET') + getShortUrl = (shortCode, domain) => + this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain }) .then((resp) => resp.data); - deleteShortUrl = (shortCode) => - this._performRequest(`/short-urls/${shortCode}`, 'DELETE') + deleteShortUrl = (shortCode, domain) => + this._performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) .then(() => ({})); - updateShortUrlTags = (shortCode, tags) => - this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags }) + updateShortUrlTags = (shortCode, domain, tags) => + this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags }) .then((resp) => resp.data.tags); - updateShortUrlMeta = (shortCode, meta) => - this._performRequest(`/short-urls/${shortCode}`, 'PATCH', {}, meta) + updateShortUrlMeta = (shortCode, domain, meta) => + this._performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta) .then(() => meta); listTags = () => @@ -73,7 +74,7 @@ export default class ShlinkApiClient { method, url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`, headers: { 'X-Api-Key': this._apiKey }, - params: query, + params: rejectNilProps(query), data: body, paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }), }); diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index d78fd876..36565e75 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -4,6 +4,7 @@ import { isEmpty, mapObjIndexed, values } from 'ramda'; import React from 'react'; import { Card } from 'reactstrap'; import PropTypes from 'prop-types'; +import qs from 'qs'; import DateRangeRow from '../utils/DateRangeRow'; import MutedMessage from '../utils/MuttedMessage'; import { formatDate } from '../utils/utils'; @@ -21,6 +22,9 @@ const ShortUrlVisits = ( match: PropTypes.shape({ params: PropTypes.object, }), + location: PropTypes.shape({ + search: PropTypes.string, + }), getShortUrlVisits: PropTypes.func, shortUrlVisits: shortUrlVisitsType, getShortUrlDetail: PropTypes.func, @@ -30,14 +34,16 @@ const ShortUrlVisits = ( state = { startDate: undefined, endDate: undefined }; loadVisits = () => { - const { match: { params }, getShortUrlVisits } = this.props; + const { match: { params }, location: { search }, getShortUrlVisits } = this.props; const { shortCode } = params; const dates = mapObjIndexed(formatDate(), this.state); const { startDate, endDate } = dates; + const queryParams = qs.parse(search, { ignoreQueryPrefix: true }); + const { domain } = queryParams; - // While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs + // While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`; - getShortUrlVisits(shortCode, dates); + getShortUrlVisits(shortCode, { startDate, endDate, domain }); }; componentDidMount() { diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index 439f2063..e13398dc 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -33,7 +33,7 @@ export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {

Visits:{' '} - + Visit stats for

diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index faefcc5b..8b23125f 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -49,7 +49,7 @@ export default handleActions({ [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), }, initialState); -export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => async (dispatch, getState) => { +export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => { dispatch({ type: GET_SHORT_URL_VISITS_START }); const { getShortUrlVisits } = await buildShlinkApiClient(getState); @@ -57,7 +57,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; const loadVisits = async (page = 1) => { - const { pagination, data } = await getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage }); + 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)) { @@ -96,7 +96,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => const loadVisitsInParallel = (pages) => Promise.all(pages.map( (page) => - getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage }) + getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }) .then(prop('data')) )).then(flatten); diff --git a/test/short-urls/helpers/ShortUrlVisitsCount.test.js b/test/short-urls/helpers/ShortUrlVisitsCount.test.js index 42508219..3a375fd9 100644 --- a/test/short-urls/helpers/ShortUrlVisitsCount.test.js +++ b/test/short-urls/helpers/ShortUrlVisitsCount.test.js @@ -7,8 +7,8 @@ import ShortUrlVisitsCount from '../../../src/short-urls/helpers/ShortUrlVisitsC describe('', () => { let wrapper; - const createWrapper = (visitsCount, meta) => { - wrapper = shallow(); + const createWrapper = (visitsCount, shortUrl) => { + wrapper = shallow(); return wrapper; }; @@ -17,11 +17,11 @@ describe('', () => { each([ undefined, {}]).it('just returns visits when no maxVisits is provided', (meta) => { const visitsCount = 45; - const wrapper = createWrapper(visitsCount, meta); + const wrapper = createWrapper(visitsCount, { meta }); const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); - expect(wrapper.html()).toEqual(`${visitsCount}`); + expect(wrapper.html()).toEqual(`${visitsCount}`); expect(maxVisitsHelper).toHaveLength(0); expect(maxVisitsTooltip).toHaveLength(0); }); @@ -30,7 +30,7 @@ describe('', () => { const visitsCount = 45; const maxVisits = 500; const meta = { maxVisits }; - const wrapper = createWrapper(visitsCount, meta); + const wrapper = createWrapper(visitsCount, { meta }); const maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control'); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.js b/test/short-urls/helpers/ShortUrlsRowMenu.test.js index cfcee90d..59d5f91e 100644 --- a/test/short-urls/helpers/ShortUrlsRowMenu.test.js +++ b/test/short-urls/helpers/ShortUrlsRowMenu.test.js @@ -83,4 +83,6 @@ describe('', () => { done(); }); }); + + it('generates expected visits page link', () => {}) }); diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js index 09697bd6..8cda6271 100644 --- a/test/utils/services/ShlinkApiClient.test.js +++ b/test/utils/services/ShlinkApiClient.test.js @@ -1,8 +1,14 @@ +import each from 'jest-each'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; describe('ShlinkApiClient', () => { const createAxiosMock = (data) => () => Promise.resolve(data); const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data)); + const shortCodesWithDomainCombinations = [ + [ 'abc123', null ], + [ 'abc123', undefined ], + [ 'abc123', 'example.com' ], + ]; describe('listShortUrls', () => { it('properly returns short URLs list', async () => { @@ -67,43 +73,45 @@ describe('ShlinkApiClient', () => { }); describe('getShortUrl', () => { - it('properly returns short URL', async () => { + each(shortCodesWithDomainCombinations).it('properly returns short URL', async (shortCode, domain) => { const expectedShortUrl = { foo: 'bar' }; const axiosSpy = jest.fn(createAxiosMock({ data: expectedShortUrl, })); const { getShortUrl } = new ShlinkApiClient(axiosSpy); - const result = await getShortUrl('abc123'); + const result = await getShortUrl(shortCode, domain); expect(expectedShortUrl).toEqual(result); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/short-urls/abc123', + url: `/short-urls/${shortCode}`, method: 'GET', + params: domain ? { domain } : {}, })); }); }); describe('updateShortUrlTags', () => { - it('properly updates short URL tags', async () => { + each(shortCodesWithDomainCombinations).it('properly updates short URL tags', async (shortCode, domain) => { const expectedTags = [ 'foo', 'bar' ]; const axiosSpy = jest.fn(createAxiosMock({ data: { tags: expectedTags }, })); const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy); - const result = await updateShortUrlTags('abc123', expectedTags); + const result = await updateShortUrlTags(shortCode, domain, expectedTags); expect(expectedTags).toEqual(result); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/short-urls/abc123/tags', + url: `/short-urls/${shortCode}/tags`, method: 'PUT', + params: domain ? { domain } : {}, })); }); }); describe('updateShortUrlMeta', () => { - it('properly updates short URL meta', async () => { + each(shortCodesWithDomainCombinations).it('properly updates short URL meta', async (shortCode, domain) => { const expectedMeta = { maxVisits: 50, validSince: '2025-01-01T10:00:00+01:00', @@ -111,12 +119,13 @@ describe('ShlinkApiClient', () => { const axiosSpy = jest.fn(createAxiosMock()); const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy); - const result = await updateShortUrlMeta('abc123', expectedMeta); + const result = await updateShortUrlMeta(shortCode, domain, expectedMeta); expect(expectedMeta).toEqual(result); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/short-urls/abc123', + url: `/short-urls/${shortCode}`, method: 'PATCH', + params: domain ? { domain } : {}, })); }); }); @@ -172,15 +181,16 @@ describe('ShlinkApiClient', () => { }); describe('deleteShortUrl', () => { - it('properly deletes provided short URL', async () => { + each(shortCodesWithDomainCombinations).it('properly deletes provided short URL', async (shortCode, domain) => { const axiosSpy = jest.fn(createAxiosMock({})); const { deleteShortUrl } = new ShlinkApiClient(axiosSpy); - await deleteShortUrl('abc123'); + await deleteShortUrl(shortCode, domain); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/short-urls/abc123', + url: `/short-urls/${shortCode}`, method: 'DELETE', + params: domain ? { domain } : {}, })); }); }); diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 40d634c5..b136efde 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -17,15 +17,17 @@ describe('', () => { const match = { params: { shortCode: 'abc123' }, }; + const location = { search: '' }; const createComponent = (shortUrlVisits) => { - const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }); + const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }, () => ''); wrapper = shallow( ', () => { it('shows the amount of visits', () => { const visitsBadge = wrapper.find('.badge'); - expect(visitsBadge.html()).toContain(`Visits: ${shortUrlVisits.visits.length}`); + expect(visitsBadge.html()).toContain(`Visits: ${shortUrlVisits.visits.length}`); }); it('shows when the URL was created', () => {