Reduce code duplication for QR code scan (#8004)

* Add Link/Unlink Barcode action
Fixes #7920

* remove unneeded imports

* remove duplication

* simplify

* add testing

* refactor type

* wait for reload to add coverage

* Add warning if custom barcode is used

* Add Image based assign

* fix action button size

* fix selection to prevent wrapping

* use left section for button

* Refactor to seperate Input

* Add comment when not scanning

* Fix punctuation

* factor scan area out

* fix readonly arg

* make BarcodeInput more generic

* make button optional

* reduce code duplication by using BarcodeInput

* remove unneeded abstraction
This commit is contained in:
Matthias Mair 2024-08-28 00:13:22 +02:00 committed by GitHub
parent 313cb4758e
commit 450abcd353
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 125 additions and 211 deletions

View File

@ -0,0 +1,59 @@
import { t } from '@lingui/macro';
import { ActionIcon, Box, Button, Divider, TextInput } from '@mantine/core';
import { IconQrcode } from '@tabler/icons-react';
import React, { useState } from 'react';
import { InputImageBarcode } from '../../pages/Index/Scan';
type BarcodeInputProps = {
onScan: (decodedText: string) => void;
value?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onAction?: () => void;
placeholder?: string;
label?: string;
actionText?: string;
};
export function BarcodeInput({
onScan,
value,
onChange,
onAction,
placeholder = t`Scan barcode data here using barcode scanner`,
label = t`Barcode`,
actionText = t`Scan`
}: Readonly<BarcodeInputProps>) {
const [isScanning, setIsScanning] = useState(false);
return (
<Box>
{isScanning && (
<>
<InputImageBarcode action={onScan} />
<Divider mt={'sm'} />
</>
)}
<TextInput
label={label}
value={value}
onChange={onChange}
placeholder={placeholder}
leftSection={
<ActionIcon
variant={isScanning ? 'filled' : 'subtle'}
onClick={() => setIsScanning(!isScanning)}
>
<IconQrcode />
</ActionIcon>
}
w="100%"
/>
{onAction ? (
<Button color="green" onClick={onAction} mt="lg" fullWidth>
{actionText}
</Button>
) : null}
</Box>
);
}

View File

@ -1,35 +1,29 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { import {
ActionIcon,
Alert, Alert,
Box, Box,
Button, Button,
Code, Code,
Divider,
Flex,
Group, Group,
Image, Image,
Select, Select,
Skeleton, Skeleton,
Stack, Stack,
Text, Text
TextInput
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { modals } from '@mantine/modals'; import { modals } from '@mantine/modals';
import { IconQrcode } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import QR from 'qrcode'; import QR from 'qrcode';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { set } from 'react-hook-form';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { InputImageBarcode, ScanItem } from '../../pages/Index/Scan';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState'; import { useGlobalSettingsState } from '../../states/SettingsState';
import { CopyButton } from '../buttons/CopyButton'; import { CopyButton } from '../buttons/CopyButton';
import { QrCodeType } from './ActionDropdown'; import { QrCodeType } from './ActionDropdown';
import { BarcodeInput } from './BarcodeInput';
type QRCodeProps = { type QRCodeProps = {
ecl?: 'L' | 'M' | 'Q' | 'H'; ecl?: 'L' | 'M' | 'Q' | 'H';
@ -162,37 +156,22 @@ export const QRCodeLink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => {
location.reload(); location.reload();
}); });
} }
const actionSubmit = (data: ScanItem[]) => { const actionSubmit = (decodedText: string) => {
linkBarcode(data[0].data); linkBarcode(decodedText);
}; };
const handleLinkBarcode = () => {
linkBarcode(barcode);
};
return ( return (
<Box> <BarcodeInput
{isScanning ? ( value={barcode}
<> onChange={(event) => setBarcode(event.currentTarget.value)}
<InputImageBarcode action={actionSubmit} /> onScan={actionSubmit}
<Divider /> onAction={handleLinkBarcode}
</> actionText={t`Link`}
) : null} />
<TextInput
label={t`Barcode`}
value={barcode}
onChange={(event) => setBarcode(event.currentTarget.value)}
placeholder={t`Scan barcode data here using barcode scanner`}
leftSection={
<ActionIcon
variant="subtle"
onClick={toggleIsScanning.toggle}
size="input-sm"
>
<IconQrcode />
</ActionIcon>
}
w="100%"
/>
<Button color="green" onClick={() => linkBarcode()} mt="lg" fullWidth>
<Trans>Link</Trans>
</Button>
</Box>
); );
}; };

View File

@ -1,69 +1,21 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { import { Button, ScrollArea, Stack, Text } from '@mantine/core';
Badge, import { useListState } from '@mantine/hooks';
Button,
Container,
Group,
ScrollArea,
Space,
Stack,
Text
} from '@mantine/core';
import {
useDocumentVisibility,
useListState,
useLocalStorage
} from '@mantine/hooks';
import { ContextModalProps } from '@mantine/modals'; import { ContextModalProps } from '@mantine/modals';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconX } from '@tabler/icons-react';
import { Html5Qrcode } from 'html5-qrcode';
import { CameraDevice } from 'html5-qrcode/camera/core';
import { Html5QrcodeResult } from 'html5-qrcode/core';
import { useEffect, useState } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { BarcodeInput } from '../items/BarcodeInput';
export function QrCodeModal({ export function QrCodeModal({
context, context,
id id
}: ContextModalProps<{ modalBody: string }>) { }: Readonly<ContextModalProps<{ modalBody: string }>>) {
const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null);
const [camId, setCamId] = useLocalStorage<CameraDevice | null>({
key: 'camId',
defaultValue: null
});
const [scanningEnabled, setScanningEnabled] = useState<boolean>(false);
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
const documentState = useDocumentVisibility();
const [values, handlers] = useListState<string>([]); const [values, handlers] = useListState<string>([]);
// Mount QR code once we are loaded function onScanAction(decodedText: string) {
useEffect(() => {
setQrCodeScanner(new Html5Qrcode('reader'));
}, []);
// Stop/star when leaving or reentering page
useEffect(() => {
if (scanningEnabled && documentState === 'hidden') {
stopScanning();
setWasAutoPaused(true);
} else if (wasAutoPaused && documentState === 'visible') {
startScanning();
setWasAutoPaused(false);
}
}, [documentState]);
// Scanner functions
function onScanSuccess(
decodedText: string,
decodedResult: Html5QrcodeResult
) {
qrCodeScanner?.pause();
handlers.append(decodedText); handlers.append(decodedText);
api api
.post(apiUrl(ApiEndpoints.barcode), { barcode: decodedText }) .post(apiUrl(ApiEndpoints.barcode), { barcode: decodedText })
@ -77,124 +29,28 @@ export function QrCodeModal({
window.location.href = response.data.url; window.location.href = response.data.url;
} }
}); });
qrCodeScanner?.resume();
}
function onScanFailure(error: string) {
if (
error !=
'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.'
) {
console.warn(`Code scan error = ${error}`);
}
}
function selectCamera() {
Html5Qrcode.getCameras()
.then((devices) => {
if (devices?.length) {
setCamId(devices[0]);
}
})
.catch((err) => {
showNotification({
title: t`Error while getting camera`,
message: err,
color: 'red',
icon: <IconX />
});
});
}
function startScanning() {
if (camId && qrCodeScanner) {
qrCodeScanner
.start(
camId.id,
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText, decodedResult) => {
onScanSuccess(decodedText, decodedResult);
},
(errorMessage) => {
onScanFailure(errorMessage);
}
)
.catch((err: string) => {
showNotification({
title: t`Error while scanning`,
message: err,
color: 'red',
icon: <IconX />
});
});
setScanningEnabled(true);
}
}
function stopScanning() {
if (qrCodeScanner && scanningEnabled) {
qrCodeScanner.stop().catch((err: string) => {
showNotification({
title: t`Error while stopping`,
message: err,
color: 'red',
icon: <IconX />
});
});
setScanningEnabled(false);
}
} }
return ( return (
<Stack> <Stack gap="xs">
<Group> <BarcodeInput onScan={onScanAction} />
<Text size="sm">{camId?.label}</Text> {values.length == 0 ? (
<Space style={{ flex: 1 }} /> <Text c={'grey'}>
<Badge>{scanningEnabled ? t`Scanning` : t`Not scanning`}</Badge> <Trans>No scans yet!</Trans>
</Group> </Text>
<Container px={0} id="reader" w={'100%'} mih="300px" />
{!camId ? (
<Button onClick={() => selectCamera()}>
<Trans>Select Camera</Trans>
</Button>
) : ( ) : (
<> <ScrollArea style={{ height: 200 }} type="auto" offsetScrollbars>
<Group> {values.map((value, index) => (
<Button <div key={`${index}-${value}`}>{value}</div>
style={{ flex: 1 }} ))}
onClick={() => startScanning()} </ScrollArea>
disabled={camId != undefined && scanningEnabled}
>
<Trans>Start scanning</Trans>
</Button>
<Button
style={{ flex: 1 }}
onClick={() => stopScanning()}
disabled={!scanningEnabled}
>
<Trans>Stop scanning</Trans>
</Button>
</Group>
{values.length == 0 ? (
<Text c={'grey'}>
<Trans>No scans yet!</Trans>
</Text>
) : (
<ScrollArea style={{ height: 200 }} type="auto" offsetScrollbars>
{values.map((value, index) => (
<div key={index}>{value}</div>
))}
</ScrollArea>
)}
</>
)} )}
<Button <Button
fullWidth fullWidth
mt="md" mt="md"
color="red" color="red"
onClick={() => { onClick={() => {
stopScanning(); // stopScanning();
context.closeModal(id); context.closeModal(id);
}} }}
> >

View File

@ -222,7 +222,21 @@ export default function Scan() {
case InputMethod.Manual: case InputMethod.Manual:
return <InputManual action={addItems} />; return <InputManual action={addItems} />;
case InputMethod.ImageBarcode: case InputMethod.ImageBarcode:
return <InputImageBarcode action={addItems} />; return (
<InputImageBarcode
action={(decodedText: string) => {
addItems([
{
id: randomId(),
ref: decodedText,
data: decodedText,
timestamp: new Date(),
source: InputMethod.ImageBarcode
}
]);
}}
/>
);
default: default:
return <Text>No input selected</Text>; return <Text>No input selected</Text>;
} }
@ -489,10 +503,15 @@ enum InputMethod {
ImageBarcode = 'imageBarcode' ImageBarcode = 'imageBarcode'
} }
interface ScanInputInterface { export interface ScanInputInterface {
action: (items: ScanItem[]) => void; action: (items: ScanItem[]) => void;
} }
interface BarcodeInputProps {
action: (decodedText: string) => void;
notScanningPlaceholder?: string;
}
function InputManual({ action }: Readonly<ScanInputInterface>) { function InputManual({ action }: Readonly<ScanInputInterface>) {
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@ -545,7 +564,10 @@ function InputManual({ action }: Readonly<ScanInputInterface>) {
} }
/* Input that uses QR code detection from images */ /* Input that uses QR code detection from images */
export function InputImageBarcode({ action }: Readonly<ScanInputInterface>) { export function InputImageBarcode({
action,
notScanningPlaceholder = t`Start scanning by selecting a camera and pressing the play button.`
}: Readonly<BarcodeInputProps>) {
const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null); const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null);
const [camId, setCamId] = useLocalStorage<CameraDevice | null>({ const [camId, setCamId] = useLocalStorage<CameraDevice | null>({
key: 'camId', key: 'camId',
@ -601,15 +623,7 @@ export function InputImageBarcode({ action }: Readonly<ScanInputInterface>) {
lastValue = decodedText; lastValue = decodedText;
// submit value upstream // submit value upstream
action([ action(decodedText);
{
id: randomId(),
ref: decodedText,
data: decodedText,
timestamp: new Date(),
source: InputMethod.ImageBarcode
}
]);
qrCodeScanner?.resume(); qrCodeScanner?.resume();
} }
@ -749,7 +763,13 @@ export function InputImageBarcode({ action }: Readonly<ScanInputInterface>) {
{scanningEnabled ? t`Scanning` : t`Not scanning`} {scanningEnabled ? t`Scanning` : t`Not scanning`}
</Badge> </Badge>
</Group> </Group>
<Container px={0} id="reader" w={'100%'} mih="300px" /> {scanningEnabled ? (
<Container px={0} id="reader" w={'100%'} mih="300px" />
) : (
<Container px={0} id="reader" w={'100%'} mih="300px">
{notScanningPlaceholder}
</Container>
)}
{!camId && ( {!camId && (
<Button onClick={btnSelectCamera}> <Button onClick={btnSelectCamera}>
<Trans>Select Camera</Trans> <Trans>Select Camera</Trans>