Extend overview to exclude/include bot visits based on config

This commit is contained in:
Alejandro Celaya 2023-03-18 10:55:07 +01:00
parent 1d8189369c
commit 934bf495a0
5 changed files with 84 additions and 19 deletions

View file

@ -5,6 +5,7 @@ import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import type { ShlinkShortUrlsListParams } from '../api/types'; import type { ShlinkShortUrlsListParams } from '../api/types';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import type { Settings } from '../settings/reducers/settings';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList'; import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
@ -16,6 +17,7 @@ import type { VisitsOverview } from '../visits/reducers/visitsOverview';
import type { SelectedServer } from './data'; import type { SelectedServer } from './data';
import { getServerId } from './data'; import { getServerId } from './data';
import { HighlightCard } from './helpers/HighlightCard'; import { HighlightCard } from './helpers/HighlightCard';
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
interface OverviewConnectProps { interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
@ -25,6 +27,7 @@ interface OverviewConnectProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
visitsOverview: VisitsOverview; visitsOverview: VisitsOverview;
loadVisitsOverview: Function; loadVisitsOverview: Function;
settings: Settings;
} }
export const Overview = ( export const Overview = (
@ -38,10 +41,11 @@ export const Overview = (
selectedServer, selectedServer,
loadVisitsOverview, loadVisitsOverview,
visitsOverview, visitsOverview,
settings: { visits },
}: OverviewConnectProps) => { }: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList; const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview; const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer); const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
const navigate = useNavigate(); const navigate = useNavigate();
@ -56,14 +60,22 @@ export const Overview = (
<> <>
<Row> <Row>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}> <VisitsHighlightCard
{loadingVisits ? 'Loading...' : prettify(visitsCount)} title="Visits"
</HighlightCard> link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={nonOrphanVisits}
/>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}> <VisitsHighlightCard
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount)} title="Orphan visits"
</HighlightCard> link={`/server/${serverId}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={orphanVisits}
/>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}> <HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>

View file

@ -0,0 +1,26 @@
import type { FC } from 'react';
import { prettify } from '../../utils/helpers/numbers';
import type { PartialVisitsSummary } from '../../visits/reducers/visitsOverview';
import type { HighlightCardProps } from './HighlightCard';
import { HighlightCard } from './HighlightCard';
type VisitsHighlightCardProps = Omit<HighlightCardProps, 'tooltip'> & {
loading: boolean;
excludeBots: boolean;
visitsSummary: PartialVisitsSummary;
};
export const VisitsHighlightCard: FC<VisitsHighlightCardProps> = ({ loading, excludeBots, visitsSummary, ...rest }) => (
<HighlightCard
tooltip={
visitsSummary.bots !== undefined
? <>{excludeBots ? 'Plus' : 'Including'} <b>{prettify(visitsSummary.bots)}</b> potential bot visits</>
: undefined
}
{...rest}
>
{loading ? 'Loading...' : prettify(
excludeBots && visitsSummary.nonBots ? visitsSummary.nonBots : visitsSummary.total,
)}
</HighlightCard>
);

View file

@ -65,7 +65,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl'); bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
bottle.decorator('Overview', connect( bottle.decorator('Overview', connect(
['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'], ['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview', 'settings'],
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'], ['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
)); ));

View file

@ -1,13 +1,15 @@
import { render, screen } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo'; import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo';
import type { ReachableServer } from '../../src/servers/data'; import type { ReachableServer } from '../../src/servers/data';
import { Overview as overviewCreator } from '../../src/servers/Overview'; import { Overview as overviewCreator } from '../../src/servers/Overview';
import type { Settings } from '../../src/settings/reducers/settings';
import type { ShortUrlsList as ShortUrlsListState } from '../../src/short-urls/reducers/shortUrlsList'; import type { ShortUrlsList as ShortUrlsListState } from '../../src/short-urls/reducers/shortUrlsList';
import type { TagsList } from '../../src/tags/reducers/tagsList'; import type { TagsList } from '../../src/tags/reducers/tagsList';
import { prettify } from '../../src/utils/helpers/numbers'; import { prettify } from '../../src/utils/helpers/numbers';
import type { VisitsOverview } from '../../src/visits/reducers/visitsOverview'; import type { VisitsOverview } from '../../src/visits/reducers/visitsOverview';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<Overview />', () => { describe('<Overview />', () => {
const ShortUrlsTable = () => <>ShortUrlsTable</>; const ShortUrlsTable = () => <>ShortUrlsTable</>;
@ -20,7 +22,7 @@ describe('<Overview />', () => {
pagination: { totalItems: 83710 }, pagination: { totalItems: 83710 },
}; };
const serverId = '123'; const serverId = '123';
const setUp = (loading = false) => render( const setUp = (loading = false, excludeBots = false) => renderWithEvents(
<MemoryRouter> <MemoryRouter>
<Overview <Overview
listShortUrls={listShortUrls} listShortUrls={listShortUrls}
@ -28,11 +30,16 @@ describe('<Overview />', () => {
loadVisitsOverview={loadVisitsOverview} loadVisitsOverview={loadVisitsOverview}
shortUrlsList={Mock.of<ShortUrlsListState>({ loading, shortUrls })} shortUrlsList={Mock.of<ShortUrlsListState>({ loading, shortUrls })}
tagsList={Mock.of<TagsList>({ loading, tags: ['foo', 'bar', 'baz'] })} tagsList={Mock.of<TagsList>({ loading, tags: ['foo', 'bar', 'baz'] })}
visitsOverview={Mock.of<VisitsOverview>({ loading, visitsCount: 3456, orphanVisitsCount: 28 })} visitsOverview={Mock.of<VisitsOverview>({
loading,
nonOrphanVisits: { total: 3456, bots: 1000, nonBots: 2456 },
orphanVisits: { total: 28, bots: 15, nonBots: 13 },
})}
selectedServer={Mock.of<ReachableServer>({ id: serverId })} selectedServer={Mock.of<ReachableServer>({ id: serverId })}
createNewVisits={jest.fn()} createNewVisits={jest.fn()}
loadMercureInfo={jest.fn()} loadMercureInfo={jest.fn()}
mercureInfo={Mock.all<MercureInfo>()} mercureInfo={Mock.all<MercureInfo>()}
settings={Mock.of<Settings>({ visits: { excludeBots } })}
/> />
</MemoryRouter>, </MemoryRouter>,
); );
@ -42,16 +49,19 @@ describe('<Overview />', () => {
expect(screen.getAllByText('Loading...')).toHaveLength(4); expect(screen.getAllByText('Loading...')).toHaveLength(4);
}); });
it('displays amounts in cards after finishing loading', () => { it.each([
setUp(); [false, 3456, 28],
[true, 2456, 13],
])('displays amounts in cards after finishing loading', (excludeBots, expectedVisits, expectedOrphanVisits) => {
setUp(false, excludeBots);
const headingElements = screen.getAllByRole('heading'); const headingElements = screen.getAllByRole('heading');
expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(headingElements[0]).toHaveTextContent('Visits'); expect(headingElements[0]).toHaveTextContent('Visits');
expect(headingElements[1]).toHaveTextContent(prettify(3456)); expect(headingElements[1]).toHaveTextContent(prettify(expectedVisits));
expect(headingElements[2]).toHaveTextContent('Orphan visits'); expect(headingElements[2]).toHaveTextContent('Orphan visits');
expect(headingElements[3]).toHaveTextContent(prettify(28)); expect(headingElements[3]).toHaveTextContent(prettify(expectedOrphanVisits));
expect(headingElements[4]).toHaveTextContent('Short URLs'); expect(headingElements[4]).toHaveTextContent('Short URLs');
expect(headingElements[5]).toHaveTextContent(prettify(83710)); expect(headingElements[5]).toHaveTextContent(prettify(83710));
expect(headingElements[6]).toHaveTextContent('Tags'); expect(headingElements[6]).toHaveTextContent('Tags');
@ -77,4 +87,20 @@ describe('<Overview />', () => {
expect(links[3]).toHaveAttribute('href', `/server/${serverId}/create-short-url`); expect(links[3]).toHaveAttribute('href', `/server/${serverId}/create-short-url`);
expect(links[4]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`); expect(links[4]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`);
}); });
it.each([
[true],
[false],
])('displays amounts of bots when hovering visits cards', async (excludeBots) => {
const { user } = setUp(false, excludeBots);
const expectTooltipToBeInTheDocument = async (tooltip: string) => waitFor(
() => expect(screen.getByText(/potential bot visits$/)).toHaveTextContent(tooltip),
);
await user.hover(screen.getByText(/^Visits/));
await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 1,000 potential bot visits`);
await user.hover(screen.getByText(/^Orphan visits/));
await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 15 potential bot visits`);
});
}); });

View file

@ -1,7 +1,8 @@
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import type { ShortUrl, ShortUrlMeta, ShortUrlVisitsSummary } from '../../../src/short-urls/data'; import type { ShlinkVisitsSummary } from '../../../src/api/types';
import type { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus'; import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus';
describe('<ShortUrlStatus />', () => { describe('<ShortUrlStatus />', () => {
@ -23,12 +24,12 @@ describe('<ShortUrlStatus />', () => {
], ],
[ [
Mock.of<ShortUrlMeta>({ maxVisits: 10 }), Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
Mock.of<ShortUrlVisitsSummary>({ total: 10 }), Mock.of<ShlinkVisitsSummary>({ total: 10 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.', 'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.',
], ],
[ [
Mock.of<ShortUrlMeta>({ maxVisits: 1 }), Mock.of<ShortUrlMeta>({ maxVisits: 1 }),
Mock.of<ShortUrlVisitsSummary>({ total: 1 }), Mock.of<ShlinkVisitsSummary>({ total: 1 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.', 'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.',
], ],
[{}, {}, 'This short URL can be visited normally.'], [{}, {}, 'This short URL can be visited normally.'],
@ -36,7 +37,7 @@ describe('<ShortUrlStatus />', () => {
[Mock.of<ShortUrlMeta>({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'], [Mock.of<ShortUrlMeta>({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
[ [
Mock.of<ShortUrlMeta>({ maxVisits: 10 }), Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
Mock.of<ShortUrlVisitsSummary>({ total: 1 }), Mock.of<ShlinkVisitsSummary>({ total: 1 }),
'This short URL can be visited normally.', 'This short URL can be visited normally.',
], ],
])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => { ])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => {