[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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user