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

Feature/qr barcode reader #8153

Merged
merged 12 commits into from
Oct 13, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ const fieldTypeToComponentMap = {
attachment: "attachmentfield",
link: "relationshipfield",
json: "jsonfield",
scannedcode: "codescanner",
}

export function makeDatasourceFormComponents(datasource) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@
} else {
return [
FIELDS.STRING,
FIELDS.SCANNEDCODE,
FIELDS.LONGFORM,
FIELDS.OPTIONS,
FIELDS.DATETIME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@
label: "Multi-select",
value: FIELDS.ARRAY.type,
},
{
label: "Scanned Code",
value: FIELDS.SCANNEDCODE.type,
},
]
</script>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const componentMap = {
"field/link": FormFieldSelect,
"field/array": FormFieldSelect,
"field/json": FormFieldSelect,
"field/scannedcode": FormFieldSelect,
// Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation
"validation/string": ValidationEditor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
let entries = Object.entries(schema ?? {})

let types = []
if (type === "field/options") {
if ((type === "field/options", type === "field/scannedcode")) {
// allow options to be used on both options and string fields
types = [type, "field/string"]
} else {
Expand Down
10 changes: 10 additions & 0 deletions packages/builder/src/constants/backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ export const FIELDS = {
presence: false,
},
},
SCANNEDCODE: {
name: "Scanned Code",
type: "scannedcode",
constraints: {
type: "string",
length: {},
presence: false,
},
},
LONGFORM: {
name: "Long Form Text",
type: "longform",
Expand Down Expand Up @@ -148,6 +157,7 @@ export const ALLOWABLE_STRING_OPTIONS = [
FIELDS.STRING,
FIELDS.OPTIONS,
FIELDS.LONGFORM,
FIELDS.SCANNEDCODE,
]
export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map(
opt => opt.type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"relationshipfield",
"datetimefield",
"multifieldselect",
"s3upload"
"s3upload",
"codescanner"
]
},
{
Expand Down
51 changes: 51 additions & 0 deletions packages/client/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3157,6 +3157,57 @@
}
]
},
"codescanner": {
"name": "Code Scanner",
"icon": "Camera",
"styles": [
"size"
],
"draggable": true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: setting draggable to true here is redundant as it's only a flag used to disable DND if set to false. Setting illegal children is also redundant since this component doesn't take child components anyway.

"illegalChildren": [
"section"
],
"settings": [
{
"type": "field/scannedcode",
"label": "Field",
"key": "field",
"required": true
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Button",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: let's rename this to "Button text" to make it more obvious that's what it is

"key": "scanButtonText"
},
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "boolean",
"label": "Allow manual entry",
"key": "allowManualEntry",
"defaultValue": false
},
{
"type": "validation/string",
"label": "Validation",
"key": "validation"
}
]
},
"embeddedmap": {
"name": "Embedded Map",
"icon": "Location",
Expand Down
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"apexcharts": "^3.22.1",
"dayjs": "^1.10.5",
"downloadjs": "1.4.7",
"html5-qrcode": "^2.2.1",
"leaflet": "^1.7.1",
"regexparam": "^1.3.0",
"sanitize-html": "^2.7.0",
Expand Down
9 changes: 9 additions & 0 deletions packages/client/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ export default {
file: `./dist/budibase-client.js`,
},
],
onwarn(warning, warn) {
if (
warning.code === "THIS_IS_UNDEFINED" ||
warning.code === "CIRCULAR_DEPENDENCY"
) {
return
}
warn(warning)
},
plugins: [
alias({
entries: [
Expand Down
235 changes: 235 additions & 0 deletions packages/client/src/components/app/CodeScanner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<script>
import { ModalContent, Modal, Icon, ActionButton } from "@budibase/bbui"
import { Input, Button, StatusLight } from "@budibase/bbui"
import { Html5Qrcode } from "html5-qrcode"

export let value
export let disabled = false
export let allowManualEntry = false
export let scanButtonText = "Scan Code"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: Change casing to "Scan code" for consistency with other buttons


import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()

let videoEle
let camModal
let manualMode = false
let cameraEnabled
let cameraStarted = false
let html5QrCode
let cameraSetting = { facingMode: "environment" }
let cameraConfig = {
fps: 25,
qrbox: { width: 250, height: 250 },
}
const onScanSuccess = decodedText => {
if (value != decodedText) {
dispatch("change", decodedText)
}
}

const initReader = async () => {
if (html5QrCode) {
html5QrCode.stop()
}
html5QrCode = new Html5Qrcode("reader")
return new Promise(resolve => {
html5QrCode
.start(cameraSetting, cameraConfig, onScanSuccess)
.then(() => {
resolve({ initialised: true })
})
.catch(err => {
console.log("There was a problem scanning the image", err)
resolve({ initialised: false })
})
})
}

const checkCamera = async () => {
return new Promise(resolve => {
Html5Qrcode.getCameras()
.then(devices => {
if (devices && devices.length) {
resolve({ enabled: true })
}
})
.catch(e => {
console.error(e)
resolve({ enabled: false })
})
})
}

const start = async () => {
const status = await initReader()
cameraStarted = status.initialised
}

$: if (cameraEnabled && videoEle && !cameraStarted) {
start()
}

const showReaderModal = async () => {
camModal.show()
const camStatus = await checkCamera()
cameraEnabled = camStatus.enabled
}

const hideReaderModal = async () => {
cameraEnabled = undefined
cameraStarted = false
if (html5QrCode) {
await html5QrCode.stop()
html5QrCode = undefined
}
camModal.hide()
}
</script>

<div class="scanner-video-wrapper">
{#if value && !manualMode}
<div class="scanner-value field-display">
<StatusLight positive />
{value}
</div>
{/if}

{#if allowManualEntry && manualMode}
<div class="manual-input">
<Input
bind:value
on:change={() => {
dispatch("change", value)
}}
/>
</div>
{/if}

{#if value}
<ActionButton
on:click={() => {
dispatch("change", "")
}}
{disabled}
>
Clear
</ActionButton>
{:else}
<ActionButton
icon="Camera"
on:click={() => {
showReaderModal()
}}
{disabled}
>
{scanButtonText}
</ActionButton>
{/if}
</div>

<div class="modal-wrap">
<Modal bind:this={camModal} on:hide={hideReaderModal}>
<ModalContent
title={scanButtonText}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scanButtonText will default to "Scan code" if the value is undefined, but if the user has entered a value in the setting field and then cleared it, the text will always be undefined as it does not account for empty strings.

2 ways to solve this would be:
$: scanButtonText = scanButtonText || "Scan code"
or every time you use scanButtonText, do
scanButtonText || "Scan code

showConfirmButton={false}
showCancelButton={false}
>
<div id="reader" class="container" bind:this={videoEle}>
<div class="camera-placeholder">
<Icon size="XXL" name="Camera" />
{#if cameraEnabled === false}
<div>Your camera is disabled.</div>
{/if}
</div>
</div>
{#if cameraEnabled === true}
<div class="code-wrap">
{#if value}
<div class="scanner-value">
<StatusLight positive />
{value}
</div>
{:else}
<div class="scanner-value">
<StatusLight neutral />
Searching for code...
</div>
{/if}
</div>
{/if}

<div slot="footer">
<div class="footer-buttons">
{#if allowManualEntry}
<Button
group
secondary
newStyles
on:click={() => {
manualMode = !manualMode
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: since this is just a toggle, if you've enabled manual mode already and then press this again, it will unset it which feels a bit strange. I reckon we should probably just hide the button if we're already in manual mode, so {#if allowManualEntry && !manualMode}, and then the onclick handler here can just set it explicitly to true.

camModal.hide()
}}
>
Enter Manually
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: change casing to "Enter manually" to be consistent with other buttons.

</Button>
{/if}

<Button
group
cta
on:click={() => {
camModal.hide()
}}
>
Confirm
</Button>
</div>
</div>
</ModalContent>
</Modal>
</div>

<style>
#reader :global(video) {
border-radius: 4px;
border: var(--border-light-2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hardcoded light border so it looks off in dark themes. Let's use

border: 2px solid var(--spectrum-global-color-gray-300);

instead so that it looks better in any theme.

overflow: hidden;
}
.field-display :global(.spectrum-Tags-item) {
margin: 0px;
}
.footer-buttons {
display: flex;
grid-area: buttonGroup;
gap: var(--spectrum-global-dimension-static-size-200);
}
.scanner-value {
display: flex;
}
.field-display {
padding-top: var(
--spectrum-fieldlabel-side-m-padding-top,
var(--spectrum-global-dimension-size-100)
);
margin-bottom: var(--spacing-m);
}
.camera-placeholder {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
border: var(--border-light-2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hardcoded light border so it looks off in dark themes. Let's use

border: 2px solid var(--spectrum-global-color-gray-300);

instead so that it looks better in any theme.

background-color: var(--spectrum-global-color-gray-200);
flex-direction: column;
gap: var(--spectrum-global-dimension-static-size-200);
}
.container,
.camera-placeholder {
width: 100%;
min-height: 240px;
}
.manual-input {
padding-bottom: var(--spacing-m);
}
</style>
Loading