Repository: shlinkio/shlink-web-client (ts)

Issue:
Improve QR code component
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.

With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->

#### Summary

* Allow changing the size (with a range component)
* Allow changing the format (png, svg)
* Allow copying the QR code URL, which should be dynamically built based on previous two points.
    * Should use the endpoint where the size goes in the path for older shlink servers.


Pull request description:
Feature/qr code improvements
Closes #379 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da1228f8..0ff5b77a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
 
+## [Unreleased]
+### Added
+* [#379](https://github.com/shlinkio/shlink-web-client/issues/379) Improved QR code modal, including controls to customize size and format, as well as a button to copy the link to the clipboard.
+
+### Changed
+* *Nothing*
+
+### Deprecated
+* *Nothing*
+
+### Removed
+* *Nothing*
+
+### Fixed
+* *Nothing*
+
+
 ## [3.0.1] - 2020-12-30
 ### Added
 * *Nothing*
diff --git a/src/servers/services/ServersImporter.ts b/src/servers/services/ServersImporter.ts
index 03c1ca25..7dcf8175 100644
--- a/src/servers/services/ServersImporter.ts
+++ b/src/servers/services/ServersImporter.ts
@@ -1,7 +1,6 @@
 import { CsvJson } from 'csvjson';
 import { ServerData } from '../data';
 
-
 interface CsvFile extends File {
   type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
 }
diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx
index 02a57e7a..8f968c87 100644
--- a/src/short-urls/helpers/QrCodeModal.tsx
+++ b/src/short-urls/helpers/QrCodeModal.tsx
@@ -1,19 +1,79 @@
-import { Modal, ModalBody, ModalHeader } from 'reactstrap';
+import { useMemo, useState } from 'react';
+import { DropdownItem, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
 import { ExternalLink } from 'react-external-link';
 import { ShortUrlModalProps } from '../data';
+import { ReachableServer } from '../../servers/data';
+import { versionMatch } from '../../utils/helpers/version';
+import { DropdownBtn } from '../../utils/DropdownBtn';
+import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
+import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
 import './QrCodeModal.scss';
 
-const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen }: ShortUrlModalProps) => (
-  <Modal isOpen={isOpen} toggle={toggle} centered>
-    <ModalHeader toggle={toggle}>
-      QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-center">
-        <img src={`${shortUrl}/qr-code`} className="qr-code-modal__img" alt="QR code" />
-      </div>
-    </ModalBody>
-  </Modal>
-);
+interface QrCodeModalConnectProps extends ShortUrlModalProps {
+  selectedServer: ReachableServer;
+}
+
+const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => {
+  const [ size, setSize ] = useState(300);
+  const [ format, setFormat ] = useState<QrCodeFormat>('png');
+  const capabilities: QrCodeCapabilities = useMemo(() => ({
+    useSizeInPath: !versionMatch(selectedServer.version, { minVersion: '2.5.0' }),
+    svgIsSupported: versionMatch(selectedServer.version, { minVersion: '2.4.0' }),
+  }), [ selectedServer ]);
+  const qrCodeUrl = useMemo(
+    () => buildQrCodeUrl(shortUrl, size, format, capabilities),
+    [ shortUrl, size, format, capabilities ],
+  );
+  const modalSize = useMemo(() => {
+    if (size < 500) {
+      return undefined;
+    }
+
+    return size < 800 ? 'lg' : 'xl';
+  }, [ size ]);
+
+  return (
+    <Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}>
+      <ModalHeader toggle={toggle}>
+        QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
+      </ModalHeader>
+      <ModalBody>
+        <Row className="mb-2">
+          <div className={capabilities.svgIsSupported ? 'col-md-6' : 'col-12'}>
+            <FormGroup>
+              <label className="mb-0">Size: {size}px</label>
+              <input
+                type="range"
+                className="form-control-range"
+                value={size}
+                step={10}
+                min={50}
+                max={1000}
+                onChange={(e) => setSize(Number(e.target.value))}
+              />
+            </FormGroup>
+          </div>
+          {capabilities.svgIsSupported && (
+            <div className="col-md-6">
+              <DropdownBtn text={`Format (${format})`}>
+                <DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
+                <DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
+              </DropdownBtn>
+            </div>
+          )}
+        </Row>
+        <div className="text-center">
+          <div className="mb-3">
+            <div>QR code URL:</div>
+            <ExternalLink className="indivisible" href={qrCodeUrl} />
+            <CopyToClipboardIcon text={qrCodeUrl} />
+          </div>
+          <img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
+          <div className="mt-2">{size}x{size}</div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};
 
 export default QrCodeModal;
diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss
index 6cf6ee42..89255646 100644
--- a/src/short-urls/helpers/ShortUrlsRow.scss
+++ b/src/short-urls/helpers/ShortUrlsRow.scss
@@ -48,11 +48,6 @@
   transform: scale(1.5);
 }
 
-.short-urls-row__copy-btn {
-  cursor: pointer;
-  font-size: 1.2rem;
-}
-
 .short-urls-row__copy-hint {
   @include vertical-align(translateX(10px));
 
diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx
index 1399099b..5551938c 100644
--- a/src/short-urls/helpers/ShortUrlsRow.tsx
+++ b/src/short-urls/helpers/ShortUrlsRow.tsx
@@ -2,13 +2,11 @@ import { isEmpty } from 'ramda';
 import { FC, useEffect, useRef } from 'react';
 import Moment from 'react-moment';
 import { ExternalLink } from 'react-external-link';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
-import CopyToClipboard from 'react-copy-to-clipboard';
 import ColorGenerator from '../../utils/services/ColorGenerator';
 import { StateFlagTimeout } from '../../utils/helpers/hooks';
 import Tag from '../../tags/helpers/Tag';
 import { SelectedServer } from '../../servers/data';
+import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
 import { ShortUrl } from '../data';
 import ShortUrlVisitsCount from './ShortUrlVisitsCount';
 import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
@@ -60,9 +58,7 @@ const ShortUrlsRow = (
       <td className="short-urls-row__cell" data-th="Short URL: ">
         <span className="indivisible short-urls-row__cell--relative">
           <ExternalLink href={shortUrl.shortUrl} />
-          <CopyToClipboard text={shortUrl.shortUrl} onCopy={setCopiedToClipboard}>
-            <FontAwesomeIcon icon={copyIcon} className="ml-2 short-urls-row__copy-btn" />
-          </CopyToClipboard>
+          <CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
           <span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
             Copied short URL!
           </span>
diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.tsx b/src/short-urls/helpers/ShortUrlsRowMenu.tsx
index 18d711fc..e867473d 100644
--- a/src/short-urls/helpers/ShortUrlsRowMenu.tsx
+++ b/src/short-urls/helpers/ShortUrlsRowMenu.tsx
@@ -14,7 +14,6 @@ import { useToggle } from '../../utils/helpers/hooks';
 import { ShortUrl, ShortUrlModalProps } from '../data';
 import { Versions } from '../../utils/helpers/version';
 import { SelectedServer } from '../../servers/data';
-import QrCodeModal from './QrCodeModal';
 import VisitStatsLink from './VisitStatsLink';
 import './ShortUrlsRowMenu.scss';
 
@@ -29,6 +28,7 @@ const ShortUrlsRowMenu = (
   EditTagsModal: ShortUrlModal,
   EditMetaModal: ShortUrlModal,
   EditShortUrlModal: ShortUrlModal,
+  QrCodeModal: ShortUrlModal,
   ForServerVersion: FC<Versions>,
 ) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
   const [ isOpen, toggle ] = useToggle();
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts
index 96a1139c..93d987b0 100644
--- a/src/short-urls/services/provideServices.ts
+++ b/src/short-urls/services/provideServices.ts
@@ -19,6 +19,7 @@ import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
 import { editShortUrl } from '../reducers/shortUrlEdition';
 import { ConnectDecorator } from '../../container/types';
 import { ShortUrlsTable } from '../ShortUrlsTable';
+import QrCodeModal from '../helpers/QrCodeModal';
 
 const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   // Components
@@ -40,6 +41,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
     'EditTagsModal',
     'EditMetaModal',
     'EditShortUrlModal',
+    'QrCodeModal',
     'ForServerVersion',
   );
   bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
@@ -69,6 +71,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
   bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
 
+  bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
+  bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
+
   // Services
   bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
   bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
diff --git a/src/utils/CopyToClipboardIcon.scss b/src/utils/CopyToClipboardIcon.scss
new file mode 100644
index 00000000..a9e958e9
--- /dev/null
+++ b/src/utils/CopyToClipboardIcon.scss
@@ -0,0 +1,4 @@
+.copy-to-clipboard-icon {
+  cursor: pointer;
+  font-size: 1.2rem;
+}
diff --git a/src/utils/CopyToClipboardIcon.tsx b/src/utils/CopyToClipboardIcon.tsx
new file mode 100644
index 00000000..46d12a8c
--- /dev/null
+++ b/src/utils/CopyToClipboardIcon.tsx
@@ -0,0 +1,16 @@
+import { FC } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import './CopyToClipboardIcon.scss';
+
+interface CopyToClipboardIconProps {
+  text: string;
+  onCopy?: (text: string, result: boolean) => void;
+}
+
+export const CopyToClipboardIcon: FC<CopyToClipboardIconProps> = ({ text, onCopy }) => (
+  <CopyToClipboard text={text} onCopy={onCopy}>
+    <FontAwesomeIcon icon={copyIcon} className="ml-2 copy-to-clipboard-icon" />
+  </CopyToClipboard>
+);
diff --git a/src/utils/helpers/qrCodes.ts b/src/utils/helpers/qrCodes.ts
new file mode 100644
index 00000000..13b298ca
--- /dev/null
+++ b/src/utils/helpers/qrCodes.ts
@@ -0,0 +1,25 @@
+import { always, cond } from 'ramda';
+
+export interface QrCodeCapabilities {
+  useSizeInPath: boolean;
+  svgIsSupported: boolean;
+}
+
+export type QrCodeFormat = 'svg' | 'png';
+
+export const buildQrCodeUrl = (
+  shortUrl: string,
+  size: number,
+  format: QrCodeFormat,
+  { useSizeInPath, svgIsSupported }: QrCodeCapabilities,
+): string => {
+  const sizeFragment = useSizeInPath ? `/${size}` : `?size=${size}`;
+  const formatFragment = !svgIsSupported ? '' : `format=${format}`;
+  const joinSymbolResolver = cond([
+    [ () => useSizeInPath && svgIsSupported, always('?') ],
+    [ () => !useSizeInPath && svgIsSupported, always('&') ],
+  ]);
+  const joinSymbol = joinSymbolResolver() ?? '';
+
+  return `${shortUrl}/qr-code${sizeFragment}${joinSymbol}${formatFragment}`;
+};


Test patch:
diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx
index 93541d60..924c8cb3 100644
--- a/test/short-urls/helpers/QrCodeModal.test.tsx
+++ b/test/short-urls/helpers/QrCodeModal.test.tsx
@@ -1,29 +1,81 @@
 import { shallow, ShallowWrapper } from 'enzyme';
 import { ExternalLink } from 'react-external-link';
+import { Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
 import { Mock } from 'ts-mockery';
 import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
 import { ShortUrl } from '../../../src/short-urls/data';
+import { ReachableServer } from '../../../src/servers/data';
+import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon';
+import { DropdownBtn } from '../../../src/utils/DropdownBtn';
 
 describe('<QrCodeModal />', () => {
   let wrapper: ShallowWrapper;
   const shortUrl = 'https://doma.in/abc123';
+  const createWrapper = (version = '2.5.0') => {
+    const selectedServer = Mock.of<ReachableServer>({ version });
 
-  beforeEach(() => {
-    wrapper = shallow(<QrCodeModal shortUrl={Mock.of<ShortUrl>({ shortUrl })} isOpen={true} toggle={() => {}} />);
-  });
-  afterEach(() => wrapper.unmount());
+    wrapper = shallow(
+      <QrCodeModal
+        shortUrl={Mock.of<ShortUrl>({ shortUrl })}
+        isOpen={true}
+        toggle={() => {}}
+        selectedServer={selectedServer}
+      />,
+    );
+
+    return wrapper;
+  };
+
+  afterEach(() => wrapper?.unmount());
 
-  it('shows an external link to the URL', () => {
-    const externalLink = wrapper.find(ExternalLink);
+  it('shows an external link to the URL in the header', () => {
+    const wrapper = createWrapper();
+    const externalLink = wrapper.find(ModalHeader).find(ExternalLink);
 
     expect(externalLink).toHaveLength(1);
     expect(externalLink.prop('href')).toEqual(shortUrl);
   });
 
-  it('displays an image with the QR code of the URL', () => {
-    const img = wrapper.find('img');
+  it.each([
+    [ '2.3.0', '/qr-code/300' ],
+    [ '2.4.0', '/qr-code/300?format=png' ],
+    [ '2.5.0', '/qr-code?size=300&format=png' ],
+  ])('displays an image with the QR code of the URL', (version, expectedUrl) => {
+    const wrapper = createWrapper(version);
+    const modalBody = wrapper.find(ModalBody);
+    const img = modalBody.find('img');
+    const linkInBody = modalBody.find(ExternalLink);
+    const copyToClipboard = modalBody.find(CopyToClipboardIcon);
+
+    expect(img.prop('src')).toEqual(`${shortUrl}${expectedUrl}`);
+    expect(linkInBody.prop('href')).toEqual(`${shortUrl}${expectedUrl}`);
+    expect(copyToClipboard.prop('text')).toEqual(`${shortUrl}${expectedUrl}`);
+  });
+
+  it.each([
+    [ 530, 'lg' ],
+    [ 200, undefined ],
+    [ 830, 'xl' ],
+  ])('renders expected size', (size, modalSize) => {
+    const wrapper = createWrapper();
+    const sizeInput = wrapper.find('.form-control-range');
+
+    sizeInput.simulate('change', { target: { value: `${size}` } });
+
+    expect(wrapper.find('.mt-2').text()).toEqual(`${size}x${size}`);
+    expect(wrapper.find('label').text()).toEqual(`Size: ${size}px`);
+    expect(wrapper.find(Modal).prop('size')).toEqual(modalSize);
+  });
+
+  it.each([
+    [ '2.3.0', 0, 'col-12' ],
+    [ '2.4.0', 1, 'col-md-6' ],
+  ])('shows expected components based on server version', (version, expectedAmountOfDropdowns, expectedRangeClass) => {
+    const wrapper = createWrapper(version);
+    const dropdown = wrapper.find(DropdownBtn);
+    const firstCol = wrapper.find(Row).find('div').first();
 
-    expect(img).toHaveLength(1);
-    expect(img.prop('src')).toEqual(`${shortUrl}/qr-code`);
+    expect(dropdown).toHaveLength(expectedAmountOfDropdowns);
+    expect(firstCol.prop('className')).toEqual(expectedRangeClass);
   });
 });
diff --git a/test/short-urls/helpers/ShortUrlsRow.test.tsx b/test/short-urls/helpers/ShortUrlsRow.test.tsx
index c4f565a9..e5429a7c 100644
--- a/test/short-urls/helpers/ShortUrlsRow.test.tsx
+++ b/test/short-urls/helpers/ShortUrlsRow.test.tsx
@@ -4,13 +4,13 @@ import Moment from 'react-moment';
 import { assoc, toString } from 'ramda';
 import { Mock } from 'ts-mockery';
 import { ExternalLink } from 'react-external-link';
-import CopyToClipboard from 'react-copy-to-clipboard';
 import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
 import Tag from '../../../src/tags/helpers/Tag';
 import ColorGenerator from '../../../src/utils/services/ColorGenerator';
 import { StateFlagTimeout } from '../../../src/utils/helpers/hooks';
 import { ShortUrl } from '../../../src/short-urls/data';
 import { ReachableServer } from '../../../src/servers/data';
+import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon';
 
 describe('<ShortUrlsRow />', () => {
   let wrapper: ShallowWrapper;
@@ -98,7 +98,7 @@ describe('<ShortUrlsRow />', () => {
 
   it('updates state when copied to clipboard', () => {
     const col = wrapper.find('td').at(1);
-    const menu = col.find(CopyToClipboard);
+    const menu = col.find(CopyToClipboardIcon);
 
     expect(menu).toHaveLength(1);
     expect(stateFlagTimeout).not.toHaveBeenCalled();
diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx
index 66af635b..3e0cfd5d 100644
--- a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx
+++ b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx
@@ -2,7 +2,6 @@ import { shallow, ShallowWrapper } from 'enzyme';
 import { ButtonDropdown, DropdownItem } from 'reactstrap';
 import { Mock } from 'ts-mockery';
 import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
-import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
 import { ReachableServer } from '../../../src/servers/data';
 import { ShortUrl } from '../../../src/short-urls/data';
 
@@ -12,6 +11,7 @@ describe('<ShortUrlsRowMenu />', () => {
   const EditTagsModal = () => null;
   const EditMetaModal = () => null;
   const EditShortUrlModal = () => null;
+  const QrCodeModal = () => null;
   const selectedServer = Mock.of<ReachableServer>({ id: 'abc123' });
   const shortUrl = Mock.of<ShortUrl>({
     shortCode: 'abc123',
@@ -23,6 +23,7 @@ describe('<ShortUrlsRowMenu />', () => {
       EditTagsModal,
       EditMetaModal,
       EditShortUrlModal,
+      QrCodeModal,
       () => null,
     );
 
diff --git a/test/utils/CopyToClipboardIcon.test.tsx b/test/utils/CopyToClipboardIcon.test.tsx
new file mode 100644
index 00000000..9f0d709b
--- /dev/null
+++ b/test/utils/CopyToClipboardIcon.test.tsx
@@ -0,0 +1,27 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
+import { CopyToClipboardIcon } from '../../src/utils/CopyToClipboardIcon';
+
+describe('<CopyToClipboardIcon />', () => {
+  let wrapper: ShallowWrapper;
+  const onCopy = () => {};
+
+  beforeEach(() => {
+    wrapper = shallow(<CopyToClipboardIcon text="foo" onCopy={onCopy} />);
+  });
+  afterEach(() => wrapper?.unmount());
+
+  test('expected components are wrapped', () => {
+    const copyToClipboard = wrapper.find(CopyToClipboard);
+    const icon = wrapper.find(FontAwesomeIcon);
+
+    expect(copyToClipboard).toHaveLength(1);
+    expect(copyToClipboard.prop('text')).toEqual('foo');
+    expect(copyToClipboard.prop('onCopy')).toEqual(onCopy);
+    expect(icon).toHaveLength(1);
+    expect(icon.prop('icon')).toEqual(copyIcon);
+    expect(icon.prop('className')).toEqual('ml-2 copy-to-clipboard-icon');
+  });
+});
diff --git a/test/utils/DropdownBtn.test.tsx b/test/utils/DropdownBtn.test.tsx
index 79722689..2076f6ec 100644
--- a/test/utils/DropdownBtn.test.tsx
+++ b/test/utils/DropdownBtn.test.tsx
@@ -6,7 +6,7 @@ import { DropdownBtn, DropdownBtnProps } from '../../src/utils/DropdownBtn';
 describe('<DropdownBtn />', () => {
   let wrapper: ShallowWrapper;
   const createWrapper = (props: PropsWithChildren<DropdownBtnProps>) => {
-    wrapper = shallow(<DropdownBtn {...props} />);
+    wrapper = shallow(<DropdownBtn children={'foo'} {...props} />);
 
     return wrapper;
   };
@@ -17,7 +17,7 @@ describe('<DropdownBtn />', () => {
     const wrapper = createWrapper({ text });
     const toggle = wrapper.find(DropdownToggle);
 
-    expect(toggle.html()).toContain(text);
+    expect(toggle.prop('children')).toContain(text);
   });
 
   it.each([[ 'foo' ], [ 'bar' ], [ 'baz' ]])('displays provided children', (children) => {
diff --git a/test/utils/SortingDropdown.test.tsx b/test/utils/SortingDropdown.test.tsx
index 6b3f9682..4f85c013 100644
--- a/test/utils/SortingDropdown.test.tsx
+++ b/test/utils/SortingDropdown.test.tsx
@@ -76,21 +76,22 @@ describe('<SortingDropdown />', () => {
   });
 
   it.each([
-    [{ isButton: false }, '>Order by<' ],
-    [{ isButton: true }, '>Order by...<' ],
+    [{ isButton: false }, <>Order by</> ],
+    [{ isButton: true }, <>Order by...</> ],
     [
       { isButton: true, orderField: 'foo', orderDir: 'ASC' as OrderDir },
-      'Order by: &quot;Foo&quot; - &quot;ASC&quot;',
+      'Order by: "Foo" - "ASC"',
     ],
     [
       { isButton: true, orderField: 'baz', orderDir: 'DESC' as OrderDir },
-      'Order by: &quot;Hello World&quot; - &quot;DESC&quot;',
+      'Order by: "Hello World" - "DESC"',
     ],
-    [{ isButton: true, orderField: 'baz' }, 'Order by: &quot;Hello World&quot; - &quot;DESC&quot;' ],
+    [{ isButton: true, orderField: 'baz' }, 'Order by: "Hello World" - "DESC"' ],
   ])('displays expected text in toggle', (props, expectedText) => {
     const wrapper = createWrapper(props);
     const toggle = wrapper.find(DropdownToggle);
+    const [ children ] = (toggle.prop('children') as any[]).filter(Boolean);
 
-    expect(toggle.html()).toContain(expectedText);
+    expect(children).toEqual(expectedText);
   });
 });
diff --git a/test/utils/helpers/qrCodes.test.ts b/test/utils/helpers/qrCodes.test.ts
new file mode 100644
index 00000000..fe0a0f76
--- /dev/null
+++ b/test/utils/helpers/qrCodes.test.ts
@@ -0,0 +1,17 @@
+import { buildQrCodeUrl, QrCodeFormat } from '../../../src/utils/helpers/qrCodes';
+
+describe('qrCodes', () => {
+  describe('buildQrCodeUrl', () => {
+    test.each([
+      [ 'foo.com', 530, 'svg', { useSizeInPath: true, svgIsSupported: true }, 'foo.com/qr-code/530?format=svg' ],
+      [ 'foo.com', 530, 'png', { useSizeInPath: true, svgIsSupported: true }, 'foo.com/qr-code/530?format=png' ],
+      [ 'bar.io', 870, 'svg', { useSizeInPath: false, svgIsSupported: false }, 'bar.io/qr-code?size=870' ],
+      [ 'bar.io', 200, 'png', { useSizeInPath: false, svgIsSupported: true }, 'bar.io/qr-code?size=200&format=png' ],
+      [ 'bar.io', 200, 'svg', { useSizeInPath: false, svgIsSupported: true }, 'bar.io/qr-code?size=200&format=svg' ],
+      [ 'foo.net', 480, 'png', { useSizeInPath: true, svgIsSupported: false }, 'foo.net/qr-code/480' ],
+      [ 'foo.net', 480, 'svg', { useSizeInPath: true, svgIsSupported: false }, 'foo.net/qr-code/480' ],
+    ])('builds expected URL based in params', (shortUrl, size, format, capabilities, expectedUrl) => {
+      expect(buildQrCodeUrl(shortUrl, size, format as QrCodeFormat, capabilities)).toEqual(expectedUrl);
+    });
+  });
+});