mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Added support to download QR codes to the QR code modal
This commit is contained in:
parent
2b5420a429
commit
eb90aa2274
8 changed files with 64 additions and 14 deletions
13
src/common/services/ImageDownloader.ts
Normal file
13
src/common/services/ImageDownloader.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue