Ensured domain is passed when loading visits for a short URL on a specific domain

This commit is contained in:
Alejandro Celaya 2020-02-08 09:07:55 +01:00
parent c682737505
commit dc672bf0f0
14 changed files with 108 additions and 45 deletions

View file

@ -3,25 +3,33 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import { shortUrlMetaType } from '../reducers/shortUrlMeta'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList';
import './ShortUrlVisitsCount.scss'; import './ShortUrlVisitsCount.scss';
import VisitStatsLink from './VisitStatsLink';
const propTypes = { const propTypes = {
visitsCount: PropTypes.number.isRequired, visitsCount: PropTypes.number.isRequired,
meta: shortUrlMetaType, shortUrl: shortUrlType,
selectedServer: serverType,
}; };
const ShortUrlVisitsCount = ({ visitsCount, meta }) => { const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer }) => {
const maxVisits = meta && meta.maxVisits; const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
<strong>{visitsCount}</strong>
</VisitStatsLink>
);
if (!maxVisits) { if (!maxVisits) {
return <span>{visitsCount}</span>; return visitsLink;
} }
return ( return (
<React.Fragment> <React.Fragment>
<span className="indivisible"> <span className="indivisible">
{visitsCount} {visitsLink}
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control"> <small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
{' '}/ {maxVisits}{' '} {' '}/ {maxVisits}{' '}
<sup> <sup>

View file

@ -58,7 +58,11 @@ const ShortUrlsRow = (
</td> </td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td> <td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: "> <td className="short-urls-row__cell text-md-right" data-th="Visits: ">
<ShortUrlVisitsCount visitsCount={shortUrl.visitsCount} meta={shortUrl.meta} /> <ShortUrlVisitsCount
visitsCount={shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
/>
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--relative"> <td className="short-urls-row__cell short-urls-row__cell--relative">
<small <small

View file

@ -10,13 +10,13 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal'; import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal'; import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss'; import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = ( const ShortUrlsRowMenu = (
@ -57,7 +57,7 @@ const ShortUrlsRowMenu = (
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp; &nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle> </DropdownToggle>
<DropdownMenu right> <DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}> <DropdownItem tag={VisitStatsLink} selectedServer={selectedServer} shortUrl={shortUrl}>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats <FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem> </DropdownItem>

View file

@ -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 <span {...rest}>{children}</span>;
}
return <Link to={buildVisitsUrl(selectedServer, shortUrl)} {...rest}>{children}</Link>;
};
VisitStatsLink.propTypes = propTypes;
export default VisitStatsLink;

View file

@ -18,6 +18,7 @@ export const shortUrlType = PropTypes.shape({
visitsCount: PropTypes.number, visitsCount: PropTypes.number,
meta: shortUrlMetaType, meta: shortUrlMetaType,
tags: PropTypes.arrayOf(PropTypes.string), tags: PropTypes.arrayOf(PropTypes.string),
domain: PropTypes.string,
}); });
const initialState = { const initialState = {

View file

@ -12,6 +12,7 @@ export const apiErrorType = PropTypes.shape({
}); });
const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : ''; const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = (options = {}) => reject(isNil, options);
export default class ShlinkApiClient { export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) { constructor(axios, baseUrl, apiKey) {
@ -22,7 +23,7 @@ export default class ShlinkApiClient {
} }
listShortUrls = pipe( listShortUrls = pipe(
(options = {}) => reject(isNil, options), rejectNilProps,
(options) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls) (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) this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
.then((resp) => resp.data.visits); .then((resp) => resp.data.visits);
getShortUrl = (shortCode) => getShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET') this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
.then((resp) => resp.data); .then((resp) => resp.data);
deleteShortUrl = (shortCode) => deleteShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE') this._performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => ({})); .then(() => ({}));
updateShortUrlTags = (shortCode, tags) => updateShortUrlTags = (shortCode, domain, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags }) this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then((resp) => resp.data.tags); .then((resp) => resp.data.tags);
updateShortUrlMeta = (shortCode, meta) => updateShortUrlMeta = (shortCode, domain, meta) =>
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', {}, meta) this._performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
.then(() => meta); .then(() => meta);
listTags = () => listTags = () =>
@ -73,7 +74,7 @@ export default class ShlinkApiClient {
method, method,
url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`, url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`,
headers: { 'X-Api-Key': this._apiKey }, headers: { 'X-Api-Key': this._apiKey },
params: query, params: rejectNilProps(query),
data: body, data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }), paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
}); });

View file

@ -4,6 +4,7 @@ import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react'; import React from 'react';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import qs from 'qs';
import DateRangeRow from '../utils/DateRangeRow'; import DateRangeRow from '../utils/DateRangeRow';
import MutedMessage from '../utils/MuttedMessage'; import MutedMessage from '../utils/MuttedMessage';
import { formatDate } from '../utils/utils'; import { formatDate } from '../utils/utils';
@ -21,6 +22,9 @@ const ShortUrlVisits = (
match: PropTypes.shape({ match: PropTypes.shape({
params: PropTypes.object, params: PropTypes.object,
}), }),
location: PropTypes.shape({
search: PropTypes.string,
}),
getShortUrlVisits: PropTypes.func, getShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType, shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func, getShortUrlDetail: PropTypes.func,
@ -30,14 +34,16 @@ const ShortUrlVisits = (
state = { startDate: undefined, endDate: undefined }; state = { startDate: undefined, endDate: undefined };
loadVisits = () => { loadVisits = () => {
const { match: { params }, getShortUrlVisits } = this.props; const { match: { params }, location: { search }, getShortUrlVisits } = this.props;
const { shortCode } = params; const { shortCode } = params;
const dates = mapObjIndexed(formatDate(), this.state); const dates = mapObjIndexed(formatDate(), this.state);
const { startDate, endDate } = dates; 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}`; this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
getShortUrlVisits(shortCode, dates); getShortUrlVisits(shortCode, { startDate, endDate, domain });
}; };
componentDidMount() { componentDidMount() {

View file

@ -33,7 +33,7 @@ export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
<h2> <h2>
<span className="badge badge-main float-right"> <span className="badge badge-main float-right">
Visits:{' '} Visits:{' '}
<ShortUrlVisitsCount visitsCount={visits.length} meta={shortUrl.meta} /> <ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
</span> </span>
Visit stats for <ExternalLink href={shortLink} /> Visit stats for <ExternalLink href={shortLink} />
</h2> </h2>

View file

@ -49,7 +49,7 @@ export default handleActions({
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
}, initialState); }, 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 }); dispatch({ type: GET_SHORT_URL_VISITS_START });
const { getShortUrlVisits } = await buildShlinkApiClient(getState); const { getShortUrlVisits } = await buildShlinkApiClient(getState);
@ -57,7 +57,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
const loadVisits = async (page = 1) => { 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 was not returned, then this is an older shlink version. Just return data
if (!pagination || isLastPage(pagination)) { if (!pagination || isLastPage(pagination)) {
@ -96,7 +96,7 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) =>
const loadVisitsInParallel = (pages) => const loadVisitsInParallel = (pages) =>
Promise.all(pages.map( Promise.all(pages.map(
(page) => (page) =>
getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage }) getShortUrlVisits(shortCode, { ...query, page, itemsPerPage })
.then(prop('data')) .then(prop('data'))
)).then(flatten); )).then(flatten);

View file

@ -7,8 +7,8 @@ import ShortUrlVisitsCount from '../../../src/short-urls/helpers/ShortUrlVisitsC
describe('<ShortUrlVisitsCount />', () => { describe('<ShortUrlVisitsCount />', () => {
let wrapper; let wrapper;
const createWrapper = (visitsCount, meta) => { const createWrapper = (visitsCount, shortUrl) => {
wrapper = shallow(<ShortUrlVisitsCount visitsCount={visitsCount} meta={meta} />); wrapper = shallow(<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />);
return wrapper; return wrapper;
}; };
@ -17,11 +17,11 @@ describe('<ShortUrlVisitsCount />', () => {
each([ undefined, {}]).it('just returns visits when no maxVisits is provided', (meta) => { each([ undefined, {}]).it('just returns visits when no maxVisits is provided', (meta) => {
const visitsCount = 45; 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 maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);
expect(wrapper.html()).toEqual(`<span>${visitsCount}</span>`); expect(wrapper.html()).toEqual(`<span><strong>${visitsCount}</strong></span>`);
expect(maxVisitsHelper).toHaveLength(0); expect(maxVisitsHelper).toHaveLength(0);
expect(maxVisitsTooltip).toHaveLength(0); expect(maxVisitsTooltip).toHaveLength(0);
}); });
@ -30,7 +30,7 @@ describe('<ShortUrlVisitsCount />', () => {
const visitsCount = 45; const visitsCount = 45;
const maxVisits = 500; const maxVisits = 500;
const meta = { maxVisits }; 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 maxVisitsHelper = wrapper.find('.short-urls-visits-count__max-visits-control');
const maxVisitsTooltip = wrapper.find(UncontrolledTooltip); const maxVisitsTooltip = wrapper.find(UncontrolledTooltip);

View file

@ -83,4 +83,6 @@ describe('<ShortUrlsRowMenu />', () => {
done(); done();
}); });
}); });
it('generates expected visits page link', () => {})
}); });

View file

@ -1,8 +1,14 @@
import each from 'jest-each';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClient', () => { describe('ShlinkApiClient', () => {
const createAxiosMock = (data) => () => Promise.resolve(data); const createAxiosMock = (data) => () => Promise.resolve(data);
const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data)); const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data));
const shortCodesWithDomainCombinations = [
[ 'abc123', null ],
[ 'abc123', undefined ],
[ 'abc123', 'example.com' ],
];
describe('listShortUrls', () => { describe('listShortUrls', () => {
it('properly returns short URLs list', async () => { it('properly returns short URLs list', async () => {
@ -67,43 +73,45 @@ describe('ShlinkApiClient', () => {
}); });
describe('getShortUrl', () => { describe('getShortUrl', () => {
it('properly returns short URL', async () => { each(shortCodesWithDomainCombinations).it('properly returns short URL', async (shortCode, domain) => {
const expectedShortUrl = { foo: 'bar' }; const expectedShortUrl = { foo: 'bar' };
const axiosSpy = jest.fn(createAxiosMock({ const axiosSpy = jest.fn(createAxiosMock({
data: expectedShortUrl, data: expectedShortUrl,
})); }));
const { getShortUrl } = new ShlinkApiClient(axiosSpy); const { getShortUrl } = new ShlinkApiClient(axiosSpy);
const result = await getShortUrl('abc123'); const result = await getShortUrl(shortCode, domain);
expect(expectedShortUrl).toEqual(result); expect(expectedShortUrl).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123', url: `/short-urls/${shortCode}`,
method: 'GET', method: 'GET',
params: domain ? { domain } : {},
})); }));
}); });
}); });
describe('updateShortUrlTags', () => { 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 expectedTags = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({ const axiosSpy = jest.fn(createAxiosMock({
data: { tags: expectedTags }, data: { tags: expectedTags },
})); }));
const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy); const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy);
const result = await updateShortUrlTags('abc123', expectedTags); const result = await updateShortUrlTags(shortCode, domain, expectedTags);
expect(expectedTags).toEqual(result); expect(expectedTags).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123/tags', url: `/short-urls/${shortCode}/tags`,
method: 'PUT', method: 'PUT',
params: domain ? { domain } : {},
})); }));
}); });
}); });
describe('updateShortUrlMeta', () => { describe('updateShortUrlMeta', () => {
it('properly updates short URL meta', async () => { each(shortCodesWithDomainCombinations).it('properly updates short URL meta', async (shortCode, domain) => {
const expectedMeta = { const expectedMeta = {
maxVisits: 50, maxVisits: 50,
validSince: '2025-01-01T10:00:00+01:00', validSince: '2025-01-01T10:00:00+01:00',
@ -111,12 +119,13 @@ describe('ShlinkApiClient', () => {
const axiosSpy = jest.fn(createAxiosMock()); const axiosSpy = jest.fn(createAxiosMock());
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy); const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy);
const result = await updateShortUrlMeta('abc123', expectedMeta); const result = await updateShortUrlMeta(shortCode, domain, expectedMeta);
expect(expectedMeta).toEqual(result); expect(expectedMeta).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123', url: `/short-urls/${shortCode}`,
method: 'PATCH', method: 'PATCH',
params: domain ? { domain } : {},
})); }));
}); });
}); });
@ -172,15 +181,16 @@ describe('ShlinkApiClient', () => {
}); });
describe('deleteShortUrl', () => { 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 axiosSpy = jest.fn(createAxiosMock({}));
const { deleteShortUrl } = new ShlinkApiClient(axiosSpy); const { deleteShortUrl } = new ShlinkApiClient(axiosSpy);
await deleteShortUrl('abc123'); await deleteShortUrl(shortCode, domain);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/short-urls/abc123', url: `/short-urls/${shortCode}`,
method: 'DELETE', method: 'DELETE',
params: domain ? { domain } : {},
})); }));
}); });
}); });

View file

@ -17,15 +17,17 @@ describe('<ShortUrlVisits />', () => {
const match = { const match = {
params: { shortCode: 'abc123' }, params: { shortCode: 'abc123' },
}; };
const location = { search: '' };
const createComponent = (shortUrlVisits) => { const createComponent = (shortUrlVisits) => {
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }); const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }, () => '');
wrapper = shallow( wrapper = shallow(
<ShortUrlVisits <ShortUrlVisits
getShortUrlDetail={identity} getShortUrlDetail={identity}
getShortUrlVisits={getShortUrlVisitsMock} getShortUrlVisits={getShortUrlVisitsMock}
match={match} match={match}
location={location}
shortUrlVisits={shortUrlVisits} shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}} shortUrlDetail={{}}
cancelGetShortUrlVisits={identity} cancelGetShortUrlVisits={identity}

View file

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