Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tupaiaWeb): RN-1371: QR code scanner #5892

Merged
merged 12 commits into from
Oct 7, 2024
1 change: 1 addition & 0 deletions packages/tupaia-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"react-dom": "^16.13.1",
"react-hook-form": "^6.15.1",
"react-leaflet": "^3.2.1",
"react-qr-reader": "^3.0.0-beta-1",
"react-router": "6.3.0",
"react-router-dom": "6.3.0",
"react-slick": "^0.30.2",
Expand Down
10 changes: 6 additions & 4 deletions packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,25 @@ import { SearchResults } from './SearchResults';
import { gaEvent } from '../../utils';

const Container = styled.div`
position: relative;
display: flex;
z-index: 1;
flex-direction: column;
align-items: center;
margin-right: 1rem;
margin-top: 0.6rem;
width: 19rem;
position: relative;
@media screen and (max-width: ${MOBILE_BREAKPOINT}) {
width: auto;
margin: 0;
position: initial;
}
`;

const ResultsWrapper = styled.div`
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ${({ theme }) => theme.palette.background.paper};
padding: 0 0.3rem 0.625rem;
width: calc(100% + 5px);
Expand All @@ -40,14 +42,14 @@ const ResultsWrapper = styled.div`
overflow-y: auto;

@media screen and (max-width: ${MOBILE_BREAKPOINT}) {
position: fixed;
width: 100%;
top: ${TOP_BAR_HEIGHT_MOBILE};
left: 0;
right: 0;
z-index: 1;
min-height: calc(100vh - ${TOP_BAR_HEIGHT_MOBILE});
max-height: calc(100vh - ${TOP_BAR_HEIGHT_MOBILE});
border-radius: 0;
position: fixed;
}
`;

Expand Down
33 changes: 20 additions & 13 deletions packages/tupaia-web/src/features/EntitySearch/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import styled from 'styled-components';
import { TextField, TextFieldProps } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import { Close, Search } from '@material-ui/icons';
import { MOBILE_BREAKPOINT, TOP_BAR_HEIGHT_MOBILE } from '../../constants';
import { IconButton } from '@tupaia/ui-components';
import { MOBILE_BREAKPOINT, TOP_BAR_HEIGHT_MOBILE } from '../../constants';
import { QRCodeScanner } from '../QRCodeScanner';

const SearchInput = styled(TextField).attrs({
variant: 'outlined',
placeholder: 'Search location',
placeholder: 'Search location...',
fullWidth: true,
InputProps: {
startAdornment: <SearchIcon />,
Expand Down Expand Up @@ -61,14 +62,9 @@ const SearchInput = styled(TextField).attrs({
`;

const MobileCloseButton = styled(IconButton)`
display: none;
@media screen and (max-width: ${MOBILE_BREAKPOINT}) {
display: block;
position: absolute;
top: 0.1rem;
right: 0.1rem;
z-index: 1;
}
top: 0.1rem;
right: 0.1rem;
z-index: 1;
`;

const Container = styled.div<{
Expand All @@ -86,6 +82,8 @@ const Container = styled.div<{
height: ${TOP_BAR_HEIGHT_MOBILE};
// Place on top of the hamburger menu on mobile
z-index: 1;
display: flex;
background: ${({ theme }) => theme.palette.background.paper};
}
`;

Expand All @@ -95,6 +93,12 @@ const MobileOpenButton = styled(IconButton)`
display: block;
}
`;
const MobileWrapper = styled.div`
display: none;
@media screen and (max-width: ${MOBILE_BREAKPOINT}) {
display: flex;
}
`;

interface SearchBarProps {
value?: string;
Expand Down Expand Up @@ -140,9 +144,12 @@ export const SearchBar = ({ value = '', onChange, onFocusChange, onClose }: Sear
onFocus={() => onFocusChange(true)}
inputRef={inputRef}
/>
<MobileCloseButton onClick={handleClickClose} color="default">
<Close />
</MobileCloseButton>
<MobileWrapper>
<QRCodeScanner onCloseEntitySearch={handleClickClose} />
<MobileCloseButton onClick={handleClickClose} color="default">
<Close />
</MobileCloseButton>
</MobileWrapper>
</Container>
</>
);
Expand Down
150 changes: 150 additions & 0 deletions packages/tupaia-web/src/features/QRCodeScanner/QRCodeScanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
import React, { useState } from 'react';
import styled from 'styled-components';
import { Button, IconButton, SmallAlert } from '@tupaia/ui-components';
import { QrReader } from 'react-qr-reader';
import { get } from '../../api';
// This import is the actual type that QrReader uses
import { Result } from '@zxing/library';
import { QRScanIcon } from './QRScanIcon';
import { ClickAwayListener, Typography } from '@material-ui/core';
import { Close } from '@material-ui/icons';
import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { ROUTE_STRUCTURE } from '../../constants';

const QRScanButton = styled(Button).attrs({
startIcon: <QRScanIcon />,
variant: 'text',
})`
background: ${({ theme }) => theme.palette.background.paper};
text-transform: none;
font-size: 0.875rem;
font-weight: 400;
padding-inline: 0.5rem;
white-space: nowrap;
height: 100%;
`;

const ScannerWrapper = styled.div`
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
z-index: 10;
background: ${({ theme }) => theme.palette.background.paper};
padding-inline: 1.4rem;
padding-block: 1.2rem;
`;

const CloseButton = styled(IconButton)`
.MuiSvgIcon-root {
font-size: 1rem;
}
padding: 0.5rem;
`;

const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;

const Title = styled(Typography).attrs({
variant: 'h1',
})`
font-size: 0.75rem;
font-weight: 500;
`;

export const QRCodeScanner = ({ onCloseEntitySearch }: { onCloseEntitySearch: () => void }) => {
const { projectCode, dashboardName } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [isQRScannerOpen, setIsQRScannerOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const toggleQRScanner = () => {
setIsQRScannerOpen(!isQRScannerOpen);
setErrorMessage(null);
};

const handleScan = async (data?: Result | null, error?: Error | null) => {
if (error?.message) {
setErrorMessage(error.message);
}

if (!data) {
return;
}

const text = data.getText();
const entityId = text.replace('entity-', '');
let entityCode: string;

try {
const results = await get(`entities/${projectCode}/${projectCode}`, {
params: {
filter: { id: entityId },
fields: ['code'],
},
});
const entity = results[0] ?? null;

if (!entity) {
setErrorMessage(
'No matching entity found in selected project. Please try another QR code, or check your project selection.',
);
return;
}
entityCode = entity.code;
// reset error message
setErrorMessage(null);
} catch (e) {
setErrorMessage('Error fetching entity details. Please refresh the page and try again.');
return;
}

const path = generatePath(ROUTE_STRUCTURE, {
projectCode,
dashboardName,
entityCode,
});

// navigate to the entity page and close the scanner and entity search
navigate({
...location,
pathname: path,
});

setIsQRScannerOpen(false);
onCloseEntitySearch();
};

return (
<>
<QRScanButton onClick={toggleQRScanner}>Scan ID</QRScanButton>
{isQRScannerOpen && (
<ClickAwayListener onClickAway={toggleQRScanner}>
<ScannerWrapper>
<Header>
<Title>Scan the location ID QR code using your camera</Title>
<CloseButton onClick={toggleQRScanner}>
<Close />
</CloseButton>
</Header>
{errorMessage && <SmallAlert severity="error">{errorMessage}</SmallAlert>}
<QrReader
// use the camera facing the environment (back camera)
constraints={{ facingMode: 'environment' }}
onResult={handleScan}
/>
</ScannerWrapper>
</ClickAwayListener>
)}
</>
);
};
24 changes: 24 additions & 0 deletions packages/tupaia-web/src/features/QRCodeScanner/QRScanIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
import React from 'react';
import { SvgIcon, SvgIconProps } from '@material-ui/core';

export const QRScanIcon = (props: SvgIconProps) => {
return (
<SvgIcon
{...props}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.92461 9.4373H9.36824V4.99367H4.92461V9.4373ZM5.82461 5.89355H8.46824V8.53717H5.82461V5.89355ZM14.9376 15.0065H10.1447V13.5928H11.0447V14.1067H12.0916V13.5928H12.9916V14.1067H14.0377V13.5928H14.9377L14.9377 15.0065H14.9376ZM11.0447 12.5213H10.1447V10.2139H13.3427V11.1139H11.0447V12.5213ZM17.2 4.65605V8.1438H16.3V4.65605C16.3 4.12924 15.871 3.70054 15.3437 3.70054H11.856V2.80054H15.3437C16.3672 2.80054 17.2 3.63292 17.2 4.65605ZM3.69999 8.1438H2.79999V4.65605C2.79999 3.6329 3.6328 2.80055 4.65624 2.80055H8.14399V3.70055H4.65624C4.12899 3.70055 3.69999 4.12911 3.69999 4.65606V8.1438ZM4.65624 16.2996H8.14399V17.1995H4.65624C3.6328 17.1995 2.79999 16.3669 2.79999 15.3433V11.8563H3.69999V15.3433C3.69999 15.8707 4.12899 16.2996 4.65624 16.2996ZM16.3 11.8563H17.2V15.3433C17.2 16.3669 16.3672 17.1995 15.3437 17.1995H11.856V16.2996H15.3437C15.871 16.2996 16.3 15.8705 16.3 15.3433V11.8563ZM4.92461 15.0064H9.36824V10.5629H4.92461V15.0064ZM5.82461 11.4628H8.46824V14.1064H5.82461V11.4628ZM14.9376 4.99355H10.494V9.43717H14.9376V4.99355ZM14.0376 8.53717H11.394V5.89342H14.0376V8.53717ZM11.7396 12.0712H14.9376V12.9712H11.7396V12.0712ZM14.9376 11.1775H14.0376V10.2136H14.9376V11.1775ZM6.71749 12.3558H7.57535V13.2136H6.71749V12.3558ZM6.71749 6.78637H7.57535V7.64424H6.71749V6.78637ZM13.1447 7.64424H12.2869V6.78637H13.1447V7.64424Z"
fill="white"
/>
</SvgIcon>
);
};
21 changes: 21 additions & 0 deletions packages/tupaia-web/src/features/QRCodeScanner/QrCodeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
import React from 'react';
import styled from 'styled-components';
import { Button } from '@tupaia/ui-components';
import { QRScanIcon } from './QRScanIcon';

export const QrScanButton = styled(Button).attrs({
startIcon: <QRScanIcon />,
variant: 'text',
})`
background: ${({ theme }) => theme.palette.background.paper};
text-transform: none;
font-size: 0.875rem;
font-weight: 400;
padding-inline: 0.5rem;
white-space: nowrap;
height: 100%;
`;
5 changes: 5 additions & 0 deletions packages/tupaia-web/src/features/QRCodeScanner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
export { QRCodeScanner } from './QRCodeScanner';
Loading