Merge pull request #775 from acelaya-forks/feature/shlink-updates

Feature/shlink updates
This commit is contained in:
Alejandro Celaya 2022-12-23 21:21:06 +01:00 committed by GitHub
commit 35fcd20123
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 113 additions and 237 deletions

View file

@ -18,7 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing* * *Nothing*
### Removed ### Removed
* [#736](https://github.com/shlinkio/shlink-web-client/issues/736) Removed support for cards mode in tags. Only tags table is supported now. * [#736](https://github.com/shlinkio/shlink-web-client/issues/736) Removed cards mode in tags. Only table mode is supported now.
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
### Fixed ### Fixed
* *Nothing* * *Nothing*

View file

@ -12,7 +12,6 @@ import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { isServerWithId, SelectedServer } from '../servers/data'; import { isServerWithId, SelectedServer } from '../servers/data';
import { supportsDomainRedirects } from '../utils/helpers/features';
import './AsideMenu.scss'; import './AsideMenu.scss';
export interface AsideMenuProps { export interface AsideMenuProps {
@ -40,7 +39,6 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
const hasId = isServerWithId(selectedServer); const hasId = isServerWithId(selectedServer);
const serverId = hasId ? selectedServer.id : ''; const serverId = hasId ? selectedServer.id : '';
const { pathname } = useLocation(); const { pathname } = useLocation();
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
const asideClass = classNames('aside-menu', { const asideClass = classNames('aside-menu', {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
}); });
@ -68,12 +66,10 @@ export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
<FontAwesomeIcon fixedWidth icon={tagsIcon} /> <FontAwesomeIcon fixedWidth icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span> <span className="aside-menu__item-text">Manage tags</span>
</AsideMenuItem> </AsideMenuItem>
{addManageDomainsLink && ( <AsideMenuItem to={buildPath('/manage-domains')}>
<AsideMenuItem to={buildPath('/manage-domains')}> <FontAwesomeIcon fixedWidth icon={domainsIcon} />
<FontAwesomeIcon fixedWidth icon={domainsIcon} /> <span className="aside-menu__item-text">Manage domains</span>
<span className="aside-menu__item-text">Manage domains</span> </AsideMenuItem>
</AsideMenuItem>
)}
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push"> <AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon fixedWidth icon={editIcon} /> <FontAwesomeIcon fixedWidth icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span> <span className="aside-menu__item-text">Edit this server</span>

View file

@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames'; import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSwipeable, useToggle } from '../utils/helpers/hooks'; import { useSwipeable, useToggle } from '../utils/helpers/hooks';
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features'; import { supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import { AsideMenuProps } from './AsideMenu'; import { AsideMenuProps } from './AsideMenu';
@ -47,7 +47,6 @@ export const MenuLayout = (
} }
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer); const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const addDomainVisitsRoute = supportsDomainVisits(selectedServer); const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
const swipeableProps = useSwipeable(showSidebar, hideSidebar); const swipeableProps = useSwipeable(showSidebar, hideSidebar);
@ -73,7 +72,7 @@ export const MenuLayout = (
<Route path="/orphan-visits/*" element={<OrphanVisits />} /> <Route path="/orphan-visits/*" element={<OrphanVisits />} />
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />} {addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
<Route path="/manage-tags" element={<TagsList />} /> <Route path="/manage-tags" element={<TagsList />} />
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />} <Route path="/manage-domains" element={<ManageDomains />} />
<Route <Route
path="*" path="*"
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>} element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}

View file

@ -4,7 +4,7 @@ import { Button, FormGroup, Input, Row } from 'reactstrap';
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda'; import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { DateTimeInput, DateTimeInputProps } from '../utils/dates/DateTimeInput'; import { DateTimeInput, DateTimeInputProps } from '../utils/dates/DateTimeInput';
import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features'; import { supportsForwardQuery } from '../utils/helpers/features';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils'; import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
import { Checkbox } from '../utils/Checkbox'; import { Checkbox } from '../utils/Checkbox';
@ -113,16 +113,14 @@ export const ShortUrlForm = (
</> </>
); );
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
const showForwardQueryControl = supportsForwardQuery(selectedServer); const showForwardQueryControl = supportsForwardQuery(selectedServer);
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
return ( return (
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}> <form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
{isBasicMode && basicComponents} {isBasicMode && basicComponents}
{!isBasicMode && ( {!isBasicMode && (
<> <>
<SimpleCard title="Basic options" className="mb-3"> <SimpleCard title="Main options" className="mb-3">
{basicComponents} {basicComponents}
</SimpleCard> </SimpleCard>
@ -190,30 +188,26 @@ export const ShortUrlForm = (
)} )}
</SimpleCard> </SimpleCard>
</div> </div>
{showBehaviorCard && ( <div className="col-sm-6 mb-3">
<div className="col-sm-6 mb-3"> <SimpleCard title="Configure behavior">
<SimpleCard title="Configure behavior"> <ShortUrlFormCheckboxGroup
{showCrawlableControl && ( infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
<ShortUrlFormCheckboxGroup checked={shortUrlData.crawlable}
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it." onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
checked={shortUrlData.crawlable} >
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })} Make it crawlable
> </ShortUrlFormCheckboxGroup>
Make it crawlable {showForwardQueryControl && (
</ShortUrlFormCheckboxGroup> <ShortUrlFormCheckboxGroup
)} infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
{showForwardQueryControl && ( checked={shortUrlData.forwardQuery}
<ShortUrlFormCheckboxGroup onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL." >
checked={shortUrlData.forwardQuery} Forward query params on redirect
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })} </ShortUrlFormCheckboxGroup>
> )}
Forward query params on redirect </SimpleCard>
</ShortUrlFormCheckboxGroup> </div>
)}
</SimpleCard>
</div>
)}
</Row> </Row>
</> </>
)} )}

View file

@ -8,7 +8,7 @@ import { SearchField } from '../utils/SearchField';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals'; import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals';
import { supportsAllTagsFiltering, supportsBotVisits } from '../utils/helpers/features'; import { supportsAllTagsFiltering } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { OrderDir } from '../utils/helpers/ordering'; import { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown'; import { OrderingDropdown } from '../utils/OrderingDropdown';
@ -47,7 +47,6 @@ export const ShortUrlsFilteringBar = (
); );
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const botsSupported = supportsBotVisits(selectedServer);
const toggleTagsMode = pipe( const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'), () => (tagsMode === 'any' ? 'all' : 'any'),
(mode) => toFirstPage({ tagsMode: mode }), (mode) => toFirstPage({ tagsMode: mode }),
@ -83,7 +82,6 @@ export const ShortUrlsFilteringBar = (
</div> </div>
<ShortUrlsFilterDropdown <ShortUrlsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0" className="ms-0 ms-md-2 mt-3 mt-md-0"
botsSupported={botsSupported}
selected={{ excludeBots: excludeBots ?? settings.visits?.excludeBots }} selected={{ excludeBots: excludeBots ?? settings.visits?.excludeBots }}
onChange={toFirstPage} onChange={toFirstPage}
/> />

View file

@ -6,8 +6,8 @@ import { ExternalLink } from 'react-external-link';
import { ShortUrlModalProps } from '../data'; import { ShortUrlModalProps } from '../data';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { supportsNonRestCors, supportsQrErrorCorrection } from '../../utils/helpers/features'; import { supportsNonRestCors } from '../../utils/helpers/features';
import { ImageDownloader } from '../../common/services/ImageDownloader'; import { ImageDownloader } from '../../common/services/ImageDownloader';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
@ -24,14 +24,10 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
const [margin, setMargin] = useState(0); const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png'); const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L'); const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const capabilities: QrCodeCapabilities = useMemo(() => ({
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
}), [selectedServer]);
const displayDownloadBtn = supportsNonRestCors(selectedServer); const displayDownloadBtn = supportsNonRestCors(selectedServer);
const willRenderThreeControls = !capabilities.errorCorrectionIsSupported;
const qrCodeUrl = useMemo( const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection, capabilities], [shortUrl, size, format, margin, errorCorrection],
); );
const totalSize = useMemo(() => size + margin, [size, margin]); const totalSize = useMemo(() => size + margin, [size, margin]);
const modalSize = useMemo(() => { const modalSize = useMemo(() => {
@ -49,7 +45,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Row> <Row>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}> <FormGroup className="d-grid col-md-4">
<label>Size: {size}px</label> <label>Size: {size}px</label>
<input <input
type="range" type="range"
@ -61,7 +57,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setSize(Number(e.target.value))} onChange={(e) => setSize(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}> <FormGroup className="d-grid col-md-4">
<label htmlFor="marginControl">Margin: {margin}px</label> <label htmlFor="marginControl">Margin: {margin}px</label>
<input <input
id="marginControl" id="marginControl"
@ -74,14 +70,12 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => (
onChange={(e) => setMargin(Number(e.target.value))} onChange={(e) => setMargin(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}> <FormGroup className="d-grid col-md-4">
<QrFormatDropdown format={format} setFormat={setFormat} /> <QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup> </FormGroup>
{capabilities.errorCorrectionIsSupported && ( <FormGroup className="col-md-6">
<FormGroup className="col-md-6"> <QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} /> </FormGroup>
</FormGroup>
)}
</Row> </Row>
<div className="text-center"> <div className="text-center">
<div className="mb-3"> <div className="mb-3">

View file

@ -7,27 +7,18 @@ interface ShortUrlsFilterDropdownProps {
onChange: (filters: ShortUrlsFilter) => void; onChange: (filters: ShortUrlsFilter) => void;
selected?: ShortUrlsFilter; selected?: ShortUrlsFilter;
className?: string; className?: string;
botsSupported: boolean;
} }
export const ShortUrlsFilterDropdown = ( export const ShortUrlsFilterDropdown = (
{ onChange, selected = {}, className, botsSupported }: ShortUrlsFilterDropdownProps, { onChange, selected = {}, className }: ShortUrlsFilterDropdownProps,
) => { ) => {
if (!botsSupported) {
return null;
}
const { excludeBots = false } = selected; const { excludeBots = false } = selected;
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
return ( return (
<DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}> <DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}>
{botsSupported && ( <DropdownItem header>Bots:</DropdownItem>
<> <DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude bots visits</DropdownItem>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude bots visits</DropdownItem>
</>
)}
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem disabled={!hasValue(selected)} onClick={() => onChange({ excludeBots: false })}> <DropdownItem disabled={!hasValue(selected)} onClick={() => onChange({ excludeBots: false })}>

View file

@ -4,10 +4,6 @@ import { SemVerPattern, versionMatch } from './version';
const serverMatchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: SelectedServer): boolean => const serverMatchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: SelectedServer): boolean =>
isReachableServer(selectedServer) && versionMatch(selectedServer.version, { minVersion }); isReachableServer(selectedServer) && versionMatch(selectedServer.version, { minVersion });
export const supportsBotVisits = serverMatchesMinVersion('2.7.0');
export const supportsCrawlableVisits = supportsBotVisits;
export const supportsQrErrorCorrection = serverMatchesMinVersion('2.8.0');
export const supportsDomainRedirects = supportsQrErrorCorrection;
export const supportsForwardQuery = serverMatchesMinVersion('2.9.0'); export const supportsForwardQuery = serverMatchesMinVersion('2.9.0');
export const supportsNonRestCors = supportsForwardQuery; export const supportsNonRestCors = supportsForwardQuery;
export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0'); export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0');

View file

@ -1,10 +1,6 @@
import { isEmpty } from 'ramda'; import { isEmpty } from 'ramda';
import { stringifyQuery } from './query'; import { stringifyQuery } from './query';
export interface QrCodeCapabilities {
errorCorrectionIsSupported: boolean;
}
export type QrCodeFormat = 'svg' | 'png'; export type QrCodeFormat = 'svg' | 'png';
export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H'; export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H';
@ -16,17 +12,11 @@ export interface QrCodeOptions {
errorCorrection: QrErrorCorrection; errorCorrection: QrErrorCorrection;
} }
export const buildQrCodeUrl = ( export const buildQrCodeUrl = (shortUrl: string, { margin, ...options }: QrCodeOptions): string => {
shortUrl: string,
{ size, format, margin, errorCorrection }: QrCodeOptions,
{ errorCorrectionIsSupported }: QrCodeCapabilities,
): string => {
const baseUrl = `${shortUrl}/qr-code`; const baseUrl = `${shortUrl}/qr-code`;
const query = stringifyQuery({ const query = stringifyQuery({
size, ...options,
format,
margin: margin > 0 ? margin : undefined, margin: margin > 0 ? margin : undefined,
errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined,
}); });
return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`; return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`;

View file

@ -22,7 +22,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure
domainVisits, domainVisits,
cancelGetDomainVisits, cancelGetDomainVisits,
settings, settings,
selectedServer,
}: DomainVisitsProps) => { }: DomainVisitsProps) => {
const goBack = useGoBack(); const goBack = useGoBack();
const { domain = '' } = useParams(); const { domain = '' } = useParams();
@ -38,7 +37,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure
visitsInfo={domainVisits} visitsInfo={domainVisits}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} /> <VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} />
</VisitsStats> </VisitsStats>

View file

@ -20,7 +20,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc
nonOrphanVisits, nonOrphanVisits,
cancelGetNonOrphanVisits, cancelGetNonOrphanVisits,
settings, settings,
selectedServer,
}: NonOrphanVisitsProps) => { }: NonOrphanVisitsProps) => {
const goBack = useGoBack(); const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
@ -34,7 +33,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc
visitsInfo={nonOrphanVisits} visitsInfo={nonOrphanVisits}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<VisitsHeader title="Non-orphan visits" goBack={goBack} visits={nonOrphanVisits.visits} /> <VisitsHeader title="Non-orphan visits" goBack={goBack} visits={nonOrphanVisits.visits} />
</VisitsStats> </VisitsStats>

View file

@ -21,7 +21,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure
orphanVisits, orphanVisits,
cancelGetOrphanVisits, cancelGetOrphanVisits,
settings, settings,
selectedServer,
}: OrphanVisitsProps) => { }: OrphanVisitsProps) => {
const goBack = useGoBack(); const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
@ -36,7 +35,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure
visitsInfo={orphanVisits} visitsInfo={orphanVisits}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
isOrphanVisits isOrphanVisits
> >
<VisitsHeader title="Orphan visits" goBack={goBack} visits={orphanVisits.visits} /> <VisitsHeader title="Orphan visits" goBack={goBack} visits={orphanVisits.visits} />

View file

@ -30,7 +30,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu
getShortUrlDetail, getShortUrlDetail,
cancelGetShortUrlVisits, cancelGetShortUrlVisits,
settings, settings,
selectedServer,
}: ShortUrlVisitsProps) => { }: ShortUrlVisitsProps) => {
const { shortCode = '' } = useParams<{ shortCode: string }>(); const { shortCode = '' } = useParams<{ shortCode: string }>();
const { search } = useLocation(); const { search } = useLocation();
@ -57,7 +56,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu
visitsInfo={shortUrlVisits} visitsInfo={shortUrlVisits}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} /> <ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats> </VisitsStats>

View file

@ -23,7 +23,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo
tagVisits, tagVisits,
cancelGetTagVisits, cancelGetTagVisits,
settings, settings,
selectedServer,
}: TagVisitsProps) => { }: TagVisitsProps) => {
const goBack = useGoBack(); const goBack = useGoBack();
const { tag = '' } = useParams(); const { tag = '' } = useParams();
@ -38,7 +37,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo
visitsInfo={tagVisits} visitsInfo={tagVisits}
settings={settings} settings={settings}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={selectedServer}
> >
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} /> <TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats> </VisitsStats>

View file

@ -11,8 +11,6 @@ import { Message } from '../utils/Message';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { NavPillItem, NavPills } from '../utils/NavPills'; import { NavPillItem, NavPills } from '../utils/NavPills';
import { ExportBtn } from '../utils/ExportBtn'; import { ExportBtn } from '../utils/ExportBtn';
@ -33,7 +31,6 @@ export type VisitsStatsProps = PropsWithChildren<{
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
visitsInfo: VisitsInfo; visitsInfo: VisitsInfo;
settings: Settings; settings: Settings;
selectedServer: SelectedServer;
cancelGetVisits: () => void; cancelGetVisits: () => void;
exportCsv: (visits: NormalizedVisit[]) => void; exportCsv: (visits: NormalizedVisit[]) => void;
isOrphanVisits?: boolean; isOrphanVisits?: boolean;
@ -63,7 +60,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
cancelGetVisits, cancelGetVisits,
settings, settings,
exportCsv, exportCsv,
selectedServer,
isOrphanVisits = false, isOrphanVisits = false,
}) => { }) => {
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo; const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
@ -82,7 +78,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
); );
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]); const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>(); const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
const botsSupported = supportsBotVisits(selectedServer);
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
const { search } = useLocation(); const { search } = useLocation();
@ -273,7 +268,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
selectedVisits={highlightedVisits} selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits} setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits} isOrphanVisits={isOrphanVisits}
selectedServer={selectedServer}
/> />
</div> </div>
)} )}
@ -306,7 +300,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
<VisitsFilterDropdown <VisitsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0" className="ms-0 ms-md-2 mt-3 mt-md-0"
isOrphanVisits={isOrphanVisits} isOrphanVisits={isOrphanVisits}
botsSupported={botsSupported}
selected={resolvedFilter} selected={resolvedFilter}
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })} onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
/> />

View file

@ -8,8 +8,6 @@ import { SimplePaginator } from '../common/SimplePaginator';
import { SearchField } from '../utils/SearchField'; import { SearchField } from '../utils/SearchField';
import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering'; import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { supportsBotVisits } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { Time } from '../utils/dates/Time'; import { Time } from '../utils/dates/Time';
import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { MediaMatcher } from '../utils/types'; import { MediaMatcher } from '../utils/types';
@ -22,7 +20,6 @@ export interface VisitsTableProps {
setSelectedVisits: (visits: NormalizedVisit[]) => void; setSelectedVisits: (visits: NormalizedVisit[]) => void;
matchMedia?: MediaMatcher; matchMedia?: MediaMatcher;
isOrphanVisits?: boolean; isOrphanVisits?: boolean;
selectedServer: SelectedServer;
} }
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot'; type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
@ -49,7 +46,6 @@ export const VisitsTable = ({
visits, visits,
selectedVisits = [], selectedVisits = [],
setSelectedVisits, setSelectedVisits,
selectedServer,
matchMedia = window.matchMedia, matchMedia = window.matchMedia,
isOrphanVisits = false, isOrphanVisits = false,
}: VisitsTableProps) => { }: VisitsTableProps) => {
@ -64,8 +60,7 @@ export const VisitsTable = ({
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const end = page * PAGE_SIZE; const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE; const start = end - PAGE_SIZE;
const supportsBots = supportsBotVisits(selectedServer); const fullSizeColSpan = 8 + Number(isOrphanVisits);
const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits);
const orderByColumn = (field: OrderableFields) => const orderByColumn = (field: OrderableFields) =>
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
@ -99,12 +94,10 @@ export const VisitsTable = ({
> >
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} /> <FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th> </th>
{supportsBots && ( <th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}> <FontAwesomeIcon icon={botIcon} />
<FontAwesomeIcon icon={botIcon} /> {renderOrderIcon('potentialBot')}
{renderOrderIcon('potentialBot')} </th>
</th>
)}
<th className={headerCellsClass} onClick={orderByColumn('date')}> <th className={headerCellsClass} onClick={orderByColumn('date')}>
Date Date
{renderOrderIcon('date')} {renderOrderIcon('date')}
@ -165,18 +158,16 @@ export const VisitsTable = ({
<td className="text-center"> <td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />} {isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td> </td>
{supportsBots && ( <td className="text-center">
<td className="text-center"> {visit.potentialBot && (
{visit.potentialBot && ( <>
<> <FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} /> <UncontrolledTooltip placement="right" target={`botIcon${index}`}>
<UncontrolledTooltip placement="right" target={`botIcon${index}`}> Potentially a visit from a bot or crawler
Potentially a visit from a bot or crawler </UncontrolledTooltip>
</UncontrolledTooltip> </>
</> )}
)} </td>
</td>
)}
<td><Time date={visit.date} /></td> <td><Time date={visit.date} /></td>
<td>{visit.country}</td> <td>{visit.country}</td>
<td>{visit.city}</td> <td>{visit.city}</td>

View file

@ -8,16 +8,11 @@ interface VisitsFilterDropdownProps {
selected?: VisitsFilter; selected?: VisitsFilter;
className?: string; className?: string;
isOrphanVisits: boolean; isOrphanVisits: boolean;
botsSupported: boolean;
} }
export const VisitsFilterDropdown = ( export const VisitsFilterDropdown = (
{ onChange, selected = {}, className, isOrphanVisits, botsSupported }: VisitsFilterDropdownProps, { onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps,
) => { ) => {
if (!botsSupported && !isOrphanVisits) {
return null;
}
const { orphanVisitsType, excludeBots = false } = selected; const { orphanVisitsType, excludeBots = false } = selected;
const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({ const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({
active: orphanVisitsType === type, active: orphanVisitsType === type,
@ -27,17 +22,12 @@ export const VisitsFilterDropdown = (
return ( return (
<DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}> <DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}>
{botsSupported && ( <DropdownItem header>Bots:</DropdownItem>
<> <DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
</>
)}
{botsSupported && isOrphanVisits && <DropdownItem divider />}
{isOrphanVisits && ( {isOrphanVisits && (
<> <>
<DropdownItem divider />
<DropdownItem header>Orphan visits type:</DropdownItem> <DropdownItem header>Orphan visits type:</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem> <DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem> <DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>

View file

@ -82,7 +82,7 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
); );
export const normalizeVisits = map((visit: Visit): NormalizedVisit => { export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
const { userAgent, date, referer, visitLocation, potentialBot = false } = visit; const { userAgent, date, referer, visitLocation, potentialBot } = visit;
const common = { const common = {
date, date,
potentialBot, potentialBot,

View file

@ -22,31 +22,31 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter'); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter');
bottle.decorator('ShortUrlVisits', connect( bottle.decorator('ShortUrlVisits', connect(
['shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer'], ['shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings'],
['getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo'], ['getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo'],
)); ));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter'); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter');
bottle.decorator('TagVisits', connect( bottle.decorator('TagVisits', connect(
['tagVisits', 'mercureInfo', 'settings', 'selectedServer'], ['tagVisits', 'mercureInfo', 'settings'],
['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'], ['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'],
)); ));
bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter'); bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter');
bottle.decorator('DomainVisits', connect( bottle.decorator('DomainVisits', connect(
['domainVisits', 'mercureInfo', 'settings', 'selectedServer'], ['domainVisits', 'mercureInfo', 'settings'],
['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'], ['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'],
)); ));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter'); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter');
bottle.decorator('OrphanVisits', connect( bottle.decorator('OrphanVisits', connect(
['orphanVisits', 'mercureInfo', 'settings', 'selectedServer'], ['orphanVisits', 'mercureInfo', 'settings'],
['getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo'], ['getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
)); ));
bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter'); bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter');
bottle.decorator('NonOrphanVisits', connect( bottle.decorator('NonOrphanVisits', connect(
['nonOrphanVisits', 'mercureInfo', 'settings', 'selectedServer'], ['nonOrphanVisits', 'mercureInfo', 'settings'],
['getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo'], ['getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
)); ));

View file

@ -1,7 +1,5 @@
import { SelectedServer } from '../../servers/data';
import { Settings } from '../../settings/reducers/settings'; import { Settings } from '../../settings/reducers/settings';
export interface CommonVisitsProps { export interface CommonVisitsProps {
selectedServer: SelectedServer;
settings: Settings; settings: Settings;
} }

View file

@ -19,7 +19,7 @@ export interface RegularVisit {
date: string; date: string;
userAgent: string; userAgent: string;
visitLocation: VisitLocation | null; visitLocation: VisitLocation | null;
potentialBot?: boolean; // Optional only when using Shlink older than v2.7 potentialBot: boolean;
} }
export interface OrphanVisit extends RegularVisit { export interface OrphanVisit extends RegularVisit {

View file

@ -3,26 +3,22 @@ import { Mock } from 'ts-mockery';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { AsideMenu as createAsideMenu } from '../../src/common/AsideMenu'; import { AsideMenu as createAsideMenu } from '../../src/common/AsideMenu';
import { ReachableServer } from '../../src/servers/data'; import { ReachableServer } from '../../src/servers/data';
import { SemVer } from '../../src/utils/helpers/version';
describe('<AsideMenu />', () => { describe('<AsideMenu />', () => {
const AsideMenu = createAsideMenu(() => <>DeleteServerButton</>); const AsideMenu = createAsideMenu(() => <>DeleteServerButton</>);
const setUp = (version: SemVer, id: string | false = 'abc123') => render( const setUp = (id: string | false = 'abc123') => render(
<MemoryRouter> <MemoryRouter>
<AsideMenu selectedServer={Mock.of<ReachableServer>({ id: id || undefined, version })} /> <AsideMenu selectedServer={Mock.of<ReachableServer>({ id: id || undefined, version: '2.8.0' })} />
</MemoryRouter>, </MemoryRouter>,
); );
it.each([ it('contains links to different sections', () => {
['2.7.0' as SemVer, 5], setUp();
['2.8.0' as SemVer, 6],
])('contains links to different sections', (version, expectedAmountOfLinks) => {
setUp(version);
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');
expect.assertions(links.length + 1); expect.assertions(links.length + 1);
expect(links).toHaveLength(expectedAmountOfLinks); expect(links).toHaveLength(6);
links.forEach((link) => expect(link.getAttribute('href')).toContain('abc123')); links.forEach((link) => expect(link.getAttribute('href')).toContain('abc123'));
}); });
@ -30,7 +26,7 @@ describe('<AsideMenu />', () => {
['abc', true], ['abc', true],
[false, false], [false, false],
])('contains a button to delete server if appropriate', (id, shouldHaveBtn) => { ])('contains a button to delete server if appropriate', (id, shouldHaveBtn) => {
setUp('2.8.0', id as string | false); setUp(id as string | false);
if (shouldHaveBtn) { if (shouldHaveBtn) {
expect(screen.getByText('DeleteServerButton')).toBeInTheDocument(); expect(screen.getByText('DeleteServerButton')).toBeInTheDocument();

View file

@ -77,7 +77,6 @@ describe('<MenuLayout />', () => {
['3.1.0' as SemVer, '/domain/domain.com/visits/foo', 'DomainVisits'], ['3.1.0' as SemVer, '/domain/domain.com/visits/foo', 'DomainVisits'],
['2.10.0' as SemVer, '/non-orphan-visits/foo', 'Oops! We could not find requested route.'], ['2.10.0' as SemVer, '/non-orphan-visits/foo', 'Oops! We could not find requested route.'],
['3.0.0' as SemVer, '/non-orphan-visits/foo', 'NonOrphanVisits'], ['3.0.0' as SemVer, '/non-orphan-visits/foo', 'NonOrphanVisits'],
['2.7.0' as SemVer, '/manage-domains', 'Oops! We could not find requested route.'],
['2.8.0' as SemVer, '/manage-domains', 'ManageDomains'], ['2.8.0' as SemVer, '/manage-domains', 'ManageDomains'],
])( ])(
'renders expected component based on location and server version', 'renders expected component based on location and server version',

View file

@ -64,7 +64,7 @@ describe('<ShortUrlForm />', () => {
}); });
it.each([ it.each([
['create' as Mode, 4], ['create' as Mode, 5],
['create-basic' as Mode, 0], ['create-basic' as Mode, 0],
])( ])(
'renders expected amount of cards based on server capabilities and mode', 'renders expected amount of cards based on server capabilities and mode',

View file

@ -11,7 +11,7 @@ describe('<QrCodeModal />', () => {
const saveImage = jest.fn().mockReturnValue(Promise.resolve()); const saveImage = jest.fn().mockReturnValue(Promise.resolve());
const QrCodeModal = createQrCodeModal(Mock.of<ImageDownloader>({ saveImage })); const QrCodeModal = createQrCodeModal(Mock.of<ImageDownloader>({ saveImage }));
const shortUrl = 'https://doma.in/abc123'; const shortUrl = 'https://doma.in/abc123';
const setUp = (version: SemVer = '2.6.0') => renderWithEvents( const setUp = (version: SemVer = '2.8.0') => renderWithEvents(
<QrCodeModal <QrCodeModal
isOpen isOpen
shortUrl={Mock.of<ShortUrl>({ shortUrl })} shortUrl={Mock.of<ShortUrl>({ shortUrl })}
@ -32,12 +32,10 @@ describe('<QrCodeModal />', () => {
}); });
it.each([ it.each([
['2.5.0' as SemVer, 0, '/qr-code?size=300&format=png'], [10, '/qr-code?size=300&format=png&errorCorrection=L&margin=10'],
['2.6.0' as SemVer, 0, '/qr-code?size=300&format=png'], [0, '/qr-code?size=300&format=png&errorCorrection=L'],
['2.6.0' as SemVer, 10, '/qr-code?size=300&format=png&margin=10'], ])('displays an image with the QR code of the URL', async (margin, expectedUrl) => {
['2.8.0' as SemVer, 0, '/qr-code?size=300&format=png&errorCorrection=L'], const { container } = setUp();
])('displays an image with the QR code of the URL', async (version, margin, expectedUrl) => {
const { container } = setUp(version);
const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1); const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1);
if (marginControl) { if (marginControl) {
@ -69,16 +67,13 @@ describe('<QrCodeModal />', () => {
modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`); modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`);
}); });
it.each([ it('shows expected components based on server version', () => {
['2.6.0' as SemVer, 1, 'col-md-4'], const { container } = setUp();
['2.8.0' as SemVer, 2, 'col-md-6'],
])('shows expected components based on server version', (version, expectedAmountOfDropdowns, expectedRangeClass) => {
const { container } = setUp(version);
const dropdowns = screen.getAllByRole('button'); const dropdowns = screen.getAllByRole('button');
const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0); const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0);
expect(dropdowns).toHaveLength(expectedAmountOfDropdowns + 1); // Add one because of the close button expect(dropdowns).toHaveLength(2 + 1); // Add one because of the close button
expect(firstCol).toHaveClass(expectedRangeClass); expect(firstCol).toHaveClass('col-md-4');
}); });
it('saves the QR code image when clicking the Download button', async () => { it('saves the QR code image when clicking the Download button', async () => {

View file

@ -6,41 +6,30 @@ describe('qrCodes', () => {
[ [
'bar.io', 'bar.io',
{ size: 870, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, { size: 870, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=870&format=svg&errorCorrection=L',
'bar.io/qr-code?size=870&format=svg',
],
[
'bar.io',
{ size: 200, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ errorCorrectionIsSupported: false },
'bar.io/qr-code?size=200&format=png',
], ],
[ [
'bar.io', 'bar.io',
{ size: 200, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, { size: 200, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=200&format=svg&errorCorrection=L',
'bar.io/qr-code?size=200&format=svg',
], ],
[ [
'shlink.io', 'shlink.io',
{ size: 456, format: 'png' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection }, { size: 456, format: 'png' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection },
{ errorCorrectionIsSupported: false }, 'shlink.io/qr-code?size=456&format=png&errorCorrection=L&margin=10',
'shlink.io/qr-code?size=456&format=png&margin=10',
], ],
[ [
'shlink.io', 'shlink.io',
{ size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'H' as QrErrorCorrection }, { size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'H' as QrErrorCorrection },
{ errorCorrectionIsSupported: true },
'shlink.io/qr-code?size=456&format=png&errorCorrection=H', 'shlink.io/qr-code?size=456&format=png&errorCorrection=H',
], ],
[ [
'shlink.io', 'shlink.io',
{ size: 999, format: 'png' as QrCodeFormat, margin: 20, errorCorrection: 'Q' as QrErrorCorrection }, { size: 999, format: 'png' as QrCodeFormat, margin: 20, errorCorrection: 'Q' as QrErrorCorrection },
{ errorCorrectionIsSupported: true }, 'shlink.io/qr-code?size=999&format=png&errorCorrection=Q&margin=20',
'shlink.io/qr-code?size=999&format=png&margin=20&errorCorrection=Q',
], ],
])('builds expected URL based in params', (shortUrl, options, capabilities, expectedUrl) => { ])('builds expected URL based in params', (shortUrl, options, expectedUrl) => {
expect(buildQrCodeUrl(shortUrl, options, capabilities)).toEqual(expectedUrl); expect(buildQrCodeUrl(shortUrl, options)).toEqual(expectedUrl);
}); });
}); });
}); });

View file

@ -7,7 +7,6 @@ import { ReportExporter } from '../../src/common/services/ReportExporter';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { DomainVisits } from '../../src/visits/reducers/domainVisits'; import { DomainVisits } from '../../src/visits/reducers/domainVisits';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { SelectedServer } from '../../src/servers/data';
import { Visit } from '../../src/visits/types'; import { Visit } from '../../src/visits/types';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
@ -30,7 +29,6 @@ describe('<DomainVisits />', () => {
cancelGetDomainVisits={cancelGetDomainVisits} cancelGetDomainVisits={cancelGetDomainVisits}
domainVisits={domainVisits} domainVisits={domainVisits}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
selectedServer={Mock.all<SelectedServer>()}
/> />
</MemoryRouter>, </MemoryRouter>,
); );

View file

@ -7,7 +7,6 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { Visit } from '../../src/visits/types'; import { Visit } from '../../src/visits/types';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { ReportExporter } from '../../src/common/services/ReportExporter'; import { ReportExporter } from '../../src/common/services/ReportExporter';
import { SelectedServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
import { VisitsInfo } from '../../src/visits/reducers/types'; import { VisitsInfo } from '../../src/visits/reducers/types';
@ -25,7 +24,6 @@ describe('<NonOrphanVisits />', () => {
cancelGetNonOrphanVisits={cancelGetNonOrphanVisits} cancelGetNonOrphanVisits={cancelGetNonOrphanVisits}
nonOrphanVisits={nonOrphanVisits} nonOrphanVisits={nonOrphanVisits}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
selectedServer={Mock.all<SelectedServer>()}
/> />
</MemoryRouter>, </MemoryRouter>,
); );

View file

@ -7,7 +7,6 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { Visit } from '../../src/visits/types'; import { Visit } from '../../src/visits/types';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { ReportExporter } from '../../src/common/services/ReportExporter'; import { ReportExporter } from '../../src/common/services/ReportExporter';
import { SelectedServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
import { VisitsInfo } from '../../src/visits/reducers/types'; import { VisitsInfo } from '../../src/visits/reducers/types';
@ -24,7 +23,6 @@ describe('<OrphanVisits />', () => {
orphanVisits={orphanVisits} orphanVisits={orphanVisits}
cancelGetOrphanVisits={jest.fn()} cancelGetOrphanVisits={jest.fn()}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
selectedServer={Mock.all<SelectedServer>()}
/> />
</MemoryRouter>, </MemoryRouter>,
); );

View file

@ -5,7 +5,6 @@ import { createMemoryHistory } from 'history';
import { VisitsStats } from '../../src/visits/VisitsStats'; import { VisitsStats } from '../../src/visits/VisitsStats';
import { Visit } from '../../src/visits/types'; import { Visit } from '../../src/visits/types';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { ReachableServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
import { rangeOf } from '../../src/utils/utils'; import { rangeOf } from '../../src/utils/utils';
import { VisitsInfo } from '../../src/visits/reducers/types'; import { VisitsInfo } from '../../src/visits/reducers/types';
@ -28,7 +27,6 @@ describe('<VisitsStats />', () => {
cancelGetVisits={() => {}} cancelGetVisits={() => {}}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
exportCsv={exportCsv} exportCsv={exportCsv}
selectedServer={Mock.of<ReachableServer>({ version: '3.0.0' })}
/> />
</Router>, </Router>,
), ),

View file

@ -3,8 +3,6 @@ import { Mock } from 'ts-mockery';
import { VisitsTable, VisitsTableProps } from '../../src/visits/VisitsTable'; import { VisitsTable, VisitsTableProps } from '../../src/visits/VisitsTable';
import { rangeOf } from '../../src/utils/utils'; import { rangeOf } from '../../src/utils/utils';
import { NormalizedVisit } from '../../src/visits/types'; import { NormalizedVisit } from '../../src/visits/types';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { SemVer } from '../../src/utils/helpers/version';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<VisitsTable />', () => { describe('<VisitsTable />', () => {
@ -13,7 +11,6 @@ describe('<VisitsTable />', () => {
const setUpFactory = (props: Partial<VisitsTableProps> = {}) => renderWithEvents( const setUpFactory = (props: Partial<VisitsTableProps> = {}) => renderWithEvents(
<VisitsTable <VisitsTable
visits={[]} visits={[]}
selectedServer={Mock.all<SelectedServer>()}
{...props} {...props}
matchMedia={matchMedia} matchMedia={matchMedia}
setSelectedVisits={setSelectedVisits} setSelectedVisits={setSelectedVisits}
@ -22,15 +19,8 @@ describe('<VisitsTable />', () => {
const setUp = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => setUpFactory( const setUp = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => setUpFactory(
{ visits, selectedVisits }, { visits, selectedVisits },
); );
const setUpForOrphanVisits = (isOrphanVisits: boolean, version: SemVer) => setUpFactory({ const setUpForOrphanVisits = (isOrphanVisits: boolean) => setUpFactory({ isOrphanVisits });
isOrphanVisits,
selectedServer: Mock.of<ReachableServer>({ printableVersion: version, version }),
});
const setUpForServerVersion = (version: SemVer) => setUpFactory({
selectedServer: Mock.of<ReachableServer>({ printableVersion: version, version }),
});
const setUpWithBots = () => setUpFactory({ const setUpWithBots = () => setUpFactory({
selectedServer: Mock.of<ReachableServer>({ printableVersion: '2.7.0', version: '2.7.0' }),
visits: [ visits: [
Mock.of<NormalizedVisit>({ potentialBot: false, date: '2022-05-05' }), Mock.of<NormalizedVisit>({ potentialBot: false, date: '2022-05-05' }),
Mock.of<NormalizedVisit>({ potentialBot: true, date: '2022-05-05' }), Mock.of<NormalizedVisit>({ potentialBot: true, date: '2022-05-05' }),
@ -39,12 +29,9 @@ describe('<VisitsTable />', () => {
afterEach(jest.resetAllMocks); afterEach(jest.resetAllMocks);
it.each([ it('renders expected amount of columns', () => {
['2.6.0' as SemVer, 6], setUp([], []);
['2.7.0' as SemVer, 7], expect(screen.getAllByRole('columnheader')).toHaveLength(8);
])('renders expected amount of columns', (version, expectedColumns) => {
setUpForServerVersion(version);
expect(screen.getAllByRole('columnheader')).toHaveLength(expectedColumns + 1);
}); });
it('shows warning when no visits are found', () => { it('shows warning when no visits are found', () => {
@ -104,17 +91,17 @@ describe('<VisitsTable />', () => {
referer: `${index}`, referer: `${index}`,
country: `Country_${index}`, country: `Country_${index}`,
}))); })));
const getFirstColumnValue = () => screen.getAllByRole('row')[2]?.querySelectorAll('td')[2]?.textContent; const getFirstColumnValue = () => screen.getAllByRole('row')[2]?.querySelectorAll('td')[3]?.textContent;
const clickColumn = async (index: number) => user.click(screen.getAllByRole('columnheader')[index]); const clickColumn = async (index: number) => user.click(screen.getAllByRole('columnheader')[index]);
expect(getFirstColumnValue()).toContain('Country_1'); expect(getFirstColumnValue()).toContain('Country_1');
await clickColumn(1); // Date column ASC await clickColumn(2); // Date column ASC
expect(getFirstColumnValue()).toContain('Country_9'); expect(getFirstColumnValue()).toContain('Country_9');
await clickColumn(6); // Referer column - ASC await clickColumn(7); // Referer column - ASC
expect(getFirstColumnValue()).toContain('Country_1'); expect(getFirstColumnValue()).toContain('Country_1');
await clickColumn(6); // Referer column - DESC await clickColumn(7); // Referer column - DESC
expect(getFirstColumnValue()).toContain('Country_9'); expect(getFirstColumnValue()).toContain('Country_9');
await clickColumn(6); // Referer column - reset await clickColumn(7); // Referer column - reset
expect(getFirstColumnValue()).toContain('Country_1'); expect(getFirstColumnValue()).toContain('Country_1');
}); });
@ -139,12 +126,10 @@ describe('<VisitsTable />', () => {
}); });
it.each([ it.each([
[true, '2.6.0' as SemVer, 8], [true, 9],
[false, '2.6.0' as SemVer, 7], [false, 8],
[true, '2.7.0' as SemVer, 9], ])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => {
[false, '2.7.0' as SemVer, 8], setUpForOrphanVisits(isOrphanVisits);
])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, version, expectedCols) => {
setUpForOrphanVisits(isOrphanVisits, version);
expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols); expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols);
}); });

View file

@ -5,10 +5,9 @@ import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<VisitsFilterDropdown />', () => { describe('<VisitsFilterDropdown />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
const setUp = (selected: VisitsFilter = {}, isOrphanVisits = true, botsSupported = true) => renderWithEvents( const setUp = (selected: VisitsFilter = {}, isOrphanVisits = true) => renderWithEvents(
<VisitsFilterDropdown <VisitsFilterDropdown
isOrphanVisits={isOrphanVisits} isOrphanVisits={isOrphanVisits}
botsSupported={botsSupported}
selected={selected} selected={selected}
onChange={onChange} onChange={onChange}
/>, />,
@ -69,9 +68,4 @@ describe('<VisitsFilterDropdown />', () => {
await user.click(screen.getAllByRole('menuitem')[index]); await user.click(screen.getAllByRole('menuitem')[index]);
expect(onChange).toHaveBeenCalledWith(expectedSelection); expect(onChange).toHaveBeenCalledWith(expectedSelection);
}); });
it('does not render the component when neither orphan visits or bots filtering will be displayed', () => {
const { container } = setUp({}, false, false);
expect(container.firstChild).toBeNull();
});
}); });

View file

@ -7,6 +7,7 @@ describe('VisitsParser', () => {
Mock.of<Visit>({ Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', userAgent: 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
referer: 'https://google.com', referer: 'https://google.com',
potentialBot: false,
visitLocation: { visitLocation: {
countryName: 'Spain', countryName: 'Spain',
cityName: 'Zaragoza', cityName: 'Zaragoza',
@ -17,6 +18,7 @@ describe('VisitsParser', () => {
Mock.of<Visit>({ Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
referer: 'https://google.com', referer: 'https://google.com',
potentialBot: false,
visitLocation: { visitLocation: {
countryName: 'United States', countryName: 'United States',
cityName: 'New York', cityName: 'New York',
@ -26,6 +28,7 @@ describe('VisitsParser', () => {
}), }),
Mock.of<Visit>({ Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
potentialBot: false,
visitLocation: { visitLocation: {
countryName: 'Spain', countryName: 'Spain',
cityName: '', cityName: '',
@ -34,6 +37,7 @@ describe('VisitsParser', () => {
Mock.of<Visit>({ Mock.of<Visit>({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
referer: 'https://m.facebook.com', referer: 'https://m.facebook.com',
potentialBot: false,
visitLocation: { visitLocation: {
countryName: 'Spain', countryName: 'Spain',
cityName: 'Zaragoza', cityName: 'Zaragoza',
@ -52,6 +56,7 @@ describe('VisitsParser', () => {
visitedUrl: 'foo', visitedUrl: 'foo',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
referer: 'https://google.com', referer: 'https://google.com',
potentialBot: false,
visitLocation: { visitLocation: {
countryName: 'United States', countryName: 'United States',
cityName: 'New York', cityName: 'New York',