diff --git a/packages/form-compiler/src/inputCompiler/index.ts b/packages/form-compiler/src/inputCompiler/index.ts index bc76824d..bfc1926d 100644 --- a/packages/form-compiler/src/inputCompiler/index.ts +++ b/packages/form-compiler/src/inputCompiler/index.ts @@ -17,6 +17,7 @@ import { MarkdownInputCompiler } from './markdownInputCompiler'; import { OptionsInputCompiler } from './optionsInputCompiler'; import { SignatureInputCompiler } from './signatureInputCompiler'; import { SampleContainerInputCompiler } from './sampleContainerInputCompiler'; +import { QRScannerInputCompiler } from './qrScannerInputCompiler'; export { AbstractInputCompiler, @@ -33,6 +34,8 @@ export { BooleanInputCompiler, DateTimeInputCompiler, SignatureInputCompiler, + QRScannerInputCompiler, + SampleContainerInputCompiler, }; export const inputCompilers: InputCompiler[] = [ @@ -49,6 +52,7 @@ export const inputCompilers: InputCompiler[] = [ new CountryInputCompiler(), new BooleanInputCompiler(), new DateTimeInputCompiler(), + new QRScannerInputCompiler(), new SampleContainerInputCompiler(), new SvgMapInputCompiler(), new SignatureInputCompiler(), diff --git a/packages/form-compiler/src/inputCompiler/qrScannerInputCompiler.ts b/packages/form-compiler/src/inputCompiler/qrScannerInputCompiler.ts new file mode 100644 index 00000000..44dd4178 --- /dev/null +++ b/packages/form-compiler/src/inputCompiler/qrScannerInputCompiler.ts @@ -0,0 +1,24 @@ +import { InputCompiler } from '../interfaces'; +import { JsonSchema, UISchemaElement } from '@jsonforms/core'; +import { Form, Input, InputType, Section } from '@eresearchqut/form-definition'; +import { AbstractInputCompiler } from './abstractInputCompiler'; + +export class QRScannerInputCompiler extends AbstractInputCompiler implements InputCompiler { + supports(form: Form, section: Section, input: Input): boolean { + return input.type === InputType.QR_SCANNER; + } + + schema(form: Form, section: Section, input: Input): JsonSchema { + return { + type: 'object', + properties: { + autoStartScanning: { type: 'boolean' }, + videoMaxWidthPx: { type: 'number' }, + }, + } as JsonSchema; + } + + ui(form: Form, section: Section, input: Input): UISchemaElement | undefined { + return this.uiControl(form, section, input); + } +} diff --git a/packages/form-components/package.json b/packages/form-components/package.json index a3188efe..ee8f2bc3 100644 --- a/packages/form-components/package.json +++ b/packages/form-components/package.json @@ -34,6 +34,7 @@ "primeflex": "2.0.0", "primeicons": "5.0.0", "primereact": "7.1.0", + "qr-scanner": "^1.4.1", "react": "17.0.2", "react-dom": "17.0.2", "react-img-mapper": "1.2.2", diff --git a/packages/form-components/src/component/QRCode/ReactQRScanner.story.tsx b/packages/form-components/src/component/QRCode/ReactQRScanner.story.tsx new file mode 100644 index 00000000..6373f026 --- /dev/null +++ b/packages/form-components/src/component/QRCode/ReactQRScanner.story.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { Meta, Story } from '@storybook/react'; +import ReactQRScanner, { ReactQRScannerProps } from './ReactQRScanner'; + +export default { + title: 'Components/QR Scanner', + component: ReactQRScanner, +} as Meta; + +const Template: Story = (props) => ; + +export const NoAutostart = Template.bind({}); +NoAutostart.args = { autoStartScanning: false }; + +export const SmallerVideo = Template.bind({}); +SmallerVideo.args = { videoMaxWidthPx: 320 }; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/packages/form-components/src/component/QRCode/ReactQRScanner.tsx b/packages/form-components/src/component/QRCode/ReactQRScanner.tsx new file mode 100644 index 00000000..41798513 --- /dev/null +++ b/packages/form-components/src/component/QRCode/ReactQRScanner.tsx @@ -0,0 +1,169 @@ +import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; +import QrScanner from 'qr-scanner'; +import { ToggleButton } from 'primereact/togglebutton'; +import { Dropdown } from 'primereact/dropdown'; + +const GENERIC_CAMERAS: QrScanner.Camera[] = [ + { id: 'environment', label: 'Back Camera' }, + { id: 'user', label: 'Front Camera' }, +]; +const DEFAULT_CAMERA = GENERIC_CAMERAS[0]; + +export interface ReactQRScannerProps { + autoStartScanning?: boolean; + videoMaxWidthPx?: number | null; + onQRCodeScanned?: (qrCode: string) => void; +} + +export const ReactQRScanner: FunctionComponent = ({ + autoStartScanning = true, + videoMaxWidthPx = null, + onQRCodeScanned = (qrCode) => { + console.log(`Scanned QR Code: ${qrCode}`); + }, +}: ReactQRScannerProps) => { + const videoRef = useRef(null); + const qrScannerRef = useRef(null); + const [availableCameras, setAvailableCameras] = useState([]); + const [selectedCamera, setSelectedCamera] = useState(DEFAULT_CAMERA); + const [lastQRCode, setLastQRCode] = useState(null); + const [isStopped, setIsStopped] = useState(!autoStartScanning); + + async function initQrScanner(video: HTMLVideoElement) { + qrScannerRef.current = new QrScanner(video, onScanned, { + onDecodeError: onScanError, + calculateScanRegion: calculateScanRegion, + highlightScanRegion: true, + highlightCodeOutline: true, + }); + qrScannerRef.current?.setInversionMode('both'); + } + + function cleanUpQrScanner() { + qrScannerRef.current?.stop(); + qrScannerRef.current?.destroy(); + qrScannerRef.current = null; + } + + async function startQrScanner() { + if (qrScannerRef.current === null) return; + try { + await qrScannerRef.current.start(); + console.log('QrScanner started'); + const cameras = await QrScanner.listCameras(true); + setAvailableCameras(cameras); + } catch (e) { + console.log(`ERROR while starting QR Scanner: ${e}`); + } + } + + function calculateScanRegion(video: HTMLVideoElement) { + const smallestDimension = Math.min(video.videoWidth, video.videoHeight); + // Original code: the scan region is two thirds of the smallest dimension of the video. + // const scanRegionSize = Math.round((2 / 3) * smallestDimension); + // We are going to go larger and use a scan region of 90% of the smallest dimension of the video. + const scanRegionSize = Math.round(smallestDimension * 0.9); + const legacyCanvasSize = 400; + return { + x: Math.round((video.videoWidth - scanRegionSize) / 2), + y: Math.round((video.videoHeight - scanRegionSize) / 2), + width: scanRegionSize, + height: scanRegionSize, + downScaledWidth: legacyCanvasSize, + downScaledHeight: legacyCanvasSize, + }; + } + + /* Init/Destroy to be called on mount/unmount */ + useEffect(() => { + if (videoRef.current === null) return; + if (qrScannerRef.current !== null) return; + initQrScanner(videoRef.current); + return () => { + cleanUpQrScanner(); + }; + }, []); + + /* Start/Stop called when isStopped state changes */ + useEffect(() => { + if (isStopped) { + qrScannerRef.current?.stop(); + } else { + startQrScanner(); + } + }, [isStopped]); + + useEffect(() => { + if (selectedCamera !== null) { + qrScannerRef.current?.setCamera(selectedCamera.id); + } + }, [selectedCamera]); + + function onScanned(qrCodeData: QrScanner.ScanResult) { + const qrCode = qrCodeData.data; + if (qrCode === '' || qrCode === lastQRCode) { + return; + } + setLastQRCode(qrCode); + } + + function onScanError(error: Error | string) { + if (error === QrScanner.NO_QR_CODE_FOUND) return; + console.error('Scanning ERROR:', error); + } + + /* + Invoke the onQRCodeScanned action only when a new QR Code has been scanned. + The QrScanner component keeps re-calling the 'onScanned' callback above with the same QR code while the QR Code can be scanned. + We don't want to replicate this behaviour, so we maintain the lastQRCode that was scanned and emit an event only when a new QR Code was scanned. */ + useEffect(() => { + if (!lastQRCode) return; + onQRCodeScanned(lastQRCode); + }, [onQRCodeScanned, lastQRCode]); + + function startOrStop() { + setIsStopped(!isStopped); + } + + const possibleCameras = [ + { label: 'Generic', items: GENERIC_CAMERAS }, + { label: 'Specific', items: availableCameras }, + ]; + + const videoStyle: React.CSSProperties = {}; + if (videoMaxWidthPx) { + videoStyle['maxWidth'] = videoMaxWidthPx; + } + + return ( +
+
+ {availableCameras && ( + setSelectedCamera(e.value)} + /> + )} +
+ +
+ +
+
+ ); +}; + +export default ReactQRScanner; diff --git a/packages/form-components/src/controls/QRCode/QRScannerControl.tsx b/packages/form-components/src/controls/QRCode/QRScannerControl.tsx new file mode 100644 index 00000000..38b3bf36 --- /dev/null +++ b/packages/form-components/src/controls/QRCode/QRScannerControl.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { and, ControlProps, ControlState, optionIs, RankedTester, rankWith, uiTypeIs } from '@jsonforms/core'; +import { Control, withJsonFormsControlProps } from '@jsonforms/react'; + +import { ReactQRScanner } from '../../component/QRCode/ReactQRScanner'; + +export class QRScannerControl extends Control { + render() { + const { data } = this.props; + const { fps, autoStartScanning } = data; + + return ( +
+ +
+ ); + } +} + +export const isQRScannerControl = and(uiTypeIs('Control'), optionIs('type', 'qr-scanner')); + +export const qrScannerControlTester: RankedTester = rankWith(2, isQRScannerControl); +export default withJsonFormsControlProps(QRScannerControl); diff --git a/packages/form-components/src/controls/index.ts b/packages/form-components/src/controls/index.ts index 789d71c2..42112e05 100644 --- a/packages/form-components/src/controls/index.ts +++ b/packages/form-components/src/controls/index.ts @@ -1,6 +1,7 @@ import InputControl, { inputControlTester } from './InputControl'; import InputBooleanControl, { inputBooleanControlTester } from './InputBooleanControl'; import SvgMapControl, { svgMapControlTester } from './SvgMapControl'; +import QRScannerControl, { qrScannerControlTester } from './QRCode/QRScannerControl'; import SampleContainerControl, { sampleContainerControlTester } from './Biobank/SampleContainerControl'; export { @@ -12,4 +13,6 @@ export { svgMapControlTester, SampleContainerControl, sampleContainerControlTester, + QRScannerControl, + qrScannerControlTester, }; diff --git a/packages/form-components/src/index.ts b/packages/form-components/src/index.ts index b65b579d..b8d8eb6d 100644 --- a/packages/form-components/src/index.ts +++ b/packages/form-components/src/index.ts @@ -9,6 +9,8 @@ import { svgMapControlTester, SampleContainerControl, sampleContainerControlTester, + QRScannerControl, + qrScannerControlTester, } from './controls'; import { CategorizationLayout, @@ -54,7 +56,6 @@ import { InputTextCell, inputTextCellTester, } from './cells'; -import { sample } from 'lodash'; export * from './controls'; export * from './cells'; @@ -66,6 +67,7 @@ export const renderers: { tester: RankedTester; renderer: any }[] = [ { tester: inputBooleanControlTester, renderer: InputBooleanControl }, { tester: svgMapControlTester, renderer: SvgMapControl }, { tester: sampleContainerControlTester, renderer: SampleContainerControl }, + { tester: qrScannerControlTester, renderer: QRScannerControl }, { tester: verticalLayoutTester, renderer: VerticalLayout }, { tester: categorizationLayoutTester, renderer: CategorizationLayout }, { tester: categoryLayoutTester, renderer: CategoryLayout }, diff --git a/packages/form-definition/src/interfaces/input.ts b/packages/form-definition/src/interfaces/input.ts index 5f772cd0..ef7b2299 100644 --- a/packages/form-definition/src/interfaces/input.ts +++ b/packages/form-definition/src/interfaces/input.ts @@ -19,6 +19,7 @@ export enum InputType { MULTILINE_TEXT = 'multiline-text', NUMERIC = 'numeric', OPTIONS = 'options', + QR_SCANNER = 'qr-scanner', RANGE = 'range', SAMPLE_CONTAINER = 'biobank-sample-container', SIGNATURE = 'signature', @@ -103,15 +104,14 @@ export interface OptionsInput extends AbstractInput { /** * Option values and labels */ - options: - { - /** - * @format uuid - */ - id: string; - label: string; - value: number | string; - }[]; + options: { + /** + * @format uuid + */ + id: string; + label: string; + value: number | string; + }[]; } /** @@ -289,6 +289,14 @@ export interface CurrencyInput extends AbstractInput { maximum?: number; } +/** + * @title QR Scanner + * + */ +export interface QRScannerInput extends AbstractInput { + type: InputType.QR_SCANNER; +} + /** * @title Sample Container * @@ -331,6 +339,7 @@ export type Input = | MultilineTextInput | NumericInput | OptionsInput + | QRScannerInput | RangeInput | Signature | SampleContainerInput diff --git a/packages/form-definition/src/schema/form.json b/packages/form-definition/src/schema/form.json index 67bcf302..8c33cbc0 100644 --- a/packages/form-definition/src/schema/form.json +++ b/packages/form-definition/src/schema/form.json @@ -442,6 +442,9 @@ { "$ref": "#/definitions/CurrencyInput" }, + { + "$ref": "#/definitions/QRScannerInput" + }, { "$ref": "#/definitions/SampleContainerInput" }, @@ -757,6 +760,47 @@ "title": "Options", "type": "object" }, + "QRScannerInput": { + "properties": { + "countsToProgress": { + "description": "Does the input count to progress when completed", + "type": "boolean" + }, + "description": { + "description": "Description text can be used to provide more context that will help the user successfully complete the entry.", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "label": { + "description": "Label can be provided to override the name", + "type": "string" + }, + "name": { + "description": "The name of the element", + "type": "string" + }, + "required": { + "description": "Can the element be flagged as required", + "type": "boolean" + }, + "type": { + "enum": [ + "qr-scanner" + ], + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "QR Scanner", + "type": "object" + }, "RangeInput": { "properties": { "countsToProgress": { diff --git a/packages/form-definition/src/schema/input.json b/packages/form-definition/src/schema/input.json index 889cce12..b13c56ac 100644 --- a/packages/form-definition/src/schema/input.json +++ b/packages/form-definition/src/schema/input.json @@ -46,6 +46,9 @@ { "$ref": "#/definitions/CurrencyInput" }, + { + "$ref": "#/definitions/QRScannerInput" + }, { "$ref": "#/definitions/SampleContainerInput" }, @@ -718,6 +721,47 @@ "title": "Options", "type": "object" }, + "QRScannerInput": { + "properties": { + "countsToProgress": { + "description": "Does the input count to progress when completed", + "type": "boolean" + }, + "description": { + "description": "Description text can be used to provide more context that will help the user successfully complete the entry.", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "label": { + "description": "Label can be provided to override the name", + "type": "string" + }, + "name": { + "description": "The name of the element", + "type": "string" + }, + "required": { + "description": "Can the element be flagged as required", + "type": "boolean" + }, + "type": { + "enum": [ + "qr-scanner" + ], + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "QR Scanner", + "type": "object" + }, "RangeInput": { "properties": { "countsToProgress": { diff --git a/packages/form-definition/src/schema/section.json b/packages/form-definition/src/schema/section.json index 9cab36eb..9a926f67 100644 --- a/packages/form-definition/src/schema/section.json +++ b/packages/form-definition/src/schema/section.json @@ -662,6 +662,47 @@ "title": "Options", "type": "object" }, + "QRScannerInput": { + "properties": { + "countsToProgress": { + "description": "Does the input count to progress when completed", + "type": "boolean" + }, + "description": { + "description": "Description text can be used to provide more context that will help the user successfully complete the entry.", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "label": { + "description": "Label can be provided to override the name", + "type": "string" + }, + "name": { + "description": "The name of the element", + "type": "string" + }, + "required": { + "description": "Can the element be flagged as required", + "type": "boolean" + }, + "type": { + "enum": [ + "qr-scanner" + ], + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "QR Scanner", + "type": "object" + }, "RangeInput": { "properties": { "countsToProgress": { @@ -1028,6 +1069,9 @@ { "$ref": "#/definitions/CurrencyInput" }, + { + "$ref": "#/definitions/QRScannerInput" + }, { "$ref": "#/definitions/SampleContainerInput" }, diff --git a/packages/form-designer/src/component/Component.tsx b/packages/form-designer/src/component/Component.tsx index 9f9732e9..a6108d96 100644 --- a/packages/form-designer/src/component/Component.tsx +++ b/packages/form-designer/src/component/Component.tsx @@ -22,6 +22,7 @@ import { faVial, faVectorSquare, IconDefinition, + faQrcode, } from '@fortawesome/free-solid-svg-icons'; import { faMarkdown } from '@fortawesome/free-brands-svg-icons'; import { InputType, SectionType } from '@eresearchqut/form-definition'; @@ -142,6 +143,14 @@ const componentDefaults: Map = new M description: 'Single or multiple choice from a list of options', }, ], + [ + InputType.QR_SCANNER, + { + icon: faQrcode, + label: 'QR Code Scanner', + description: 'Scans QR Codes using the camera', + }, + ], [ InputType.RANGE, { diff --git a/packages/form-designer/src/component/FormPreview.story.tsx b/packages/form-designer/src/component/FormPreview.story.tsx index d47ec10b..6240a710 100644 --- a/packages/form-designer/src/component/FormPreview.story.tsx +++ b/packages/form-designer/src/component/FormPreview.story.tsx @@ -46,6 +46,10 @@ export const BiobankExample = Template.bind({}); BiobankExample.args = { data: { biobank: { + qrScanner: { + autoStartScanning: false, + videoMaxWidthPx: 480, + }, exampleTray: { width: 10, length: 10, diff --git a/packages/form-designer/src/component/biobank-definition.story.json b/packages/form-designer/src/component/biobank-definition.story.json index 51ad581c..55ed0ff0 100644 --- a/packages/form-designer/src/component/biobank-definition.story.json +++ b/packages/form-designer/src/component/biobank-definition.story.json @@ -8,6 +8,11 @@ "inputs": [ { "id": "6b0b5622-72cc-443c-b2d1-eead5272e451", + "type": "qr-scanner", + "name": "QR Scanner" + }, + { + "id": "6b0b5622-72cc-443c-b2d1-eead5272e452", "type": "biobank-sample-container", "name": "Example Tray" } diff --git a/yarn.lock b/yarn.lock index 4d8d4cc4..b797603c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5760,6 +5760,11 @@ resolved "https://registry.yarnpkg.com/@types/npmlog/-/npmlog-4.1.4.tgz#30eb872153c7ead3e8688c476054ddca004115f6" integrity sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ== +"@types/offscreencanvas@^2019.6.4": + version "2019.6.4" + resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.6.4.tgz#64f6d120b53925028299c744fcdd32d2cd525963" + integrity sha512-u8SAgdZ8ROtkTF+mfZGOscl0or6BSj9A4g37e6nvxDc+YB/oDut0wHkK2PBBiC2bNR8TS0CPV+1gAk4fNisr1Q== + "@types/overlayscrollbars@^1.12.0": version "1.12.1" resolved "https://registry.yarnpkg.com/@types/overlayscrollbars/-/overlayscrollbars-1.12.1.tgz#fb637071b545834fb12aea94ee309a2ff4cdc0a8" @@ -15915,6 +15920,13 @@ q@^1.5.1: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qr-scanner@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/qr-scanner/-/qr-scanner-1.4.1.tgz#31a1bf7f9927f0eb1e3c0909fe66fec97a3b3701" + integrity sha512-xiR90NONHTfTwaFgW/ihlqjGMIZg6ExHDOvGQRba1TvV+WVw7GoDArIOt21e+RO+9WiO4AJJq+mwc5f4BnGH3w== + dependencies: + "@types/offscreencanvas" "^2019.6.4" + qs@6.9.3: version "6.9.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.3.tgz#bfadcd296c2d549f1dffa560619132c977f5008e"