diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4b3d18..76565154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * `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. +* [#469](https://github.com/shlinkio/shlink-web-client/pull/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher. ### Changed * *Nothing* diff --git a/package.json b/package.json index efe43b57..d2376a65 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "license": "MIT", "scripts": { "lint": "npm run lint:css && npm run lint:js", - "lint:js": "eslint --ext .js,.ts,.tsx src test", - "lint:js:fix": "npm run lint:js -- --fix", "lint:css": "stylelint src/*.scss src/**/*.scss", + "lint:js": "eslint --ext .js,.ts,.tsx src test", + "lint:fix": "npm run lint:css:fix && npm run lint:js:fix", "lint:css:fix": "npm run lint:css -- --fix", + "lint:js:fix": "npm run lint:js -- --fix", "start": "node scripts/start.js", "serve:build": "serve ./build", "build": "node scripts/build.js", diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index 9b43b58c..6521e5e1 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -1,37 +1,45 @@ import { FC, useMemo, useState } from 'react'; -import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap'; +import { Modal, 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'; import { SelectedServer } from '../../servers/data'; -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 { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; +import { + supportsQrCodeSizeInQuery, + supportsQrCodeSvgFormat, + supportsQrCodeMargin, + supportsQrErrorCorrection, +} from '../../utils/helpers/features'; import { ImageDownloader } from '../../common/services/ImageDownloader'; import { Versions } from '../../utils/helpers/version'; +import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import './QrCodeModal.scss'; +import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; interface QrCodeModalConnectProps extends ShortUrlModalProps { selectedServer: SelectedServer; } -const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC) => ( +const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC) => ( // eslint-disable-line { shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps, ) => { const [ size, setSize ] = useState(300); const [ margin, setMargin ] = useState(0); const [ format, setFormat ] = useState('png'); + const [ errorCorrection, setErrorCorrection ] = useState('L'); const capabilities: QrCodeCapabilities = useMemo(() => ({ useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer), svgIsSupported: supportsQrCodeSvgFormat(selectedServer), marginIsSupported: supportsQrCodeMargin(selectedServer), + errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer), }), [ selectedServer ]); const qrCodeUrl = useMemo( - () => buildQrCodeUrl(shortUrl, { size, format, margin }, capabilities), - [ shortUrl, size, format, margin, capabilities ], + () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), + [ shortUrl, size, format, margin, errorCorrection, capabilities ], ); const totalSize = useMemo(() => size + margin, [ size, margin ]); const modalSize = useMemo(() => { @@ -48,50 +56,48 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC{shortUrl} - -
+ - - + + setSize(Number(e.target.value))} + /> + + {capabilities.marginIsSupported && ( + + setSize(Number(e.target.value))} + value={margin} + step={1} + min={0} + max={100} + onChange={(e) => setMargin(Number(e.target.value))} /> -
- {capabilities.marginIsSupported && ( -
- - - setMargin(Number(e.target.value))} - /> - -
)} {capabilities.svgIsSupported && ( -
- - setFormat('png')}>PNG - setFormat('svg')}>SVG - -
+ + + + )} + {capabilities.errorCorrectionIsSupported && ( + + + )}
diff --git a/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx b/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx new file mode 100644 index 00000000..b6fd68b7 --- /dev/null +++ b/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; +import { DropdownItem } from 'reactstrap'; +import { DropdownBtn } from '../../../utils/DropdownBtn'; +import { QrErrorCorrection } from '../../../utils/helpers/qrCodes'; + +interface QrErrorCorrectionDropdownProps { + errorCorrection: QrErrorCorrection; + setErrorCorrection: (errorCorrection: QrErrorCorrection) => void; +} + +export const QrErrorCorrectionDropdown: FC = ( + { errorCorrection, setErrorCorrection }, +) => ( + + setErrorCorrection('L')}> + Low + + setErrorCorrection('M')}> + Medium + + setErrorCorrection('Q')}> + Quartile + + setErrorCorrection('H')}> + High + + +); diff --git a/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx b/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx new file mode 100644 index 00000000..3c9ca705 --- /dev/null +++ b/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import { DropdownItem } from 'reactstrap'; +import { DropdownBtn } from '../../../utils/DropdownBtn'; +import { QrCodeFormat } from '../../../utils/helpers/qrCodes'; + +interface QrFormatDropdownProps { + format: QrCodeFormat; + setFormat: (format: QrCodeFormat) => void; +} + +export const QrFormatDropdown: FC = ({ format, setFormat }) => ( + + setFormat('png')}>PNG + setFormat('svg')}>SVG + +); diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 9eb314f2..609c9e95 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -27,3 +27,5 @@ export const supportsTagsInPatch = supportsShortUrlTitle; export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); export const supportsCrawlableVisits = supportsBotVisits; + +export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' }); diff --git a/src/utils/helpers/qrCodes.ts b/src/utils/helpers/qrCodes.ts index 60342bd4..fb94350a 100644 --- a/src/utils/helpers/qrCodes.ts +++ b/src/utils/helpers/qrCodes.ts @@ -5,26 +5,31 @@ export interface QrCodeCapabilities { useSizeInPath: boolean; svgIsSupported: boolean; marginIsSupported: boolean; + errorCorrectionIsSupported: boolean; } export type QrCodeFormat = 'svg' | 'png'; +export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H'; + export interface QrCodeOptions { size: number; format: QrCodeFormat; margin: number; + errorCorrection: QrErrorCorrection; } export const buildQrCodeUrl = ( shortUrl: string, - { size, format, margin }: QrCodeOptions, - { useSizeInPath, svgIsSupported, marginIsSupported }: QrCodeCapabilities, + { size, format, margin, errorCorrection }: QrCodeOptions, + { useSizeInPath, svgIsSupported, marginIsSupported, errorCorrectionIsSupported }: QrCodeCapabilities, ): string => { const baseUrl = `${shortUrl}/qr-code${useSizeInPath ? `/${size}` : ''}`; const query = stringifyQuery({ size: useSizeInPath ? undefined : size, format: svgIsSupported ? format : undefined, margin: marginIsSupported && margin > 0 ? margin : undefined, + errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined, }); return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`; diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx index 9cdf1bb7..aa8b5160 100644 --- a/test/short-urls/helpers/QrCodeModal.test.tsx +++ b/test/short-urls/helpers/QrCodeModal.test.tsx @@ -1,14 +1,15 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { ExternalLink } from 'react-external-link'; -import { Button, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; +import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; import { Mock } from 'ts-mockery'; 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'; +import { QrFormatDropdown } from '../../../src/short-urls/helpers/qr-codes/QrFormatDropdown'; +import { QrErrorCorrectionDropdown } from '../../../src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown'; describe('', () => { let wrapper: ShallowWrapper; @@ -48,6 +49,7 @@ describe('', () => { [ '2.5.0' as SemVer, 0, '/qr-code?size=300&format=png' ], [ '2.6.0' as SemVer, 0, '/qr-code?size=300&format=png' ], [ '2.6.0' as SemVer, 10, '/qr-code?size=300&format=png&margin=10' ], + [ '2.8.0' as SemVer, 0, '/qr-code?size=300&format=png&errorCorrection=L' ], ])('displays an image with the QR code of the URL', (version, margin, expectedUrl) => { const wrapper = createWrapper(version); const formControls = wrapper.find('.form-control-range'); @@ -91,12 +93,13 @@ describe('', () => { [ '2.3.0' as SemVer, 0, 'col-12' ], [ '2.4.0' as SemVer, 1, 'col-md-6' ], [ '2.6.0' as SemVer, 1, 'col-md-4' ], + [ '2.8.0' as SemVer, 2, '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(); + const dropdownsLength = wrapper.find(QrFormatDropdown).length + wrapper.find(QrErrorCorrectionDropdown).length; + const firstCol = wrapper.find(Row).find(FormGroup).first(); - expect(dropdown).toHaveLength(expectedAmountOfDropdowns); + expect(dropdownsLength).toEqual(expectedAmountOfDropdowns); expect(firstCol.prop('className')).toEqual(expectedRangeClass); }); diff --git a/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx b/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx new file mode 100644 index 00000000..32db92f8 --- /dev/null +++ b/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx @@ -0,0 +1,47 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { QrErrorCorrection } from '../../../../src/utils/helpers/qrCodes'; +import { QrErrorCorrectionDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown'; + +describe('', () => { + const initialErrorCorrection: QrErrorCorrection = 'Q'; + const setErrorCorrection = jest.fn(); + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow( + , + ); + }); + + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); + + it('renders initial state', () => { + const items = wrapper.find(DropdownItem); + + expect(wrapper.prop('text')).toEqual('Error correction (Q)'); + expect(items.at(0).prop('active')).toEqual(false); + expect(items.at(1).prop('active')).toEqual(false); + expect(items.at(2).prop('active')).toEqual(true); + expect(items.at(3).prop('active')).toEqual(false); + }); + + it('invokes callback when items are clicked', () => { + const items = wrapper.find(DropdownItem); + + expect(setErrorCorrection).not.toHaveBeenCalled(); + + items.at(0).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('L'); + + items.at(1).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('M'); + + items.at(2).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('Q'); + + items.at(3).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('H'); + }); +}); diff --git a/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx b/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx new file mode 100644 index 00000000..c40e05d0 --- /dev/null +++ b/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx @@ -0,0 +1,37 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { QrCodeFormat } from '../../../../src/utils/helpers/qrCodes'; +import { QrFormatDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrFormatDropdown'; + +describe('', () => { + const initialFormat: QrCodeFormat = 'svg'; + const setFormat = jest.fn(); + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); + + it('renders initial state', () => { + const items = wrapper.find(DropdownItem); + + expect(wrapper.prop('text')).toEqual('Format (svg)'); + expect(items.at(0).prop('active')).toEqual(false); + expect(items.at(1).prop('active')).toEqual(true); + }); + + it('invokes callback when items are clicked', () => { + const items = wrapper.find(DropdownItem); + + expect(setFormat).not.toHaveBeenCalled(); + + items.at(0).simulate('click'); + expect(setFormat).toHaveBeenCalledWith('png'); + + items.at(1).simulate('click'); + expect(setFormat).toHaveBeenCalledWith('svg'); + }); +}); diff --git a/test/utils/helpers/qrCodes.test.ts b/test/utils/helpers/qrCodes.test.ts index 12f1afdd..cf77e34f 100644 --- a/test/utils/helpers/qrCodes.test.ts +++ b/test/utils/helpers/qrCodes.test.ts @@ -1,68 +1,74 @@ -import { buildQrCodeUrl, QrCodeFormat } from '../../../src/utils/helpers/qrCodes'; +import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../../src/utils/helpers/qrCodes'; describe('qrCodes', () => { describe('buildQrCodeUrl', () => { test.each([ [ 'foo.com', - { size: 530, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false }, + { size: 530, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.com/qr-code/530?format=svg', ], [ 'foo.com', - { size: 530, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false }, + { size: 530, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.com/qr-code/530?format=png', ], [ 'bar.io', - { size: 870, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: false, svgIsSupported: false, marginIsSupported: false }, + { size: 870, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: false, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=870', ], [ 'bar.io', - { size: 200, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false }, + { size: 200, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=200&format=png', ], [ 'bar.io', - { size: 200, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false }, + { size: 200, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=200&format=svg', ], [ 'foo.net', - { size: 480, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false }, + { size: 480, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.net/qr-code/480', ], [ 'foo.net', - { size: 480, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false }, + { size: 480, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.net/qr-code/480', ], [ 'shlink.io', - { size: 123, format: 'svg' as QrCodeFormat, margin: 10 }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false }, + { size: 123, format: 'svg' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/123', ], [ 'shlink.io', - { size: 456, format: 'png' as QrCodeFormat, margin: 10 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true }, + { size: 456, format: 'png' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/456?format=png&margin=10', ], [ 'shlink.io', - { size: 456, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true }, + { size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/456?format=png', ], + [ + 'shlink.io', + { size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'H' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: true }, + 'shlink.io/qr-code/456?format=png&errorCorrection=H', + ], ])('builds expected URL based in params', (shortUrl, options, capabilities, expectedUrl) => { expect(buildQrCodeUrl(shortUrl, options, capabilities)).toEqual(expectedUrl); });