Merge pull request #629 from acelaya-forks/feature/shlink-2.6

Feature/shlink 2.6
This commit is contained in:
Alejandro Celaya
2022-05-01 11:07:42 +02:00
committed by GitHub
17 changed files with 100 additions and 234 deletions

View File

@@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing* * *Nothing*
### Removed ### Removed
* *Nothing* * [#623](https://github.com/shlinkio/shlink-web-client/pull/623) Dropped support for Shlink older than 2.6.0.
### Fixed ### Fixed
* *Nothing* * *Nothing*

View File

@@ -80,17 +80,6 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {}); .then(() => {});
/**
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
*/
public readonly updateShortUrlTags = async (
shortCode: string,
domain: OptionalString,
tags: string[],
): Promise<string[]> =>
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then(({ data }) => data.tags);
public readonly updateShortUrl = async ( public readonly updateShortUrl = async (
shortCode: string, shortCode: string,
domain: OptionalString, domain: OptionalString,

View File

@@ -5,12 +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 { import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
supportsDomainRedirects,
supportsDomainVisits,
supportsNonOrphanVisits,
supportsOrphanVisits,
} 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';
@@ -51,7 +46,6 @@ const MenuLayout = (
return <ServerError />; return <ServerError />;
} }
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer); const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
const addManageDomainsRoute = supportsDomainRedirects(selectedServer); const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
const addDomainVisitsRoute = supportsDomainVisits(selectedServer); const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
@@ -76,7 +70,7 @@ const MenuLayout = (
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} /> <Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
<Route path="/tag/:tag/visits/*" element={<TagVisits />} /> <Route path="/tag/:tag/visits/*" element={<TagVisits />} />
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />} {addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
{addOrphanVisitsRoute && <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 />} />} {addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}

View File

@@ -10,7 +10,7 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { ShlinkShortUrlsListParams } from '../api/types'; import { ShlinkShortUrlsListParams } from '../api/types';
import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features'; import { supportsNonOrphanVisits } from '../utils/helpers/features';
import { getServerId, SelectedServer } from './data'; import { getServerId, SelectedServer } from './data';
import { HighlightCard } from './helpers/HighlightCard'; import { HighlightCard } from './helpers/HighlightCard';
import { ForServerVersionProps } from './helpers/ForServerVersion'; import { ForServerVersionProps } from './helpers/ForServerVersion';
@@ -42,7 +42,6 @@ export const Overview = (
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview; const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const linkToOrphanVisits = supportsOrphanVisits(selectedServer);
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer); const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -61,7 +60,7 @@ export const Overview = (
</HighlightCard> </HighlightCard>
</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={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}> <HighlightCard title="Orphan visits" link={`/server/${serverId}/orphan-visits`}>
<ForServerVersion minVersion="2.6.0"> <ForServerVersion minVersion="2.6.0">
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)} {loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
</ForServerVersion> </ForServerVersion>

View File

@@ -2,10 +2,9 @@ import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/types/lib/Input'; import { InputType } from 'reactstrap/types/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap'; 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 classNames from 'classnames';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import DateInput, { DateInputProps } from '../utils/DateInput'; import DateInput, { DateInputProps } from '../utils/DateInput';
import { supportsCrawlableVisits, supportsForwardQuery, supportsShortUrlTitle } from '../utils/helpers/features'; import { supportsCrawlableVisits, 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';
@@ -33,7 +32,6 @@ export interface ShortUrlFormProps {
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date); const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
const dynamicColClasses = (flag: boolean) => ({ 'col-sm-6': flag, 'col-sm-12': !flag });
export const ShortUrlForm = ( export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
@@ -115,13 +113,9 @@ export const ShortUrlForm = (
</> </>
); );
const supportsTitle = supportsShortUrlTitle(selectedServer);
const showCustomizeCard = supportsTitle || !isEdit;
const limitAccessCardClasses = classNames('mb-3', dynamicColClasses(showCustomizeCard));
const showCrawlableControl = supportsCrawlableVisits(selectedServer); const showCrawlableControl = supportsCrawlableVisits(selectedServer);
const showForwardQueryControl = supportsForwardQuery(selectedServer); const showForwardQueryControl = supportsForwardQuery(selectedServer);
const showBehaviorCard = showCrawlableControl || showForwardQueryControl; const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
const extraChecksCardClasses = classNames('mb-3', dynamicColClasses(showBehaviorCard));
return ( return (
<form className="short-url-form" onSubmit={submit}> <form className="short-url-form" onSubmit={submit}>
@@ -133,36 +127,34 @@ export const ShortUrlForm = (
</SimpleCard> </SimpleCard>
<Row> <Row>
{showCustomizeCard && ( <div className="col-sm-6 mb-3">
<div className="col-sm-6 mb-3"> <SimpleCard title="Customize the short URL">
<SimpleCard title="Customize the short URL"> {renderOptionalInput('title', 'Title')}
{supportsTitle && renderOptionalInput('title', 'Title')} {!isEdit && (
{!isEdit && ( <>
<> <Row>
<Row> <div className="col-lg-6">
<div className="col-lg-6"> {renderOptionalInput('customSlug', 'Custom slug', 'text', {
{renderOptionalInput('customSlug', 'Custom slug', 'text', { disabled: hasValue(shortUrlData.shortCodeLength),
disabled: hasValue(shortUrlData.shortCodeLength), })}
})} </div>
</div> <div className="col-lg-6">
<div className="col-lg-6"> {renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', { min: 4,
min: 4, disabled: hasValue(shortUrlData.customSlug),
disabled: hasValue(shortUrlData.customSlug), })}
})} </div>
</div> </Row>
</Row> <DomainSelector
<DomainSelector value={shortUrlData.domain}
value={shortUrlData.domain} onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })} />
/> </>
</> )}
)} </SimpleCard>
</SimpleCard> </div>
</div>
)}
<div className={limitAccessCardClasses}> <div className="col-sm-6 mb-3">
<SimpleCard title="Limit access to the short URL"> <SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
<div className="mb-3"> <div className="mb-3">
@@ -174,7 +166,7 @@ export const ShortUrlForm = (
</Row> </Row>
<Row> <Row>
<div className={extraChecksCardClasses}> <div className="col-sm-6 mb-3">
<SimpleCard title="Extra checks"> <SimpleCard title="Extra checks">
<ShortUrlFormCheckboxGroup <ShortUrlFormCheckboxGroup
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible." infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."

View File

@@ -2,7 +2,6 @@ import { FC, ReactNode } from 'react';
import { isEmpty } from 'ramda'; import { isEmpty } from 'ramda';
import classNames from 'classnames'; import classNames from 'classnames';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { supportsShortUrlTitle } from '../utils/helpers/features';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow'; import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
import { ShortUrlsOrderableFields } from './data'; import { ShortUrlsOrderableFields } from './data';
@@ -29,7 +28,6 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn }); const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses); const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
const tableClasses = classNames('table table-hover responsive-table', className); const tableClasses = classNames('table table-hover responsive-table', className);
const supportsTitle = supportsShortUrlTitle(selectedServer);
const renderShortUrls = () => { const renderShortUrls = () => {
if (error) { if (error) {
@@ -70,21 +68,15 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
Short URL {renderOrderIcon?.('shortCode')} Short URL {renderOrderIcon?.('shortCode')}
</th> </th>
{!supportsTitle ? ( <th className="short-urls-table__header-cell">
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}> <span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
Long URL {renderOrderIcon?.('longUrl')} Title {renderOrderIcon?.('title')}
</th> </span>
) : ( &nbsp;&nbsp;/&nbsp;&nbsp;
<th className="short-urls-table__header-cell"> <span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}> <span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
Title {renderOrderIcon?.('title')} </span>
</span> </th>
&nbsp;&nbsp;/&nbsp;&nbsp;
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
</span>
</th>
)}
<th className="short-urls-table__header-cell">Tags</th> <th className="short-urls-table__header-cell">Tags</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}> <th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span> <span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>

View File

@@ -7,11 +7,7 @@ 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, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
import { import { supportsQrErrorCorrection } from '../../utils/helpers/features';
supportsQrCodeSizeInQuery,
supportsQrCodeMargin,
supportsQrErrorCorrection,
} from '../../utils/helpers/features';
import { ImageDownloader } from '../../common/services/ImageDownloader'; import { ImageDownloader } from '../../common/services/ImageDownloader';
import { ForServerVersionProps } from '../../servers/helpers/ForServerVersion'; import { ForServerVersionProps } from '../../servers/helpers/ForServerVersion';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
@@ -30,11 +26,9 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<ForS
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(() => ({ const capabilities: QrCodeCapabilities = useMemo(() => ({
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
marginIsSupported: supportsQrCodeMargin(selectedServer),
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer), errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
}), [selectedServer]); }), [selectedServer]);
const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported; const willRenderThreeControls = !capabilities.errorCorrectionIsSupported;
const qrCodeUrl = useMemo( const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
[shortUrl, size, format, margin, errorCorrection, capabilities], [shortUrl, size, format, margin, errorCorrection, capabilities],
@@ -67,21 +61,19 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<ForS
onChange={(e) => setSize(Number(e.target.value))} onChange={(e) => setSize(Number(e.target.value))}
/> />
</FormGroup> </FormGroup>
{capabilities.marginIsSupported && ( <FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}>
<FormGroup className={`d-grid ${willRenderThreeControls ? 'col-md-4' : 'col-md-6'}`}> <label htmlFor="marginControl">Margin: {margin}px</label>
<label htmlFor="marginControl">Margin: {margin}px</label> <input
<input id="marginControl"
id="marginControl" type="range"
type="range" className="form-control-range"
className="form-control-range" value={margin}
value={margin} step={1}
step={1} min={0}
min={0} max={100}
max={100} 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={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
<QrFormatDropdown format={format} setFormat={setFormat} /> <QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup> </FormGroup>

View File

@@ -6,7 +6,6 @@ import { EditShortUrlData, ShortUrl } from '../data';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types'; import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { supportsTagsInPatch } from '../../utils/helpers/features';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
@@ -42,15 +41,10 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_SHORT_URL_START }); dispatch({ type: EDIT_SHORT_URL_START });
const { selectedServer } = getState(); const { updateShortUrl } = buildShlinkApiClient(getState);
const sendTagsSeparately = !supportsTagsInPatch(selectedServer);
const { updateShortUrl, updateShortUrlTags } = buildShlinkApiClient(getState);
try { try {
const [shortUrl] = await Promise.all([ const shortUrl = await updateShortUrl(shortCode, domain, data as any); // FIXME parse dates;
updateShortUrl(shortCode, domain, data as any), // FIXME Parse dates
sendTagsSeparately && data.tags ? updateShortUrlTags(shortCode, domain, data.tags) : undefined,
]);
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED }); dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
} catch (e: any) { } catch (e: any) {

View File

@@ -1,20 +1,15 @@
import { isReachableServer, SelectedServer } from '../../servers/data'; import { isReachableServer, SelectedServer } from '../../servers/data';
import { versionMatch, Versions } from './version'; import { SemVerPattern, versionMatch } from './version';
const serverMatchesVersions = (versions: Versions) => (selectedServer: SelectedServer): boolean => const serverMatchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: SelectedServer): boolean =>
isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions); isReachableServer(selectedServer) && versionMatch(selectedServer.version, { minVersion });
export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' }); export const supportsBotVisits = serverMatchesMinVersion('2.7.0');
export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' });
export const supportsOrphanVisits = supportsShortUrlTitle;
export const supportsQrCodeMargin = supportsShortUrlTitle;
export const supportsTagsInPatch = supportsShortUrlTitle;
export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });
export const supportsCrawlableVisits = supportsBotVisits; export const supportsCrawlableVisits = supportsBotVisits;
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' }); export const supportsQrErrorCorrection = serverMatchesMinVersion('2.8.0');
export const supportsDomainRedirects = supportsQrErrorCorrection; export const supportsDomainRedirects = supportsQrErrorCorrection;
export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' }); export const supportsForwardQuery = serverMatchesMinVersion('2.9.0');
export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' }); export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0');
export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' }); export const supportsNonOrphanVisits = serverMatchesMinVersion('3.0.0');
export const supportsAllTagsFiltering = supportsNonOrphanVisits; export const supportsAllTagsFiltering = supportsNonOrphanVisits;
export const supportsDomainVisits = serverMatchesVersions({ minVersion: '3.1.0' }); export const supportsDomainVisits = serverMatchesMinVersion('3.1.0');

View File

@@ -2,8 +2,6 @@ import { isEmpty } from 'ramda';
import { stringifyQuery } from './query'; import { stringifyQuery } from './query';
export interface QrCodeCapabilities { export interface QrCodeCapabilities {
useSizeInPath: boolean;
marginIsSupported: boolean;
errorCorrectionIsSupported: boolean; errorCorrectionIsSupported: boolean;
} }
@@ -21,13 +19,13 @@ export interface QrCodeOptions {
export const buildQrCodeUrl = ( export const buildQrCodeUrl = (
shortUrl: string, shortUrl: string,
{ size, format, margin, errorCorrection }: QrCodeOptions, { size, format, margin, errorCorrection }: QrCodeOptions,
{ useSizeInPath, marginIsSupported, errorCorrectionIsSupported }: QrCodeCapabilities, { errorCorrectionIsSupported }: QrCodeCapabilities,
): string => { ): string => {
const baseUrl = `${shortUrl}/qr-code${useSizeInPath ? `/${size}` : ''}`; const baseUrl = `${shortUrl}/qr-code`;
const query = stringifyQuery({ const query = stringifyQuery({
size: useSizeInPath ? undefined : size, size,
format, format,
margin: marginIsSupported && margin > 0 ? margin : undefined, margin: margin > 0 ? margin : undefined,
errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined, errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined,
}); });

View File

@@ -156,25 +156,6 @@ describe('ShlinkApiClient', () => {
}); });
}); });
describe('updateShortUrlTags', () => {
it.each(shortCodesWithDomainCombinations)('properly updates short URL tags', async (shortCode, domain) => {
const expectedTags = ['foo', 'bar'];
const axiosSpy = createAxiosMock({
data: { tags: expectedTags },
});
const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy, '', '');
const result = await updateShortUrlTags(shortCode, domain, expectedTags);
expect(expectedTags).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: `/short-urls/${shortCode}/tags`,
method: 'PUT',
params: domain ? { domain } : {},
}));
});
});
describe('updateShortUrl', () => { describe('updateShortUrl', () => {
it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => { it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => {
const meta = { const meta = {

View File

@@ -53,7 +53,6 @@ describe('<MenuLayout />', () => {
}); });
it.each([ it.each([
['2.5.0' as SemVer, 9],
['2.6.0' as SemVer, 10], ['2.6.0' as SemVer, 10],
['2.7.0' as SemVer, 10], ['2.7.0' as SemVer, 10],
['2.8.0' as SemVer, 11], ['2.8.0' as SemVer, 11],

View File

@@ -77,10 +77,11 @@ describe('<Overview />', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const links = wrapper.find(Link); const links = wrapper.find(Link);
expect(links).toHaveLength(4); expect(links).toHaveLength(5);
expect(links.at(0).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`); expect(links.at(0).prop('to')).toEqual(`/server/${serverId}/orphan-visits`);
expect(links.at(1).prop('to')).toEqual(`/server/${serverId}/manage-tags`); expect(links.at(1).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`);
expect(links.at(2).prop('to')).toEqual(`/server/${serverId}/create-short-url`); expect(links.at(2).prop('to')).toEqual(`/server/${serverId}/manage-tags`);
expect(links.at(3).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`); expect(links.at(3).prop('to')).toEqual(`/server/${serverId}/create-short-url`);
expect(links.at(4).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`);
}); });
}); });

View File

@@ -66,16 +66,12 @@ describe('<ShortUrlForm />', () => {
}); });
it.each([ it.each([
[null, 'create' as Mode, 4], ['create' as Mode, 4],
[null, 'create-basic' as Mode, 0], ['create-basic' as Mode, 0],
[Mock.of<ReachableServer>({ version: '2.6.0' }), 'create' as Mode, 4],
[Mock.of<ReachableServer>({ version: '2.5.0' }), 'create' as Mode, 4],
[Mock.of<ReachableServer>({ version: '2.6.0' }), 'edit' as Mode, 4],
[Mock.of<ReachableServer>({ version: '2.5.0' }), 'edit' as Mode, 3],
])( ])(
'renders expected amount of cards based on server capabilities and mode', 'renders expected amount of cards based on server capabilities and mode',
(selectedServer, mode, expectedAmountOfCards) => { (mode, expectedAmountOfCards) => {
const wrapper = createWrapper(selectedServer, mode); const wrapper = createWrapper(null, mode);
const cards = wrapper.find(SimpleCard); const cards = wrapper.find(SimpleCard);
expect(cards).toHaveLength(expectedAmountOfCards); expect(cards).toHaveLength(expectedAmountOfCards);

View File

@@ -4,7 +4,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/ShortUrlsTable'; import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/ShortUrlsTable';
import { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList'; import { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList';
import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { SemVer } from '../../src/utils/helpers/version';
import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from '../../src/short-urls/data'; import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from '../../src/short-urls/data';
describe('<ShortUrlsTable />', () => { describe('<ShortUrlsTable />', () => {
@@ -61,13 +60,8 @@ describe('<ShortUrlsTable />', () => {
}); });
}); });
it.each([ it('should render composed title column', () => {
['2.6.0' as SemVer], const wrapper = createWrapper(Mock.of<ReachableServer>({ version: '2.0.0' }));
['2.6.1' as SemVer],
['2.7.0' as SemVer],
['3.0.0' as SemVer],
])('should render composed column when server supports title', (version) => {
const wrapper = createWrapper(Mock.of<ReachableServer>({ version }));
const composedColumn = wrapper.find('table').find('th').at(2); const composedColumn = wrapper.find('table').find('th').at(2);
const text = composedColumn.text(); const text = composedColumn.text();

View File

@@ -8,7 +8,7 @@ import reducer, {
} from '../../../src/short-urls/reducers/shortUrlEdition'; } from '../../../src/short-urls/reducers/shortUrlEdition';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
import { ReachableServer, SelectedServer } from '../../../src/servers/data'; import { SelectedServer } from '../../../src/servers/data';
describe('shortUrlEditionReducer', () => { describe('shortUrlEditionReducer', () => {
const longUrl = 'https://shlink.io'; const longUrl = 'https://shlink.io';
@@ -41,8 +41,7 @@ describe('shortUrlEditionReducer', () => {
describe('editShortUrl', () => { describe('editShortUrl', () => {
const updateShortUrl = jest.fn().mockResolvedValue(shortUrl); const updateShortUrl = jest.fn().mockResolvedValue(shortUrl);
const updateShortUrlTags = jest.fn().mockResolvedValue([]); const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl });
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl, updateShortUrlTags });
const dispatch = jest.fn(); const dispatch = jest.fn();
const createGetState = (selectedServer: SelectedServer = null) => () => Mock.of<ShlinkState>({ selectedServer }); const createGetState = (selectedServer: SelectedServer = null) => () => Mock.of<ShlinkState>({ selectedServer });
@@ -59,25 +58,6 @@ describe('shortUrlEditionReducer', () => {
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, shortUrl }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, shortUrl });
}); });
it.each([
[null, { tags: ['foo', 'bar'] }, 1],
[null, {}, 0],
[Mock.of<ReachableServer>({ version: '2.6.0' }), {}, 0],
[Mock.of<ReachableServer>({ version: '2.6.0' }), { tags: ['foo', 'bar'] }, 0],
[Mock.of<ReachableServer>({ version: '2.5.0' }), {}, 0],
[Mock.of<ReachableServer>({ version: '2.5.0' }), { tags: ['foo', 'bar'] }, 1],
])(
'sends tags separately when appropriate, based on selected server and the payload',
async (server, payload, expectedTagsCalls) => {
const getState = createGetState(server);
await editShortUrl(buildShlinkApiClient)(shortCode, null, payload)(dispatch, getState);
expect(updateShortUrl).toHaveBeenCalled();
expect(updateShortUrlTags).toHaveBeenCalledTimes(expectedTagsCalls);
},
);
it('dispatches error on failure', async () => { it('dispatches error on failure', async () => {
const error = new Error(); const error = new Error();

View File

@@ -3,71 +3,41 @@ import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../../src/ut
describe('qrCodes', () => { describe('qrCodes', () => {
describe('buildQrCodeUrl', () => { describe('buildQrCodeUrl', () => {
it.each([ it.each([
[
'foo.com',
{ size: 530, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false },
'foo.com/qr-code/530?format=svg',
],
[
'foo.com',
{ size: 530, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false },
'foo.com/qr-code/530?format=png',
],
[ [
'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 },
{ useSizeInPath: false, marginIsSupported: false, errorCorrectionIsSupported: false }, { errorCorrectionIsSupported: false },
'bar.io/qr-code?size=870&format=svg', 'bar.io/qr-code?size=870&format=svg',
], ],
[ [
'bar.io', 'bar.io',
{ size: 200, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, { size: 200, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ useSizeInPath: false, marginIsSupported: false, errorCorrectionIsSupported: false }, { errorCorrectionIsSupported: false },
'bar.io/qr-code?size=200&format=png', '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 },
{ useSizeInPath: false, marginIsSupported: false, errorCorrectionIsSupported: false }, { errorCorrectionIsSupported: false },
'bar.io/qr-code?size=200&format=svg', 'bar.io/qr-code?size=200&format=svg',
], ],
[
'foo.net',
{ size: 480, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false },
'foo.net/qr-code/480?format=png',
],
[
'foo.net',
{ size: 480, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false },
'foo.net/qr-code/480?format=svg',
],
[
'shlink.io',
{ size: 123, format: 'svg' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection },
{ useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false },
'shlink.io/qr-code/123?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 },
{ useSizeInPath: true, marginIsSupported: true, errorCorrectionIsSupported: false }, { errorCorrectionIsSupported: false },
'shlink.io/qr-code/456?format=png&margin=10', 'shlink.io/qr-code?size=456&format=png&margin=10',
],
[
'shlink.io',
{ size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
{ useSizeInPath: true, marginIsSupported: true, errorCorrectionIsSupported: false },
'shlink.io/qr-code/456?format=png',
], ],
[ [
'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 },
{ useSizeInPath: true, marginIsSupported: true, errorCorrectionIsSupported: true }, { errorCorrectionIsSupported: true },
'shlink.io/qr-code/456?format=png&errorCorrection=H', 'shlink.io/qr-code?size=456&format=png&errorCorrection=H',
],
[
'shlink.io',
{ size: 999, format: 'png' as QrCodeFormat, margin: 20, errorCorrection: 'Q' as QrErrorCorrection },
{ errorCorrectionIsSupported: true },
'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, capabilities, expectedUrl) => {
expect(buildQrCodeUrl(shortUrl, options, capabilities)).toEqual(expectedUrl); expect(buildQrCodeUrl(shortUrl, options, capabilities)).toEqual(expectedUrl);