Merge pull request #470 from acelaya-forks/feature/download-qr-code

Feature/download qr code
This commit is contained in:
Alejandro Celaya 2021-08-16 13:20:00 +02:00 committed by GitHub
commit c89e2b5d25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 66 additions and 14 deletions

View file

@ -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. * `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. * `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 ### Changed
* *Nothing* * *Nothing*

View file

@ -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);
}
}

View file

@ -9,12 +9,17 @@ import ErrorHandler from '../ErrorHandler';
import ShlinkVersionsContainer from '../ShlinkVersionsContainer'; import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ImageDownloader } from './ImageDownloader';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Services
bottle.constant('window', (global as any).window); bottle.constant('window', (global as any).window);
bottle.constant('console', global.console); bottle.constant('console', global.console);
bottle.constant('axios', axios); bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
// Components
bottle.serviceFactory('ScrollToTop', ScrollToTop); bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter); bottle.decorator('ScrollToTop', withRouter);

View file

@ -2,7 +2,7 @@ import { dissoc, values } from 'ramda';
import { CsvJson } from 'csvjson'; import { CsvJson } from 'csvjson';
import LocalStorage from '../../utils/services/LocalStorage'; import LocalStorage from '../../utils/services/LocalStorage';
import { ServersMap } from '../data'; import { ServersMap } from '../data';
import { saveCsv } from '../../utils/helpers/csv'; import { saveCsv } from '../../utils/helpers/files';
const SERVERS_FILENAME = 'shlink-servers.csv'; const SERVERS_FILENAME = 'shlink-servers.csv';

View file

@ -1,5 +1,7 @@
import { useMemo, useState } from 'react'; import { FC, useMemo, useState } from 'react';
import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap'; 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 { ExternalLink } from 'react-external-link';
import classNames from 'classnames'; import classNames from 'classnames';
import { ShortUrlModalProps } from '../data'; import { ShortUrlModalProps } from '../data';
@ -8,13 +10,17 @@ import { DropdownBtn } from '../../utils/DropdownBtn';
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes'; import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features'; import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features';
import { ImageDownloader } from '../../common/services/ImageDownloader';
import { Versions } from '../../utils/helpers/version';
import './QrCodeModal.scss'; import './QrCodeModal.scss';
interface QrCodeModalConnectProps extends ShortUrlModalProps { interface QrCodeModalConnectProps extends ShortUrlModalProps {
selectedServer: SelectedServer; 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 [ size, setSize ] = useState(300);
const [ margin, setMargin ] = useState(0); const [ margin, setMargin ] = useState(0);
const [ format, setFormat ] = useState<QrCodeFormat>('png'); const [ format, setFormat ] = useState<QrCodeFormat>('png');
@ -90,12 +96,21 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
</Row> </Row>
<div className="text-center"> <div className="text-center">
<div className="mb-3"> <div className="mb-3">
<div>QR code URL:</div>
<ExternalLink href={qrCodeUrl} /> <ExternalLink href={qrCodeUrl} />
<CopyToClipboardIcon text={qrCodeUrl} /> <CopyToClipboardIcon text={qrCodeUrl} />
</div> </div>
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" /> <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> </div>
</ModalBody> </ModalBody>
</Modal> </Modal>

View file

@ -51,7 +51,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ])); bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
bottle.serviceFactory('QrCodeModal', () => QrCodeModal); bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
// Services // Services

View file

@ -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 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('href', url);
link.setAttribute('download', filename); link.setAttribute('download', filename);
@ -10,3 +8,10 @@ export const saveCsv = ({ document }: Window, csv: string, filename: string) =>
link.click(); link.click();
document.body.removeChild(link); 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);
};

View file

@ -1,6 +1,6 @@
import { CsvJson } from 'csvjson'; import { CsvJson } from 'csvjson';
import { NormalizedVisit } from '../types'; import { NormalizedVisit } from '../types';
import { saveCsv } from '../../utils/helpers/csv'; import { saveCsv } from '../../utils/helpers/files';
export class VisitsExporter { export class VisitsExporter {
public constructor( public constructor(

View file

@ -1,16 +1,19 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { ExternalLink } from 'react-external-link'; 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 { 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 { ShortUrl } from '../../../src/short-urls/data';
import { ReachableServer } from '../../../src/servers/data'; import { ReachableServer } from '../../../src/servers/data';
import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon'; import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon';
import { DropdownBtn } from '../../../src/utils/DropdownBtn'; import { DropdownBtn } from '../../../src/utils/DropdownBtn';
import { SemVer } from '../../../src/utils/helpers/version'; import { SemVer } from '../../../src/utils/helpers/version';
import { ImageDownloader } from '../../../src/common/services/ImageDownloader';
describe('<QrCodeModal />', () => { describe('<QrCodeModal />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const saveImage = jest.fn();
const QrCodeModal = createQrCodeModal(Mock.of<ImageDownloader>({ saveImage }), () => null);
const shortUrl = 'https://doma.in/abc123'; const shortUrl = 'https://doma.in/abc123';
const createWrapper = (version: SemVer = '2.6.0') => { const createWrapper = (version: SemVer = '2.6.0') => {
const selectedServer = Mock.of<ReachableServer>({ version }); const selectedServer = Mock.of<ReachableServer>({ version });
@ -28,6 +31,7 @@ describe('<QrCodeModal />', () => {
}; };
afterEach(() => wrapper?.unmount()); afterEach(() => wrapper?.unmount());
afterEach(jest.clearAllMocks);
it('shows an external link to the URL in the header', () => { it('shows an external link to the URL in the header', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
@ -78,7 +82,6 @@ describe('<QrCodeModal />', () => {
sizeInput.simulate('change', { target: { value: `${size}` } }); sizeInput.simulate('change', { target: { value: `${size}` } });
marginInput.simulate('change', { target: { value: `${margin}` } }); 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(0).text()).toEqual(`Size: ${size}px`);
expect(wrapper.find('label').at(1).text()).toEqual(`Margin: ${margin}px`); expect(wrapper.find('label').at(1).text()).toEqual(`Margin: ${margin}px`);
expect(wrapper.find(Modal).prop('size')).toEqual(modalSize); expect(wrapper.find(Modal).prop('size')).toEqual(modalSize);
@ -96,4 +99,13 @@ describe('<QrCodeModal />', () => {
expect(dropdown).toHaveLength(expectedAmountOfDropdowns); expect(dropdown).toHaveLength(expectedAmountOfDropdowns);
expect(firstCol.prop('className')).toEqual(expectedRangeClass); 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);
});
}); });