mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 01:20:24 +03:00
Merge pull request #470 from acelaya-forks/feature/download-qr-code
Feature/download qr code
This commit is contained in:
commit
c89e2b5d25
9 changed files with 66 additions and 14 deletions
|
@ -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*
|
||||
|
||||
|
|
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 { 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);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue