('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, capabilities),
+ [ shortUrl, size, format, capabilities ],
+ );
+ const modalSize = useMemo(() => {
+ if (size < 500) {
+ return undefined;
+ }
+
+ return size < 800 ? 'lg' : 'xl';
+ }, [ size ]);
+
+ return (
+
+
+ QR code for {shortUrl}
+
+
+
+
+
+
+ setSize(Number(e.target.value))}
+ />
+
+
+ {capabilities.svgIsSupported && (
+
+
+ setFormat('png')}>PNG
+ setFormat('svg')}>SVG
+
+
+ )}
+
+
+
+

+
{size}x{size}
+
+
+
+ );
+};
export default QrCodeModal;
diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss
index 6cf6ee42..89255646 100644
--- a/src/short-urls/helpers/ShortUrlsRow.scss
+++ b/src/short-urls/helpers/ShortUrlsRow.scss
@@ -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));
diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx
index 1399099b..5551938c 100644
--- a/src/short-urls/helpers/ShortUrlsRow.tsx
+++ b/src/short-urls/helpers/ShortUrlsRow.tsx
@@ -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 = (
-
-
-
+
Copied short URL!
diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.tsx b/src/short-urls/helpers/ShortUrlsRowMenu.tsx
index 18d711fc..e867473d 100644
--- a/src/short-urls/helpers/ShortUrlsRowMenu.tsx
+++ b/src/short-urls/helpers/ShortUrlsRowMenu.tsx
@@ -14,7 +14,6 @@ import { useToggle } from '../../utils/helpers/hooks';
import { ShortUrl, ShortUrlModalProps } from '../data';
import { Versions } from '../../utils/helpers/version';
import { SelectedServer } from '../../servers/data';
-import QrCodeModal from './QrCodeModal';
import VisitStatsLink from './VisitStatsLink';
import './ShortUrlsRowMenu.scss';
@@ -29,6 +28,7 @@ const ShortUrlsRowMenu = (
EditTagsModal: ShortUrlModal,
EditMetaModal: ShortUrlModal,
EditShortUrlModal: ShortUrlModal,
+ QrCodeModal: ShortUrlModal,
ForServerVersion: FC,
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
const [ isOpen, toggle ] = useToggle();
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts
index 96a1139c..93d987b0 100644
--- a/src/short-urls/services/provideServices.ts
+++ b/src/short-urls/services/provideServices.ts
@@ -19,6 +19,7 @@ import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
import { editShortUrl } from '../reducers/shortUrlEdition';
import { ConnectDecorator } from '../../container/types';
import { ShortUrlsTable } from '../ShortUrlsTable';
+import QrCodeModal from '../helpers/QrCodeModal';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
@@ -40,6 +41,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'EditTagsModal',
'EditMetaModal',
'EditShortUrlModal',
+ 'QrCodeModal',
'ForServerVersion',
);
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
@@ -69,6 +71,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
+ bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
+ bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
+
// Services
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
diff --git a/src/utils/CopyToClipboardIcon.scss b/src/utils/CopyToClipboardIcon.scss
new file mode 100644
index 00000000..a9e958e9
--- /dev/null
+++ b/src/utils/CopyToClipboardIcon.scss
@@ -0,0 +1,4 @@
+.copy-to-clipboard-icon {
+ cursor: pointer;
+ font-size: 1.2rem;
+}
diff --git a/src/utils/CopyToClipboardIcon.tsx b/src/utils/CopyToClipboardIcon.tsx
new file mode 100644
index 00000000..46d12a8c
--- /dev/null
+++ b/src/utils/CopyToClipboardIcon.tsx
@@ -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 = ({ text, onCopy }) => (
+
+
+
+);
diff --git a/src/utils/helpers/qrCodes.ts b/src/utils/helpers/qrCodes.ts
new file mode 100644
index 00000000..13b298ca
--- /dev/null
+++ b/src/utils/helpers/qrCodes.ts
@@ -0,0 +1,25 @@
+import { always, cond } from 'ramda';
+
+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 joinSymbolResolver = cond([
+ [ () => useSizeInPath && svgIsSupported, always('?') ],
+ [ () => !useSizeInPath && svgIsSupported, always('&') ],
+ ]);
+ const joinSymbol = joinSymbolResolver() ?? '';
+
+ return `${shortUrl}/qr-code${sizeFragment}${joinSymbol}${formatFragment}`;
+};
diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx
index 93541d60..924c8cb3 100644
--- a/test/short-urls/helpers/QrCodeModal.test.tsx
+++ b/test/short-urls/helpers/QrCodeModal.test.tsx
@@ -1,29 +1,81 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { ExternalLink } from 'react-external-link';
+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('', () => {
let wrapper: ShallowWrapper;
const shortUrl = 'https://doma.in/abc123';
+ const createWrapper = (version = '2.5.0') => {
+ const selectedServer = Mock.of({ version });
- beforeEach(() => {
- wrapper = shallow(({ shortUrl })} isOpen={true} toggle={() => {}} />);
- });
- afterEach(() => wrapper.unmount());
+ wrapper = shallow(
+ ({ shortUrl })}
+ isOpen={true}
+ toggle={() => {}}
+ selectedServer={selectedServer}
+ />,
+ );
- it('shows an external link to the URL', () => {
- const externalLink = wrapper.find(ExternalLink);
+ return wrapper;
+ };
+
+ afterEach(() => wrapper?.unmount());
+
+ it('shows an external link to the URL in the header', () => {
+ const wrapper = createWrapper();
+ const externalLink = wrapper.find(ModalHeader).find(ExternalLink);
expect(externalLink).toHaveLength(1);
expect(externalLink.prop('href')).toEqual(shortUrl);
});
- it('displays an image with the QR code of the URL', () => {
- 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);
});
});
diff --git a/test/short-urls/helpers/ShortUrlsRow.test.tsx b/test/short-urls/helpers/ShortUrlsRow.test.tsx
index c4f565a9..e5429a7c 100644
--- a/test/short-urls/helpers/ShortUrlsRow.test.tsx
+++ b/test/short-urls/helpers/ShortUrlsRow.test.tsx
@@ -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('', () => {
let wrapper: ShallowWrapper;
@@ -98,7 +98,7 @@ describe('', () => {
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();
diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx
index 66af635b..3e0cfd5d 100644
--- a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx
+++ b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx
@@ -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('', () => {
const EditTagsModal = () => null;
const EditMetaModal = () => null;
const EditShortUrlModal = () => null;
+ const QrCodeModal = () => null;
const selectedServer = Mock.of({ id: 'abc123' });
const shortUrl = Mock.of({
shortCode: 'abc123',
@@ -23,6 +23,7 @@ describe('', () => {
EditTagsModal,
EditMetaModal,
EditShortUrlModal,
+ QrCodeModal,
() => null,
);
diff --git a/test/utils/CopyToClipboardIcon.test.tsx b/test/utils/CopyToClipboardIcon.test.tsx
new file mode 100644
index 00000000..9f0d709b
--- /dev/null
+++ b/test/utils/CopyToClipboardIcon.test.tsx
@@ -0,0 +1,27 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
+import { CopyToClipboardIcon } from '../../src/utils/CopyToClipboardIcon';
+
+describe('', () => {
+ let wrapper: ShallowWrapper;
+ const onCopy = () => {};
+
+ beforeEach(() => {
+ wrapper = shallow();
+ });
+ afterEach(() => wrapper?.unmount());
+
+ test('expected components are wrapped', () => {
+ const copyToClipboard = wrapper.find(CopyToClipboard);
+ const icon = wrapper.find(FontAwesomeIcon);
+
+ expect(copyToClipboard).toHaveLength(1);
+ expect(copyToClipboard.prop('text')).toEqual('foo');
+ expect(copyToClipboard.prop('onCopy')).toEqual(onCopy);
+ expect(icon).toHaveLength(1);
+ expect(icon.prop('icon')).toEqual(copyIcon);
+ expect(icon.prop('className')).toEqual('ml-2 copy-to-clipboard-icon');
+ });
+});
diff --git a/test/utils/DropdownBtn.test.tsx b/test/utils/DropdownBtn.test.tsx
index 79722689..2076f6ec 100644
--- a/test/utils/DropdownBtn.test.tsx
+++ b/test/utils/DropdownBtn.test.tsx
@@ -6,7 +6,7 @@ import { DropdownBtn, DropdownBtnProps } from '../../src/utils/DropdownBtn';
describe('', () => {
let wrapper: ShallowWrapper;
const createWrapper = (props: PropsWithChildren) => {
- wrapper = shallow();
+ wrapper = shallow();
return wrapper;
};
@@ -17,7 +17,7 @@ describe('', () => {
const wrapper = createWrapper({ text });
const toggle = wrapper.find(DropdownToggle);
- expect(toggle.html()).toContain(text);
+ expect(toggle.prop('children')).toContain(text);
});
it.each([[ 'foo' ], [ 'bar' ], [ 'baz' ]])('displays provided children', (children) => {
diff --git a/test/utils/SortingDropdown.test.tsx b/test/utils/SortingDropdown.test.tsx
index 6b3f9682..4f85c013 100644
--- a/test/utils/SortingDropdown.test.tsx
+++ b/test/utils/SortingDropdown.test.tsx
@@ -76,21 +76,22 @@ describe('', () => {
});
it.each([
- [{ isButton: false }, '>Order by<' ],
- [{ isButton: true }, '>Order by...<' ],
+ [{ isButton: false }, <>Order by> ],
+ [{ isButton: true }, <>Order by...> ],
[
{ isButton: true, orderField: 'foo', orderDir: 'ASC' as OrderDir },
- 'Order by: "Foo" - "ASC"',
+ 'Order by: "Foo" - "ASC"',
],
[
{ isButton: true, orderField: 'baz', orderDir: 'DESC' as OrderDir },
- 'Order by: "Hello World" - "DESC"',
+ 'Order by: "Hello World" - "DESC"',
],
- [{ isButton: true, orderField: 'baz' }, 'Order by: "Hello World" - "DESC"' ],
+ [{ isButton: true, orderField: 'baz' }, 'Order by: "Hello World" - "DESC"' ],
])('displays expected text in toggle', (props, expectedText) => {
const wrapper = createWrapper(props);
const toggle = wrapper.find(DropdownToggle);
+ const [ children ] = (toggle.prop('children') as any[]).filter(Boolean);
- expect(toggle.html()).toContain(expectedText);
+ expect(children).toEqual(expectedText);
});
});
diff --git a/test/utils/helpers/qrCodes.test.ts b/test/utils/helpers/qrCodes.test.ts
new file mode 100644
index 00000000..fe0a0f76
--- /dev/null
+++ b/test/utils/helpers/qrCodes.test.ts
@@ -0,0 +1,17 @@
+import { buildQrCodeUrl, QrCodeFormat } from '../../../src/utils/helpers/qrCodes';
+
+describe('qrCodes', () => {
+ describe('buildQrCodeUrl', () => {
+ test.each([
+ [ 'foo.com', 530, 'svg', { useSizeInPath: true, svgIsSupported: true }, 'foo.com/qr-code/530?format=svg' ],
+ [ 'foo.com', 530, 'png', { useSizeInPath: true, svgIsSupported: true }, 'foo.com/qr-code/530?format=png' ],
+ [ 'bar.io', 870, 'svg', { useSizeInPath: false, svgIsSupported: false }, 'bar.io/qr-code?size=870' ],
+ [ 'bar.io', 200, 'png', { useSizeInPath: false, svgIsSupported: true }, 'bar.io/qr-code?size=200&format=png' ],
+ [ 'bar.io', 200, 'svg', { useSizeInPath: false, svgIsSupported: true }, 'bar.io/qr-code?size=200&format=svg' ],
+ [ 'foo.net', 480, 'png', { useSizeInPath: true, svgIsSupported: false }, 'foo.net/qr-code/480' ],
+ [ 'foo.net', 480, 'svg', { useSizeInPath: true, svgIsSupported: false }, 'foo.net/qr-code/480' ],
+ ])('builds expected URL based in params', (shortUrl, size, format, capabilities, expectedUrl) => {
+ expect(buildQrCodeUrl(shortUrl, size, format as QrCodeFormat, capabilities)).toEqual(expectedUrl);
+ });
+ });
+});
|