mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Improved QR code modal, to allow selecting size, format and copy URL
This commit is contained in:
parent
556495ea7e
commit
3546a17575
10 changed files with 106 additions and 47 deletions
|
@ -1,7 +1,6 @@
|
|||
import { CsvJson } from 'csvjson';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
|
||||
interface CsvFile extends File {
|
||||
type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
|
||||
}
|
||||
|
|
|
@ -1,36 +1,28 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { DropdownItem, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { ReachableServer } from '../../servers/data';
|
||||
import { versionMatch } from '../../utils/helpers/version';
|
||||
import { DropdownBtn } from '../../utils/DropdownBtn';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
|
||||
import './QrCodeModal.scss';
|
||||
|
||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
||||
selectedServer: ReachableServer;
|
||||
}
|
||||
|
||||
type QrCodeFormat = 'svg' | 'png';
|
||||
|
||||
const buildQrCodeUrl = (shortUrl: string, size: number, format: QrCodeFormat, version: string): string => {
|
||||
const useSizeInPath = !versionMatch(version, { minVersion: '2.5.0' });
|
||||
const svgIsSupported = versionMatch(version, { minVersion: '2.4.0' });
|
||||
const sizeFragment = useSizeInPath ? `/${size}?` : `?size=${size}&`;
|
||||
const formatFragment = !svgIsSupported ? '' : `format=${format}`;
|
||||
|
||||
return `${shortUrl}/qr-code${sizeFragment}${formatFragment}`;
|
||||
};
|
||||
|
||||
const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => {
|
||||
const [ size, setSize ] = useState(300);
|
||||
const [ format, setFormat ] = useState<QrCodeFormat>('png');
|
||||
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
||||
useSizeInPath: !versionMatch(selectedServer.version, { minVersion: '2.5.0' }),
|
||||
svgIsSupported: versionMatch(selectedServer.version, { minVersion: '2.4.0' }),
|
||||
}), [ selectedServer ]);
|
||||
const qrCodeUrl = useMemo(
|
||||
() => buildQrCodeUrl(shortUrl, size, format, selectedServer.version),
|
||||
[ shortUrl, size, format, selectedServer ],
|
||||
() => buildQrCodeUrl(shortUrl, size, format, capabilities),
|
||||
[ shortUrl, size, format, capabilities ],
|
||||
);
|
||||
const modalSize = useMemo(() => {
|
||||
if (size < 500) {
|
||||
|
@ -47,7 +39,7 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
|
|||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row className="mb-2">
|
||||
<div className="col-md-6">
|
||||
<div className={capabilities.svgIsSupported ? 'col-md-6' : 'col-12'}>
|
||||
<FormGroup>
|
||||
<label className="mb-0">Size: {size}px</label>
|
||||
<input
|
||||
|
@ -61,20 +53,20 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
|
|||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<DropdownBtn text={`Format (${format})`}>
|
||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||
</DropdownBtn>
|
||||
</div>
|
||||
{capabilities.svgIsSupported && (
|
||||
<div className="col-md-6">
|
||||
<DropdownBtn text={`Format (${format})`}>
|
||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||
</DropdownBtn>
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
<div className="text-center">
|
||||
<div className="mb-3">
|
||||
<div>QR code URL:</div>
|
||||
<ExternalLink className="indivisible" href={qrCodeUrl} />
|
||||
<CopyToClipboard text={qrCodeUrl}>
|
||||
<FontAwesomeIcon icon={copyIcon} className="ml-2" />
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||
</div>
|
||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||
<div className="mt-2">{size}x{size}</div>
|
||||
|
|
|
@ -48,11 +48,6 @@
|
|||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.short-urls-row__copy-btn {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.short-urls-row__copy-hint {
|
||||
@include vertical-align(translateX(10px));
|
||||
|
||||
|
|
|
@ -2,13 +2,11 @@ import { isEmpty } from 'ramda';
|
|||
import { FC, useEffect, useRef } from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { StateFlagTimeout } from '../../utils/helpers/hooks';
|
||||
import Tag from '../../tags/helpers/Tag';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { ShortUrl } from '../data';
|
||||
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
|
||||
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
||||
|
@ -60,9 +58,7 @@ const ShortUrlsRow = (
|
|||
<td className="short-urls-row__cell" data-th="Short URL: ">
|
||||
<span className="indivisible short-urls-row__cell--relative">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
<CopyToClipboard text={shortUrl.shortUrl} onCopy={setCopiedToClipboard}>
|
||||
<FontAwesomeIcon icon={copyIcon} className="ml-2 short-urls-row__copy-btn" />
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||
Copied short URL!
|
||||
</span>
|
||||
|
|
4
src/utils/CopyToClipboardIcon.scss
Normal file
4
src/utils/CopyToClipboardIcon.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
.copy-to-clipboard-icon {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
16
src/utils/CopyToClipboardIcon.tsx
Normal file
16
src/utils/CopyToClipboardIcon.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import './CopyToClipboardIcon.scss';
|
||||
|
||||
interface CopyToClipboardIconProps {
|
||||
text: string;
|
||||
onCopy?: (text: string, result: boolean) => void;
|
||||
}
|
||||
|
||||
export const CopyToClipboardIcon: FC<CopyToClipboardIconProps> = ({ text, onCopy }) => (
|
||||
<CopyToClipboard text={text} onCopy={onCopy}>
|
||||
<FontAwesomeIcon icon={copyIcon} className="ml-2 copy-to-clipboard-icon" />
|
||||
</CopyToClipboard>
|
||||
);
|
19
src/utils/helpers/qrCodes.ts
Normal file
19
src/utils/helpers/qrCodes.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export interface QrCodeCapabilities {
|
||||
useSizeInPath: boolean;
|
||||
svgIsSupported: boolean;
|
||||
}
|
||||
|
||||
export type QrCodeFormat = 'svg' | 'png';
|
||||
|
||||
export const buildQrCodeUrl = (
|
||||
shortUrl: string,
|
||||
size: number,
|
||||
format: QrCodeFormat,
|
||||
{ useSizeInPath, svgIsSupported }: QrCodeCapabilities,
|
||||
): string => {
|
||||
const sizeFragment = useSizeInPath ? `/${size}` : `?size=${size}`;
|
||||
const formatFragment = !svgIsSupported ? '' : `format=${format}`;
|
||||
const joinSymbol = useSizeInPath && svgIsSupported ? '?' : !useSizeInPath && svgIsSupported ? '&' : '';
|
||||
|
||||
return `${shortUrl}/qr-code${sizeFragment}${joinSymbol}${formatFragment}`;
|
||||
};
|
|
@ -1,10 +1,12 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { ModalHeader } from 'reactstrap';
|
||||
import { Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import QrCodeModal 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';
|
||||
|
||||
describe('<QrCodeModal />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
|
@ -34,11 +36,46 @@ describe('<QrCodeModal />', () => {
|
|||
expect(externalLink.prop('href')).toEqual(shortUrl);
|
||||
});
|
||||
|
||||
it('displays an image with the QR code of the URL', () => {
|
||||
const wrapper = createWrapper();
|
||||
const img = wrapper.find('img');
|
||||
it.each([
|
||||
[ '2.3.0', '/qr-code/300' ],
|
||||
[ '2.4.0', '/qr-code/300?format=png' ],
|
||||
[ '2.5.0', '/qr-code?size=300&format=png' ],
|
||||
])('displays an image with the QR code of the URL', (version, expectedUrl) => {
|
||||
const wrapper = createWrapper(version);
|
||||
const modalBody = wrapper.find(ModalBody);
|
||||
const img = modalBody.find('img');
|
||||
const linkInBody = modalBody.find(ExternalLink);
|
||||
const copyToClipboard = modalBody.find(CopyToClipboardIcon);
|
||||
|
||||
expect(img).toHaveLength(1);
|
||||
expect(img.prop('src')).toEqual(`${shortUrl}/qr-code`);
|
||||
expect(img.prop('src')).toEqual(`${shortUrl}${expectedUrl}`);
|
||||
expect(linkInBody.prop('href')).toEqual(`${shortUrl}${expectedUrl}`);
|
||||
expect(copyToClipboard.prop('text')).toEqual(`${shortUrl}${expectedUrl}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 530, 'lg' ],
|
||||
[ 200, undefined ],
|
||||
[ 830, 'xl' ],
|
||||
])('renders expected size', (size, modalSize) => {
|
||||
const wrapper = createWrapper();
|
||||
const sizeInput = wrapper.find('.form-control-range');
|
||||
|
||||
sizeInput.simulate('change', { target: { value: `${size}` } });
|
||||
|
||||
expect(wrapper.find('.mt-2').text()).toEqual(`${size}x${size}`);
|
||||
expect(wrapper.find('label').text()).toEqual(`Size: ${size}px`);
|
||||
expect(wrapper.find(Modal).prop('size')).toEqual(modalSize);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ '2.3.0', 0, 'col-12' ],
|
||||
[ '2.4.0', 1, 'col-md-6' ],
|
||||
])('shows expected components based on server version', (version, expectedAmountOfDropdowns, expectedRangeClass) => {
|
||||
const wrapper = createWrapper(version);
|
||||
const dropdown = wrapper.find(DropdownBtn);
|
||||
const firstCol = wrapper.find(Row).find('div').first();
|
||||
|
||||
expect(dropdown).toHaveLength(expectedAmountOfDropdowns);
|
||||
expect(firstCol.prop('className')).toEqual(expectedRangeClass);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,13 +4,13 @@ import Moment from 'react-moment';
|
|||
import { assoc, toString } from 'ramda';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
|
||||
import Tag from '../../../src/tags/helpers/Tag';
|
||||
import ColorGenerator from '../../../src/utils/services/ColorGenerator';
|
||||
import { StateFlagTimeout } from '../../../src/utils/helpers/hooks';
|
||||
import { ShortUrl } from '../../../src/short-urls/data';
|
||||
import { ReachableServer } from '../../../src/servers/data';
|
||||
import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon';
|
||||
|
||||
describe('<ShortUrlsRow />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
|
@ -98,7 +98,7 @@ describe('<ShortUrlsRow />', () => {
|
|||
|
||||
it('updates state when copied to clipboard', () => {
|
||||
const col = wrapper.find('td').at(1);
|
||||
const menu = col.find(CopyToClipboard);
|
||||
const menu = col.find(CopyToClipboardIcon);
|
||||
|
||||
expect(menu).toHaveLength(1);
|
||||
expect(stateFlagTimeout).not.toHaveBeenCalled();
|
||||
|
|
|
@ -2,7 +2,6 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
|||
import { ButtonDropdown, DropdownItem } from 'reactstrap';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
|
||||
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
|
||||
import { ReachableServer } from '../../../src/servers/data';
|
||||
import { ShortUrl } from '../../../src/short-urls/data';
|
||||
|
||||
|
@ -12,6 +11,7 @@ describe('<ShortUrlsRowMenu />', () => {
|
|||
const EditTagsModal = () => null;
|
||||
const EditMetaModal = () => null;
|
||||
const EditShortUrlModal = () => null;
|
||||
const QrCodeModal = () => null;
|
||||
const selectedServer = Mock.of<ReachableServer>({ id: 'abc123' });
|
||||
const shortUrl = Mock.of<ShortUrl>({
|
||||
shortCode: 'abc123',
|
||||
|
@ -23,6 +23,7 @@ describe('<ShortUrlsRowMenu />', () => {
|
|||
EditTagsModal,
|
||||
EditMetaModal,
|
||||
EditShortUrlModal,
|
||||
QrCodeModal,
|
||||
() => null,
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in a new issue