[FIX] web, *: autocomplete cancels search on click out

This commit fixes a bug where the value of an autocomplete with autoselect
could go into an undefined state where the model value doesn't match the
input value but no change is detected. This would happen on escape press,
scroll and more recently due to https://github.com/odoo/odoo/pull/159333
on click out. To fix this issue, the commit introduces some change in the
autocomplete behavior so that it will no longer select the active option
on click out (blur) when autoselect is active and instead it will revert
the input value to the one stored into the model. Also applies this
behavior on escape key press and scroll for the before mentioned reasons.

Steps to reproduce:
- Go to CRM form view
- Edit the salesperson name to some non existant one and click out (or
scroll or press escape)

Before the fix, the name was stuck to the invalid one but no change was
detected so we couldnt save it. Before https://github.com/odoo/odoo/pull/159333,
click out would select the active option of the dropdown instead (and
launch a dialog for quick create). This is removed because the behavior
is neither intuitive nor practical. After the fix, the name is reset to its
previous valid value instead. This applies to all autocomplete components
with autoselect prop set to true.

closes odoo/odoo#162040

X-original-commit: 9d9026ed0c7bb0af6cf910cc206cf8b55d1b7a36
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Signed-off-by: Julien Carion (juca) <juca@odoo.com>
This commit is contained in:
Julien Carion (juca)
2024-04-17 13:44:45 +00:00
parent ef631c5d10
commit 77aadf471c
14 changed files with 137 additions and 100 deletions
@@ -73,6 +73,9 @@ export class PartnerAutoCompleteCharField extends CharField {
}
});
this.props.record.update(data.company);
if (this.props.setDirty) {
this.props.setDirty(false);
}
}
}
@@ -4,11 +4,11 @@ import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import {
click,
editInput,
editSelect,
getFixture,
patchWithCleanup,
triggerEvent,
editInput,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { loadJS } from "@web/core/assets";
@@ -348,18 +348,20 @@ QUnit.module('partner_autocomplete', {
}
});
QUnit.test("Show confirmation dialog on input blur", async function (assert) {
assert.expect(1);
QUnit.test("Click out after edition", async function (assert) {
assert.expect(2);
await makeView(makeViewParams);
const input = target.querySelector("[name=parent_id] input.o-autocomplete--input.o_input");
await triggerEvent(input, null, "focus");
await click(input);
await editInput(input, null, "go");
assert.strictEqual(input.value, "go");
await triggerEvent(target, null, "pointerdown");
await triggerEvent(input, null, "blur");
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(input.value, "");
});
QUnit.test("Hide auto complate suggestion for no create", async function (assert) {
QUnit.test("Hide auto complete suggestion for no create", async function (assert) {
const partnerMakeViewParams = {
...makeViewParams,
arch:
@@ -20,7 +20,7 @@ export class SaleOrderLineProductField extends Many2OneField {
isInternalUpdate = true;
return updateRecord.call(this, value);
};
useEffect(value => {
useEffect(value => {
if (!isMounted) {
isMounted = true;
} else if (value && isInternalUpdate) {
@@ -1,7 +1,13 @@
/** @odoo-module **/
import {
getFixture, patchWithCleanup, addRow, editInput, triggerEvent, click } from "@web/../tests/helpers/utils";
getFixture,
patchWithCleanup,
addRow,
editInput,
triggerHotkey,
nextTick
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { browser } from "@web/core/browser/browser";
@@ -88,7 +94,7 @@ QUnit.module("Fields", (hooks) => {
QUnit.module("Sale product field");
QUnit.test("blurring input with incomplete text will propose to create product", async function (assert) {
QUnit.test("pressing tab with incomplete text will create a product", async function (assert) {
await makeView({
type: "form",
@@ -112,23 +118,16 @@ QUnit.module("Fields", (hooks) => {
// add a line and enter new product name
await addRow(target, ".o_field_x2many_list");
await editInput(target, "[name='product_template_id'] input", "new product");
// blur input => should ask for confirmation if we want to create product
await triggerEvent(target, "[name='product_template_id'] input", "blur");
assert.containsOnce(target, ".modal:contains(Create new product as a new Product)")
await triggerHotkey("tab");
await nextTick();
assert.verifySteps([
"get_views",
"onchange",
"onchange",
"name_search",
]);
await click(target, ".modal button.btn-primary");
assert.verifySteps([
"name_create",
"get_single_product_variant",
]);
});
});
@@ -104,6 +104,16 @@ export class AutoComplete extends Component {
this.state.activeSourceOption = null;
}
cancel() {
if (this.inputRef.el.value.length) {
if (this.props.autoSelect) {
this.inputRef.el.value = this.props.value;
this.props.onCancel();
}
}
this.close();
}
async loadSources(useInput) {
this.sources = [];
this.state.activeSourceOption = null;
@@ -237,21 +247,10 @@ export class AutoComplete extends Component {
this.ignoreBlur = false;
return;
}
const value = this.inputRef.el.value;
if (
this.props.autoSelect &&
this.state.activeSourceOption &&
value.length > 0 &&
value !== this.props.value
) {
this.selectOption(this.state.activeSourceOption, { triggeredOnBlur: true });
} else {
this.props.onBlur({
inputValue: value,
});
this.inEdition = false;
this.close();
}
this.props.onBlur({
inputValue: this.inputRef.el.value,
});
this.inEdition = false;
}
onInputClick() {
if (!this.isOpened) {
@@ -325,7 +324,7 @@ export class AutoComplete extends Component {
if (!this.isOpened) {
return;
}
this.close();
this.cancel();
break;
case "tab":
if (!this.isOpened) {
@@ -373,7 +372,7 @@ export class AutoComplete extends Component {
externalClose(ev) {
if (this.isOpened && !this.root.el.contains(ev.target)) {
this.close();
this.cancel();
}
}
}
@@ -397,6 +396,7 @@ Object.assign(AutoComplete, {
placeholder: { type: String, optional: true },
autoSelect: { type: Boolean, optional: true },
resetOnSelect: { type: Boolean, optional: true },
onCancel: { type: Function, optional: true },
onInput: { type: Function, optional: true },
onChange: { type: Function, optional: true },
onBlur: { type: Function, optional: true },
@@ -411,6 +411,7 @@ Object.assign(AutoComplete, {
placeholder: "",
autoSelect: false,
dropdown: true,
onCancel: () => {},
onInput: () => {},
onChange: () => {},
onBlur: () => {},
@@ -134,10 +134,7 @@ export class Many2OneField extends Component {
};
if (this.props.canQuickCreate) {
this.quickCreate = (name, params = {}) => {
if (params.triggeredOnBlur) {
return this.openConfirmationDialog(name);
}
this.quickCreate = (name) => {
this.state.isFloating = false;
return this.updateRecord([false, name]);
};
@@ -368,7 +365,7 @@ export const many2OneField = {
extractProps({ attrs, context, decorations, options, string }, dynamicInfo) {
const hasCreatePermission = attrs.can_create ? evaluateBooleanExpr(attrs.can_create) : true;
const hasWritePermission = attrs.can_write ? evaluateBooleanExpr(attrs.can_write) : true;
const canCreate = options.no_create ? false : hasCreatePermission
const canCreate = options.no_create ? false : hasCreatePermission;
return {
placeholder: attrs.placeholder,
canOpen: !options.no_open,
@@ -293,10 +293,6 @@ export class PropertyValue extends Component {
* @param {object} params
*/
async onQuickCreate(name, params = {}) {
if (params.triggeredOnBlur) {
this.onValueChange(false);
return;
}
const result = await this.orm.call(this.props.comodel, "name_create", [name], {
context: this.props.context,
});
@@ -253,6 +253,9 @@ export class Many2XAutocomplete extends Component {
this.props.setInputFloats(true);
}
}
onCancel() {
this.props.setInputFloats(false);
}
onSelect(option, params = {}) {
if (option.action) {
@@ -59,6 +59,7 @@
dropdown="props.dropdown"
autofocus="props.autofocus"
resetOnSelect="props.value === ''"
onCancel.bind="onCancel"
/>
<span class="o_dropdown_button" />
</div>
@@ -164,7 +164,7 @@ QUnit.module("Components", (hooks) => {
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
});
QUnit.test("close dropdown on escape keydown", async (assert) => {
QUnit.test("cancel result on escape keydown", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
@@ -172,17 +172,21 @@ QUnit.module("Components", (hooks) => {
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "H");
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Escape" });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
});
QUnit.test("select input text on first focus", async (assert) => {
@@ -198,7 +202,7 @@ QUnit.module("Components", (hooks) => {
assert.strictEqual(el.value.substring(el.selectionStart, el.selectionEnd), "Bar");
});
QUnit.test("scroll outside should close dropdown", async (assert) => {
QUnit.test("scroll outside should cancel result", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
@@ -206,17 +210,21 @@ QUnit.module("Components", (hooks) => {
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await click(target, ".o-autocomplete--input");
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "H");
await triggerEvent(target, null, "scroll");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
});
QUnit.test("scroll inside should keep dropdown open", async (assert) => {
@@ -240,7 +248,33 @@ QUnit.module("Components", (hooks) => {
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
});
QUnit.test("losing focus should close dropdown", async (assert) => {
QUnit.test("losing focus should cancel result", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
autoSelect="true"
/>
`;
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "H");
await triggerEvent(target, "", "pointerdown");
await triggerEvent(target, ".o-autocomplete--input", "blur");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
});
QUnit.test("click out after clearing input", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
@@ -253,12 +287,16 @@ QUnit.module("Components", (hooks) => {
await mount(Parent, target, { env });
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
await editInput(target, ".o-autocomplete--input", "");
await triggerEvent(target, "", "pointerdown");
await triggerEvent(target, ".o-autocomplete--input", "blur");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
});
QUnit.test("open twice should not display previous results", async (assert) => {
@@ -495,10 +533,9 @@ QUnit.module("Components", (hooks) => {
onChange() {
assert.step("change");
}
onSelect(option, params) {
onSelect(option) {
target.querySelector(".o-autocomplete--input").value = option.label;
assert.step("select " + option.label);
assert.notOk(params.triggeredOnBlur);
}
onBlur() {
assert.step("blur");
@@ -1014,7 +1014,8 @@ QUnit.module("Fields", (hooks) => {
await triggerEvent(input, null, "focus");
await click(input);
await editInput(input, null, "go");
await triggerEvent(input, null, "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsNone(document.body, ".modal");
assert.containsOnce(target, ".o_field_many2many_tags .badge");
@@ -1028,7 +1029,8 @@ QUnit.module("Fields", (hooks) => {
await click(input);
await editInput(input, null, "r");
await triggerEvent(input, null, "keydown", { key: "ArrowDown" });
await triggerEvent(input, null, "blur");
await triggerHotkey("tab");
await nextTick();
assert.strictEqual(
target.querySelectorAll(".o_field_many2many_tags .badge")[1].textContent,
"red",
@@ -2950,7 +2950,7 @@ QUnit.module("Fields", (hooks) => {
);
QUnit.test("quick create on a many2one", async function (assert) {
assert.expect(2);
assert.expect(1);
await makeView({
type: "form",
@@ -2971,13 +2971,7 @@ QUnit.module("Fields", (hooks) => {
await triggerEvent(target, ".o_field_many2one input", "focus");
await editInput(target, ".o_field_many2one input", "new partner");
await triggerEvent(target, ".o_field_many2one input", "blur");
assert.strictEqual(
target.querySelector(".modal .modal-body").textContent.trim(),
"Create new partner as a new Product?"
);
await click(target, ".modal .modal-footer .btn-primary");
await triggerHotkey("tab");
});
QUnit.test(
@@ -3117,7 +3111,8 @@ QUnit.module("Fields", (hooks) => {
// cancel the many2one creation with Discard button
await editInput(target, ".o_field_many2one input", "new product");
await triggerEvent(target, ".o_field_many2one input", "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsOnce(target, ".modal", "there should be one opened modal");
@@ -3131,7 +3126,8 @@ QUnit.module("Fields", (hooks) => {
// cancel the many2one creation with Close button
await editInput(target, ".o_field_many2one input", "new product");
await triggerEvent(target, ".o_field_many2one input", "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsOnce(target, ".modal", "there should be one opened modal");
await click(target, ".modal .modal-header button");
@@ -3153,7 +3149,8 @@ QUnit.module("Fields", (hooks) => {
);
await editInput(target, ".o_field_many2one input", "new product");
await triggerEvent(target, ".o_field_many2one input", "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsOnce(target, ".modal", "there should be one opened modal");
await click(target, ".modal .modal-footer .btn:not(.btn-primary)");
@@ -3165,7 +3162,8 @@ QUnit.module("Fields", (hooks) => {
// confirm the many2one creation
await editInput(target, ".o_field_many2one input", "new product");
await triggerEvent(target, ".o_field_many2one input", "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsOnce(
target,
@@ -3185,7 +3183,8 @@ QUnit.module("Fields", (hooks) => {
});
await editInput(target, ".o_field_many2one input", "xph");
await triggerEvent(target, ".o_field_many2one input", "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsNone(target, ".modal");
assert.strictEqual(target.querySelector(".o_field_many2one input").value, "xphone");
@@ -3206,7 +3205,8 @@ QUnit.module("Fields", (hooks) => {
});
await editInput(target, ".o_field_many2one input", "new partner");
await triggerEvent(target, ".o_field_many2one input", "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsNone(target, ".modal", "should not display the create modal");
assert.strictEqual(
@@ -3230,7 +3230,8 @@ QUnit.module("Fields", (hooks) => {
</form>`,
});
await editInput(target, ".o_field_many2one input", "new partner");
await triggerEvent(target, ".o_field_many2one input", "blur");
await triggerHotkey("tab");
await nextTick();
assert.containsNone(target, ".modal", "should not display the create modal");
assert.strictEqual(
@@ -3240,30 +3241,33 @@ QUnit.module("Fields", (hooks) => {
);
});
QUnit.test("no_quick_create option on a many2one when can_create is absent", async function (assert) {
serverData.models.partner.fields.product_id.readonly = true;
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
QUnit.test(
"no_quick_create option on a many2one when can_create is absent",
async function (assert) {
serverData.models.partner.fields.product_id.readonly = true;
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<sheet>
<field name="product_id" options="{'no_quick_create': 1}" readonly="0" />
</sheet>
</form>`,
});
await editInput(target, ".o_field_many2one input", "new partner");
assert.containsOnce(
target,
".ui-autocomplete .o_m2o_dropdown_option",
"Dropdown should be opened and have only one item"
);
assert.hasClass(
target.querySelector(".ui-autocomplete .o_m2o_dropdown_option"),
"o_m2o_dropdown_option_create_edit"
);
});
});
await editInput(target, ".o_field_many2one input", "new partner");
assert.containsOnce(
target,
".ui-autocomplete .o_m2o_dropdown_option",
"Dropdown should be opened and have only one item"
);
assert.hasClass(
target.querySelector(".ui-autocomplete .o_m2o_dropdown_option"),
"o_m2o_dropdown_option_create_edit"
);
}
);
QUnit.test("can_create and can_write option on a many2one", async function (assert) {
serverData.models.product.options = {
@@ -3451,12 +3455,8 @@ QUnit.module("Fields", (hooks) => {
"there should be option for 'No records'"
);
await triggerEvent(target, ".o_field_many2one[name=product_id] input", "blur");
assert.containsNone(
target,
".o_field_many2one[name=product_id] .o_m2o_no_result",
"there should be option for 'No records'"
);
await triggerEvent(target, "", "pointerdown");
assert.containsNone(target, ".o_field_many2one[name=product_id] .o_m2o_no_result");
}
);
@@ -3571,6 +3571,7 @@ QUnit.module("Views", (hooks) => {
QUnit.test("quick create record: cancel when modal is opened", async (assert) => {
serverData.views["partner,some_view_ref,form"] = '<form><field name="product_id"/></form>';
serverData.views["product,false,form"] = '<form><field name="name"/></form>';
// patch setTimeout s.t. the autocomplete dropdown opens directly
patchWithCleanup(browser, {
@@ -3596,7 +3597,7 @@ QUnit.module("Views", (hooks) => {
await editInput(target, ".o_kanban_quick_create input", "test");
await triggerEvent(target, ".o_kanban_quick_create input", "input");
await triggerEvent(target, ".o_kanban_quick_create input", "blur");
await click(target, ".o_m2o_dropdown_option_create_edit");
// When focusing out of the many2one, a modal to add a 'product' will appear.
// The following assertions ensures that a click on the body element that has 'modal-open'
@@ -13115,10 +13115,9 @@ QUnit.module("Views", (hooks) => {
await editInput(target, ".o_field_many2one input", "abcdef");
await nextTick();
// simulate focus out
await triggerEvent(target, ".o_field_many2one input", "blur");
await click(target, ".o_m2o_dropdown_option_create_edit");
assert.containsOnce(target, ".modal", "should ask confirmation to create a record");
assert.containsOnce(target, ".modal", "should show dialog to create the record");
assert.containsOnce(target, ".o_data_row", "the row should still be there");
}
);
@@ -16809,13 +16808,9 @@ QUnit.module("Views", (hooks) => {
const input = target.querySelector(".o_data_row .o_data_cell input");
await editInput(input, null, "aaa");
await triggerEvents(input, null, ["keyup", "blur"]);
document.body.click();
await triggerEvents(input, null, ["keyup"]);
await triggerHotkey("tab");
await nextTick();
assert.containsOnce(target, ".modal", "the quick_create modal should appear");
await click(target.querySelector(".modal .btn-primary"));
await click(target.querySelector(".o_list_view"));
assert.strictEqual(
target.getElementsByClassName("o_data_cell")[0].innerHTML,
"aaa",