diff --git a/CHANGELOG.md b/CHANGELOG.md index da1228f8..0ff5b77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* [#379](https://github.com/shlinkio/shlink-web-client/issues/379) Improved QR code modal, including controls to customize size and format, as well as a button to copy the link to the clipboard. + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [3.0.1] - 2020-12-30 ### Added * *Nothing* diff --git a/src/servers/services/ServersImporter.ts b/src/servers/services/ServersImporter.ts index 03c1ca25..7dcf8175 100644 --- a/src/servers/services/ServersImporter.ts +++ b/src/servers/services/ServersImporter.ts @@ -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'; } diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index 02a57e7a..8f968c87 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -1,19 +1,79 @@ -import { Modal, ModalBody, ModalHeader } from 'reactstrap'; +import { useMemo, useState } from 'react'; +import { DropdownItem, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; import { ExternalLink } from 'react-external-link'; 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'; -const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen }: ShortUrlModalProps) => ( - - - QR code for {shortUrl} - - -
- QR code -
-
-
-); +interface QrCodeModalConnectProps extends ShortUrlModalProps { + selectedServer: ReachableServer; +} + +const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => { + const [ size, setSize ] = useState(300); + const [ format, setFormat ] = useState('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 + +
+ )} +
+
+
+
QR code URL:
+ + +
+ QR code +
{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 = ( - - - + 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); + }); + }); +});