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

Issue:
Add a button to download the QR code for a short URL
<!--
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.
-->

Pull request:
Feature/download qr code
Closes #464 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccd3121f..0c4b3d18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
     * `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
     * `includes`: Suggests tags that contain the input.
 
+* [#464](https://github.com/shlinkio/shlink-web-client/pull/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
+
 ### Changed
 * *Nothing*
 
diff --git a/src/common/services/ImageDownloader.ts b/src/common/services/ImageDownloader.ts
new file mode 100644
index 00000000..2e131d7a
--- /dev/null
+++ b/src/common/services/ImageDownloader.ts
@@ -0,0 +1,13 @@
+import { AxiosInstance } from 'axios';
+import { saveUrl } from '../../utils/helpers/files';
+
+export class ImageDownloader {
+  public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
+
+  public async saveImage(imgUrl: string, filename: string): Promise<void> {
+    const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
+    const url = URL.createObjectURL(data);
+
+    saveUrl(this.window, url, filename);
+  }
+}
diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts
index eccd43f2..a7a71139 100644
--- a/src/common/services/provideServices.ts
+++ b/src/common/services/provideServices.ts
@@ -9,12 +9,17 @@ import ErrorHandler from '../ErrorHandler';
 import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
 import { ConnectDecorator } from '../../container/types';
 import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
+import { ImageDownloader } from './ImageDownloader';
 
 const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
+  // Services
   bottle.constant('window', (global as any).window);
   bottle.constant('console', global.console);
   bottle.constant('axios', axios);
 
+  bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
+
+  // Components
   bottle.serviceFactory('ScrollToTop', ScrollToTop);
   bottle.decorator('ScrollToTop', withRouter);
 
diff --git a/src/servers/services/ServersExporter.ts b/src/servers/services/ServersExporter.ts
index a54536a4..f230af67 100644
--- a/src/servers/services/ServersExporter.ts
+++ b/src/servers/services/ServersExporter.ts
@@ -2,7 +2,7 @@ import { dissoc, values } from 'ramda';
 import { CsvJson } from 'csvjson';
 import LocalStorage from '../../utils/services/LocalStorage';
 import { ServersMap } from '../data';
-import { saveCsv } from '../../utils/helpers/csv';
+import { saveCsv } from '../../utils/helpers/files';
 
 const SERVERS_FILENAME = 'shlink-servers.csv';
 
diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx
index 3d4d9236..9b43b58c 100644
--- a/src/short-urls/helpers/QrCodeModal.tsx
+++ b/src/short-urls/helpers/QrCodeModal.tsx
@@ -1,5 +1,7 @@
-import { useMemo, useState } from 'react';
-import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap';
+import { FC, useMemo, useState } from 'react';
+import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
 import { ExternalLink } from 'react-external-link';
 import classNames from 'classnames';
 import { ShortUrlModalProps } from '../data';
@@ -8,13 +10,17 @@ import { DropdownBtn } from '../../utils/DropdownBtn';
 import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
 import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
 import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features';
+import { ImageDownloader } from '../../common/services/ImageDownloader';
+import { Versions } from '../../utils/helpers/version';
 import './QrCodeModal.scss';
 
 interface QrCodeModalConnectProps extends ShortUrlModalProps {
   selectedServer: SelectedServer;
 }
 
-const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => {
+const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Versions>) => (
+  { shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
+) => {
   const [ size, setSize ] = useState(300);
   const [ margin, setMargin ] = useState(0);
   const [ format, setFormat ] = useState<QrCodeFormat>('png');
@@ -90,12 +96,21 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
         </Row>
         <div className="text-center">
           <div className="mb-3">
-            <div>QR code URL:</div>
             <ExternalLink 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>
+          <ForServerVersion minVersion="2.9.0">
+            <div className="mt-3">
+              <Button
+                block
+                color="primary"
+                onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
+              >
+                Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
+              </Button>
+            </div>
+          </ForServerVersion>
         </div>
       </ModalBody>
     </Modal>
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts
index 49dee640..48541144 100644
--- a/src/short-urls/services/provideServices.ts
+++ b/src/short-urls/services/provideServices.ts
@@ -51,7 +51,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
   bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
 
-  bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
+  bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
   bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
 
   // Services
diff --git a/src/utils/helpers/csv.ts b/src/utils/helpers/files.ts
similarity index 67%
rename from src/utils/helpers/csv.ts
rename to src/utils/helpers/files.ts
index 08c895a0..89f0da85 100644
--- a/src/utils/helpers/csv.ts
+++ b/src/utils/helpers/files.ts
@@ -1,7 +1,5 @@
-export const saveCsv = ({ document }: Window, csv: string, filename: string) => {
+export const saveUrl = ({ document }: Window, url: string, filename: string) => {
   const link = document.createElement('a');
-  const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
-  const url = URL.createObjectURL(blob);
 
   link.setAttribute('href', url);
   link.setAttribute('download', filename);
@@ -10,3 +8,10 @@ export const saveCsv = ({ document }: Window, csv: string, filename: string) =>
   link.click();
   document.body.removeChild(link);
 };
+
+export const saveCsv = (window: Window, csv: string, filename: string) => {
+  const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
+  const url = URL.createObjectURL(blob);
+
+  saveUrl(window, url, filename);
+};
diff --git a/src/visits/services/VisitsExporter.ts b/src/visits/services/VisitsExporter.ts
index daa8b81b..ff863e8b 100644
--- a/src/visits/services/VisitsExporter.ts
+++ b/src/visits/services/VisitsExporter.ts
@@ -1,6 +1,6 @@
 import { CsvJson } from 'csvjson';
 import { NormalizedVisit } from '../types';
-import { saveCsv } from '../../utils/helpers/csv';
+import { saveCsv } from '../../utils/helpers/files';
 
 export class VisitsExporter {
   public constructor(


Test patch:
diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx
index b57b8fcc..9cdf1bb7 100644
--- a/test/short-urls/helpers/QrCodeModal.test.tsx
+++ b/test/short-urls/helpers/QrCodeModal.test.tsx
@@ -1,16 +1,19 @@
 import { shallow, ShallowWrapper } from 'enzyme';
 import { ExternalLink } from 'react-external-link';
-import { Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
+import { Button, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
 import { Mock } from 'ts-mockery';
-import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
+import createQrCodeModal 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';
 import { SemVer } from '../../../src/utils/helpers/version';
+import { ImageDownloader } from '../../../src/common/services/ImageDownloader';
 
 describe('<QrCodeModal />', () => {
   let wrapper: ShallowWrapper;
+  const saveImage = jest.fn();
+  const QrCodeModal = createQrCodeModal(Mock.of<ImageDownloader>({ saveImage }), () => null);
   const shortUrl = 'https://doma.in/abc123';
   const createWrapper = (version: SemVer = '2.6.0') => {
     const selectedServer = Mock.of<ReachableServer>({ version });
@@ -28,6 +31,7 @@ describe('<QrCodeModal />', () => {
   };
 
   afterEach(() => wrapper?.unmount());
+  afterEach(jest.clearAllMocks);
 
   it('shows an external link to the URL in the header', () => {
     const wrapper = createWrapper();
@@ -78,7 +82,6 @@ describe('<QrCodeModal />', () => {
     sizeInput.simulate('change', { target: { value: `${size}` } });
     marginInput.simulate('change', { target: { value: `${margin}` } });
 
-    expect(wrapper.find('.mt-2').text()).toEqual(`${size}x${size}`);
     expect(wrapper.find('label').at(0).text()).toEqual(`Size: ${size}px`);
     expect(wrapper.find('label').at(1).text()).toEqual(`Margin: ${margin}px`);
     expect(wrapper.find(Modal).prop('size')).toEqual(modalSize);
@@ -96,4 +99,13 @@ describe('<QrCodeModal />', () => {
     expect(dropdown).toHaveLength(expectedAmountOfDropdowns);
     expect(firstCol.prop('className')).toEqual(expectedRangeClass);
   });
+
+  it('saves the QR code image when clicking the Download button', () => {
+    const wrapper = createWrapper();
+    const downloadBtn = wrapper.find(Button);
+
+    expect(saveImage).not.toHaveBeenCalled();
+    downloadBtn.simulate('click');
+    expect(saveImage).toHaveBeenCalledTimes(1);
+  });
 });