[IMP] web: convert many2one_barcode to OWL

Before this commit there is a widget `many2one_barcode` that allows
to scan a barcode.

After this commit, we have converted it to OWL, but it's more than a new
component. From now, the logic is in the basic Many2One as a
property/option (`canScanBarcode`) and we also have the equivalent
Component (`widget="many2one_barcode"`). This choice has been made to
support multiple use cases (e.g. having the product_configurator and the
barcode on the same product field).

Note:
now we use the `browser` class to allow to patch the browser feature
during testing.

Part-of: odoo/odoo#100082
This commit is contained in:
Romeo Fragomeli
2022-09-15 11:05:22 +02:00
parent 71a3048ec1
commit b6a36df34a
8 changed files with 473 additions and 5 deletions
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { Dialog } from "@web/core/dialog/dialog";
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
@@ -7,6 +8,8 @@ import { useChildRef, useOwnedDialogs, useService } from "@web/core/utils/hooks"
import { sprintf } from "@web/core/utils/strings";
import { standardFieldProps } from "../standard_field_props";
import { Many2XAutocomplete, useOpenMany2XRecord } from "@web/views/fields/relational_utils";
import { isMobileOS } from "@web/core/browser/feature_detection";
import * as BarcodeScanner from "@web/webclient/barcode/barcode_scanner";
const { Component, onWillUpdateProps, useState } = owl;
@@ -40,6 +43,7 @@ export class Many2OneField extends Component {
this.orm = useService("orm");
this.action = useService("action");
this.dialog = useService("dialog");
this.notification = useService("notification");
this.autocompleteContainerRef = useChildRef();
this.addDialog = useOwnedDialogs();
@@ -168,6 +172,54 @@ export class Many2OneField extends Component {
onExternalBtnClick() {
this.openDialog(this.resId);
}
async onBarcodeBtnClick() {
const barcode = await BarcodeScanner.scanBarcode();
if (barcode) {
await this.onBarcodeScanned(barcode);
if ("vibrate" in browser.navigator) {
browser.navigator.vibrate(100);
}
} else {
this.notification.add(this.env._t("Please, scan again !"), {
type: "warning",
});
}
}
async search(barcode) {
const results = await this.orm.call(this.relation, "name_search", [], {
name: barcode,
args: this.getDomain(),
operator: "ilike",
limit: 2, // If one result we set directly and if more than one we use normal flow so no need to search more
context: this.context,
});
return results.map((result) => {
const [id, displayName] = result;
return {
id,
name: displayName,
};
});
}
async onBarcodeScanned(barcode) {
const results = await this.search(barcode);
const records = results.filter((r) => !!r.id);
if (records.length === 1) {
this.update([{ id: records[0].id, name: records[0].name }]);
} else {
const searchInput = this.autocompleteContainerRef.el.querySelector("input");
searchInput.value = barcode;
searchInput.dispatchEvent(new Event("input"));
if (this.env.isSmall) {
searchInput.click();
}
}
}
get hasBarcodeButton() {
const canScanBarcode = this.props.canScanBarcode;
const supported = BarcodeScanner.isBarcodeScannerSupported();
return canScanBarcode && isMobileOS() && supported && !this.hasExternalButton;
}
}
Many2OneField.SEARCH_MORE_LIMIT = 320;
@@ -188,6 +240,7 @@ Many2OneField.props = {
searchLimit: { type: Number, optional: true },
relation: { type: String, optional: true },
string: { type: String, optional: true },
canScanBarcode: { type: Boolean, optional: true },
};
Many2OneField.defaultProps = {
canOpen: true,
@@ -198,6 +251,7 @@ Many2OneField.defaultProps = {
createNameField: "name",
searchLimit: 7,
string: "",
canScanBarcode: false,
};
Many2OneField.displayName = _lt("Many2one");
@@ -210,6 +264,7 @@ Many2OneField.extractProps = ({ attrs, field }) => {
const canWrite = attrs.can_write && Boolean(JSON.parse(attrs.can_write));
const noQuickCreate = Boolean(attrs.options.no_quick_create);
const noCreateEdit = Boolean(attrs.options.no_create_edit);
const canScanBarcode = Boolean(attrs.options.can_scan_barcode);
return {
placeholder: attrs.placeholder,
@@ -221,6 +276,7 @@ Many2OneField.extractProps = ({ attrs, field }) => {
relation: field.relation,
string: attrs.string || field.string,
createNameField: attrs.options.create_name_field,
canScanBarcode: canScanBarcode,
};
};
@@ -67,6 +67,17 @@
t-on-click="onExternalBtnClick"
/>
</t>
<button
t-if="hasBarcodeButton"
t-on-click="onBarcodeBtnClick"
type="button"
class="btn o_barcode"
tabindex="-1"
draggable="false"
aria-label="Scan barcode"
title="Scan barcode"
data-tooltip="Scan barcode"
/>
</div>
<div class="o_field_many2one_extra">
<t t-foreach="extraLines" t-as="extraLine" t-key="extraLine_index">
@@ -0,0 +1,28 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Many2OneField } from "../many2one/many2one_field";
export class Many2OneBarcodeField extends Many2OneField {}
Many2OneBarcodeField.props = {
...Many2OneField.props,
};
Many2OneBarcodeField.defaultProps = {
...Many2OneField.defaultProps,
canScanBarcode: true,
};
Many2OneBarcodeField.displayName = _lt("Many2OneBarcode");
Many2OneBarcodeField.template = "web.Many2OneField";
Many2OneBarcodeField.supportedTypes = ["many2one"];
Many2OneBarcodeField.extractProps = (args) => {
return {
...Many2OneField.extractProps(args),
canScanBarcode: true,
};
};
registry.category("fields").add("many2one_barcode", Many2OneBarcodeField);
@@ -133,7 +133,7 @@ export class Many2XAutocomplete extends Component {
setup() {
this.orm = useService("orm");
const autoCompleteContainer = useForwardRefToParent("autocomplete_container");
this.autoCompleteContainer = useForwardRefToParent("autocomplete_container");
const { activeActions, resModel, update, isToMany, fieldString } = this.props;
this.openMany2X = useOpenMany2XRecord({
@@ -145,7 +145,7 @@ export class Many2XAutocomplete extends Component {
},
fieldString,
onClose: () => {
const autoCompleteInput = autoCompleteContainer.el.querySelector("input");
const autoCompleteInput = this.autoCompleteContainer.el.querySelector("input");
// There are two cases:
// 1. Value is the same as the input: it means the autocomplete has re-rendered with the right value
@@ -278,6 +278,11 @@ export class Many2XAutocomplete extends Component {
return options;
}
async onSearchMoreSmall() {
const autoCompleteInput = this.autoCompleteContainer.el.querySelector("input");
return this.onSearchMore(autoCompleteInput.value);
}
async onSearchMore(request) {
const { resModel, getDomain, context, fieldString } = this.props;
@@ -42,7 +42,7 @@
class="o_input"
readonly=""
t-att-value="props.value"
t-on-click="onSearchMore"
t-on-click="onSearchMoreSmall"
/>
<AutoComplete t-else=""
value="props.value"
@@ -1,6 +1,7 @@
/** @odoo-module **/
/* global BarcodeDetector */
import { browser } from "@web/core/browser/browser";
import Dialog from "web.OwlDialog";
import { delay } from "web.concurrency";
import { loadJS, templates } from "@web/core/assets";
@@ -160,7 +161,7 @@ class BarcodeDialog extends Component {
};
try {
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
this.stream = await browser.navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
const errors = {
NotFoundError: _t("No device can be found."),
@@ -249,7 +250,7 @@ Object.assign(BarcodeDialog, {
* @returns {boolean}
*/
export function isBarcodeScannerSupported() {
return navigator.mediaDevices && navigator.mediaDevices.getUserMedia;
return browser.navigator.mediaDevices && browser.navigator.mediaDevices.getUserMedia;
}
/**
@@ -0,0 +1,157 @@
/** @odoo-module **/
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { browser } from "@web/core/browser/browser";
import { click, clickSave, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import * as BarcodeScanner from "@web/webclient/barcode/barcode_scanner";
let serverData;
let target;
const CREATE = "create";
const NAME_SEARCH = "name_search";
const PRODUCT_PRODUCT = "product.product";
const SALE_ORDER_LINE = "sale_order_line";
const PRODUCT_FIELD_NAME = "product_id";
// MockRPC to allow the search in barcode too
async function barcodeMockRPC(route, args, performRPC) {
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
const result = await performRPC(route, args);
const records = serverData.models[PRODUCT_PRODUCT].records
.filter((record) => record.barcode === args.kwargs.name)
.map((record) => [record.id, record.name]);
return records.concat(result);
}
}
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
[PRODUCT_PRODUCT]: {
fields: {
id: { type: "integer" },
name: {},
barcode: {},
},
records: [
{
id: 111,
name: "product_cable_management_box",
barcode: "601647855631",
},
{
id: 112,
name: "product_n95_mask",
barcode: "601647855632",
},
{
id: 113,
name: "product_surgical_mask",
barcode: "601647855633",
},
],
},
[SALE_ORDER_LINE]: {
fields: {
id: { type: "integer" },
[PRODUCT_FIELD_NAME]: {
string: PRODUCT_FIELD_NAME,
type: "many2one",
relation: PRODUCT_PRODUCT,
},
},
},
},
views: {
"product.product,false,kanban": `
<kanban><templates><t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="id"/>
<field name="name"/>
<field name="barcode"/>
</div>
</t></templates></kanban>
`,
"product.product,false,search": "<search></search>",
},
};
setupViewRegistries();
patchWithCleanup(AutoComplete, {
delay: 0,
});
// simulate a environment with a camera/webcam
patchWithCleanup(
browser,
Object.assign({}, browser, {
setTimeout: (fn) => fn(),
navigator: {
userAgent: "Chrome/0.0.0 (Linux; Android 13; Odoo TestSuite)",
mediaDevices: {
getUserMedia: () => [],
},
},
})
);
});
QUnit.module("Many2OneField Barcode (Small)");
QUnit.test("barcode button with multiple results", async function (assert) {
assert.expect(4);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[1];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => "mask",
});
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
</form>`,
async mockRPC(route, args, performRPC) {
if (args.method === CREATE && args.model === SALE_ORDER_LINE) {
const selectedId = args.args[0][PRODUCT_FIELD_NAME];
assert.equal(
selectedId,
selectedRecordTest.id,
`product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`
);
return performRPC(route, args, performRPC);
}
return barcodeMockRPC(route, args, performRPC);
},
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
await click(target, ".o_barcode");
const modal = target.querySelector(".modal-dialog.modal-lg");
assert.containsOnce(target, modal, "there should be one modal opened in full screen");
assert.containsN(
modal,
".o_kanban_record .oe_kanban_global_click",
2,
"there should be 2 records displayed"
);
await click(modal, ".o_kanban_record:nth-child(1)");
await clickSave(target);
});
});
@@ -0,0 +1,210 @@
/** @odoo-module **/
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { browser } from "@web/core/browser/browser";
import { click, clickSave, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import * as BarcodeScanner from "@web/webclient/barcode/barcode_scanner";
let serverData;
let target;
const CREATE = "create";
const NAME_SEARCH = "name_search";
const PRODUCT_PRODUCT = "product.product";
const SALE_ORDER_LINE = "sale_order_line";
const PRODUCT_FIELD_NAME = "product_id";
// MockRPC to allow the search in barcode too
async function barcodeMockRPC(route, args, performRPC) {
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
const result = await performRPC(route, args);
const records = serverData.models[PRODUCT_PRODUCT].records
.filter((record) => record.barcode === args.kwargs.name)
.map((record) => [record.id, record.name]);
return records.concat(result);
}
}
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
[PRODUCT_PRODUCT]: {
fields: {
id: { type: "integer" },
name: {},
barcode: {},
},
records: [
{
id: 111,
name: "product_cable_management_box",
barcode: "601647855631",
},
{
id: 112,
name: "product_n95_mask",
barcode: "601647855632",
},
{
id: 113,
name: "product_surgical_mask",
barcode: "601647855633",
},
],
},
[SALE_ORDER_LINE]: {
fields: {
id: { type: "integer" },
[PRODUCT_FIELD_NAME]: {
string: PRODUCT_FIELD_NAME,
type: "many2one",
relation: PRODUCT_PRODUCT,
},
},
},
},
};
setupViewRegistries();
patchWithCleanup(AutoComplete, {
delay: 0,
});
// simulate a environment with a camera/webcam
patchWithCleanup(
browser,
Object.assign({}, browser, {
setTimeout: (fn) => fn(),
navigator: {
userAgent: "Chrome/0.0.0 (Linux; Android 13; Odoo TestSuite)",
mediaDevices: {
getUserMedia: () => [],
},
},
})
);
});
QUnit.module("Many2OneField Barcode (Desktop)");
QUnit.test(
"Many2OneBarcode component should display the barcode icon",
async function (assert) {
assert.expect(1);
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
</form>
`,
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
}
);
QUnit.test("barcode button with single results", async function (assert) {
assert.expect(2);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[0];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => selectedRecordTest.barcode,
});
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
</form>
`,
async mockRPC(route, args, performRPC) {
if (args.method === CREATE && args.model === SALE_ORDER_LINE) {
const selectedId = args.args[0][PRODUCT_FIELD_NAME];
assert.equal(
selectedId,
selectedRecordTest.id,
`product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`
);
return performRPC(route, args, performRPC);
}
return barcodeMockRPC(route, args, performRPC);
},
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
await click(target, ".o_barcode");
await clickSave(target);
});
QUnit.test("barcode button with multiple results", async function (assert) {
assert.expect(4);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[1];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => "mask",
});
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
</form>`,
async mockRPC(route, args, performRPC) {
if (args.method === CREATE && args.model === SALE_ORDER_LINE) {
const selectedId = args.args[0][PRODUCT_FIELD_NAME];
assert.equal(
selectedId,
selectedRecordTest.id,
`product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`
);
return performRPC(route, args, performRPC);
}
return barcodeMockRPC(route, args, performRPC);
},
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
await click(target, ".o_barcode");
const autocompleteDropdown = target.querySelector(".o-autocomplete--dropdown-menu");
assert.containsOnce(
target,
autocompleteDropdown,
"there should be one autocomplete dropdown opened"
);
assert.containsN(
autocompleteDropdown,
".o-autocomplete--dropdown-item.ui-menu-item:not(.o_m2o_dropdown_option)",
2,
"there should be 2 records displayed"
);
await click(autocompleteDropdown, ".o-autocomplete--dropdown-item:nth-child(1)");
await clickSave(target);
});
});