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.
* `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*

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 { 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);

View file

@ -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';

View file

@ -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>

View file

@ -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

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

View file

@ -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(

View file

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