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 { + 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) => ( + { shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps, +) => { const [ size, setSize ] = useState(300); const [ margin, setMargin ] = useState(0); const [ format, setFormat ] = useState('png'); @@ -90,12 +96,21 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
-
QR code URL:
QR code -
{size}x{size}
+ +
+ +
+
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( 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('', () => { let wrapper: ShallowWrapper; + const saveImage = jest.fn(); + const QrCodeModal = createQrCodeModal(Mock.of({ saveImage }), () => null); const shortUrl = 'https://doma.in/abc123'; const createWrapper = (version: SemVer = '2.6.0') => { const selectedServer = Mock.of({ version }); @@ -28,6 +31,7 @@ describe('', () => { }; 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('', () => { 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('', () => { 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); + }); });