From a8b13c24815b4a8bd74ca01274f1d50d236ee7ab Mon Sep 17 00:00:00 2001 From: "Michael (mcm)" Date: Thu, 23 Jun 2022 06:29:12 +0000 Subject: [PATCH] [IMP] web: add AutoComplete component This commit introduces an AutoComplete component, which displays an input and reacts to input events to display a list of suggestions in a dropdown. The main usecase for this component is the Many2One field. Part-of: odoo/odoo#92475 --- .../src/core/autocomplete/autocomplete.js | 318 ++++++++++++++++++ .../src/core/autocomplete/autocomplete.scss | 11 + .../src/core/autocomplete/autocomplete.xml | 59 ++++ addons/web/static/src/core/utils/timing.js | 60 ++-- .../static/tests/core/autocomplete_tests.js | 204 +++++++++++ 5 files changed, 629 insertions(+), 23 deletions(-) create mode 100644 addons/web/static/src/core/autocomplete/autocomplete.js create mode 100644 addons/web/static/src/core/autocomplete/autocomplete.scss create mode 100644 addons/web/static/src/core/autocomplete/autocomplete.xml create mode 100644 addons/web/static/tests/core/autocomplete_tests.js diff --git a/addons/web/static/src/core/autocomplete/autocomplete.js b/addons/web/static/src/core/autocomplete/autocomplete.js new file mode 100644 index 00000000000..d90b99c5eec --- /dev/null +++ b/addons/web/static/src/core/autocomplete/autocomplete.js @@ -0,0 +1,318 @@ +/** @odoo-module **/ + +import { useService } from "@web/core/utils/hooks"; +import { useDebounced } from "@web/core/utils/timing"; +import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; +import { usePosition } from "@web/core/position_hook"; + +const { Component, useExternalListener, useRef, useState, useEffect } = owl; + +export class AutoComplete extends Component { + setup() { + this.nextSourceId = 0; + this.nextOptionId = 0; + this.sources = []; + + this.state = useState({ + navigationRev: 0, + optionsRev: 0, + open: false, + activeSourceOption: null, + }); + + this.inputRef = useRef("input"); + this.root = useRef("root"); + this.debouncedOnInput = useDebounced(this.onInput, this.constructor.timeout); + useExternalListener(window, "scroll", this.onWindowScroll, true); + + this.hotkey = useService("hotkey"); + this.hotkeysToRemove = []; + + owl.onWillUpdateProps((nextProps) => { + if (nextProps.forceClose) { + this.inputRef.el.value = nextProps.value; + this.close(); + } + }); + + // position and size + const sourcesListRef = useRef("sourcesList"); + useEffect( + () => { + if (sourcesListRef.el) { + sourcesListRef.el.style.width = + this.inputRef.el.getBoundingClientRect().width + "px"; + } + }, + () => [sourcesListRef.el] + ); + usePosition(() => this.inputRef.el, { + popper: "sourcesList", + }); + } + + get isOpened() { + return this.state.open; + } + + open(useInput = false) { + this.state.open = true; + this.loadSources(useInput); + } + + close() { + this.state.open = false; + this.state.activeSourceOption = null; + } + + loadSources(useInput) { + const sources = []; + const proms = []; + for (const pSource of this.props.sources) { + const source = this.makeSource(pSource); + sources.push(source); + + const options = this.loadOptions( + pSource.options, + useInput ? this.inputRef.el.value.trim() : "" + ); + if (options instanceof Promise) { + source.isLoading = true; + const prom = options.then((options) => { + source.options = options.map((option) => this.makeOption(option)); + source.isLoading = false; + this.state.optionsRev++; + }); + proms.push(prom); + } else { + source.options = options.map((option) => this.makeOption(option)); + } + } + this.sources = sources; + Promise.all(proms).then(() => { + this.navigate(0); + }); + } + loadOptions(options, request) { + if (typeof options === "function") { + return options(request); + } else { + return options; + } + } + makeOption(option) { + return Object.assign(Object.create(option), { + id: ++this.nextOptionId, + }); + } + makeSource(source) { + return { + id: ++this.nextSourceId, + options: [], + isLoading: false, + placeholder: source.placeholder, + optionTemplate: source.optionTemplate, + }; + } + + isActiveSourceOption([sourceIndex, optionIndex]) { + return ( + this.state.activeSourceOption && + this.state.activeSourceOption[0] === sourceIndex && + this.state.activeSourceOption[1] === optionIndex + ); + } + selectOption(indices, params = {}) { + this.inputRef.el.value = ""; + + const option = this.sources[indices[0]].options[indices[1]]; + if (option.unselectable) { + return; + } + + this.props.onSelect(option, { + ...params, + inputValue: this.inputRef.el.value.trim(), + input: this.inputRef.el, + }); + this.close(); + } + + navigate(direction) { + let step = Math.sign(direction); + if (!step) { + this.state.activeSourceOption = null; + step = 1; + } else { + this.state.navigationRev++; + } + + if (this.state.activeSourceOption) { + let [sourceIndex, optionIndex] = this.state.activeSourceOption; + let source = this.sources[sourceIndex]; + + optionIndex += step; + if (0 > optionIndex || optionIndex >= source.options.length) { + sourceIndex += step; + source = this.sources[sourceIndex]; + + while (source && source.isLoading) { + sourceIndex += step; + source = this.sources[sourceIndex]; + } + + if (source) { + optionIndex = step < 0 ? source.options.length - 1 : 0; + } + } + + this.state.activeSourceOption = source ? [sourceIndex, optionIndex] : null; + } else { + let sourceIndex = step < 0 ? this.sources.length - 1 : 0; + let source = this.sources[sourceIndex]; + + while (source && source.isLoading) { + sourceIndex += step; + source = this.sources[sourceIndex]; + } + + if (source) { + const optionIndex = step < 0 ? source.options.length - 1 : 0; + this.state.activeSourceOption = [sourceIndex, optionIndex]; + } + } + } + + onInputBlur() { + 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.close(); + } + } + onInputClick() { + if (!this.isOpened) { + this.open(this.inputRef.el.value.trim() !== this.props.value); + } else { + this.close(); + } + } + onInputChange() { + this.props.onChange({ + inputValue: this.inputRef.el.value, + }); + } + onInput() { + this.props.onInput({ + inputValue: this.inputRef.el.value, + }); + this.open(true); + } + + onInputKeydown(ev) { + const hotkey = getActiveHotkey(ev); + switch (hotkey) { + case "enter": + if (!this.isOpened || !this.state.activeSourceOption) { + return; + } + this.selectOption(this.state.activeSourceOption); + break; + case "escape": + if (!this.isOpened) { + return; + } + this.close(); + break; + case "tab": + if (!this.isOpened) { + return; + } + if ( + this.props.autoSelect && + this.state.activeSourceOption && + (this.state.navigationRev > 0 || this.inputRef.el.value.length > 0) + ) { + this.selectOption(this.state.activeSourceOption); + } + this.close(); + return; + case "arrowup": + this.navigate(-1); + if (!this.isOpened) { + this.open(true); + } + break; + case "arrowdown": + this.navigate(+1); + if (!this.isOpened) { + this.open(true); + } + break; + default: + return; + } + + ev.stopPropagation(); + ev.preventDefault(); + } + + onOptionMouseEnter(indices) { + this.state.activeSourceOption = indices; + } + onOptionMouseLeave() { + this.state.activeSourceOption = null; + } + onOptionClick(indices) { + this.selectOption(indices); + } + + onWindowScroll(ev) { + if (this.isOpened && !this.root.el.contains(ev.target)) { + this.close(); + } + } +} +Object.assign(AutoComplete, { + template: "web.AutoComplete", + props: { + value: { type: String }, + id: { type: String, optional: true }, + onSelect: { type: Function }, + sources: { + type: Array, + element: { + type: Object, + shape: { + placeholder: { type: String, optional: true }, + optionTemplate: { type: String, optional: true }, + options: [Array, Function], + }, + }, + }, + placeholder: { type: String, optional: true }, + autoSelect: { type: Boolean, optional: true }, + onInput: { type: Function, optional: true }, + onChange: { type: Function, optional: true }, + onBlur: { type: Function, optional: true }, + forceClose: { type: Boolean, optional: true }, + }, + defaultProps: { + placeholder: "", + autoSelect: false, + onInput: () => {}, + onChange: () => {}, + onBlur: () => {}, + forceClose: false, + }, + timeout: 250, +}); diff --git a/addons/web/static/src/core/autocomplete/autocomplete.scss b/addons/web/static/src/core/autocomplete/autocomplete.scss new file mode 100644 index 00000000000..2f186ac15b1 --- /dev/null +++ b/addons/web/static/src/core/autocomplete/autocomplete.scss @@ -0,0 +1,11 @@ + +.o-autocomplete { + .o-autocomplete--input { + width: 100%; + } + + .o-autocomplete--dropdown-menu { + left: 0; + right: 0; + } +} diff --git a/addons/web/static/src/core/autocomplete/autocomplete.xml b/addons/web/static/src/core/autocomplete/autocomplete.xml new file mode 100644 index 00000000000..2654ff87029 --- /dev/null +++ b/addons/web/static/src/core/autocomplete/autocomplete.xml @@ -0,0 +1,59 @@ + + + + + + + + diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js index 7cc64925030..b40470dab23 100644 --- a/addons/web/static/src/core/utils/timing.js +++ b/addons/web/static/src/core/utils/timing.js @@ -2,6 +2,8 @@ import { browser } from "@web/core/browser/browser"; +const { onWillUnmount, useComponent } = owl; + /** * Creates a version of the function where only the last call between two * animation frames is executed before the browser's next repaint. This @@ -31,6 +33,33 @@ export function throttleForAnimation(func) { ); } +/** + * Returns a function, that, as long as it continues to be invoked, will be + * triggered every N milliseconds. + * + * @deprecated this function has behaviour that is unexpected considering its + * name, prefer _.throttle until this function is rewritten + * @param {Function} func + * @param {number} delay + * @returns {Function} + */ +export function throttle(func, delay) { + let waiting = false; + const funcName = func.name ? func.name + " (throttle)" : "throttle"; + return { + [funcName](...args) { + const context = this; + if (!waiting) { + waiting = true; + browser.setTimeout(function () { + waiting = false; + func.call(context, ...args); + }, delay); + } + }, + }[funcName]; +} + /** * Creates and returns a new debounced version of the passed function (func) * which will postpone its execution until after 'delay' milliseconds @@ -81,28 +110,13 @@ export function debounce(func, delay, immediate = false) { } /** - * Returns a function, that, as long as it continues to be invoked, will be - * triggered every N milliseconds. - * - * @deprecated this function has behaviour that is unexpected considering its - * name, prefer _.throttle until this function is rewritten - * @param {Function} func - * @param {number} delay - * @returns {Function} + * Hook that returns a debounced version of the given function, and cancels + * the potential pending execution on willUnmount. + * @see debounce */ -export function throttle(func, delay) { - let waiting = false; - const funcName = func.name ? func.name + " (throttle)" : "throttle"; - return { - [funcName](...args) { - const context = this; - if (!waiting) { - waiting = true; - browser.setTimeout(function () { - waiting = false; - func.call(context, ...args); - }, delay); - } - }, - }[funcName]; +export function useDebounced(callback, delay) { + const component = useComponent(); + const debounced = debounce(callback.bind(component), delay); + onWillUnmount(() => debounced.cancel()); + return debounced; } diff --git a/addons/web/static/tests/core/autocomplete_tests.js b/addons/web/static/tests/core/autocomplete_tests.js new file mode 100644 index 00000000000..5f1b07d1613 --- /dev/null +++ b/addons/web/static/tests/core/autocomplete_tests.js @@ -0,0 +1,204 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; +import { registry } from "@web/core/registry"; +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { makeTestEnv } from "../helpers/mock_env"; +import { + click, + getFixture, + mount, + patchWithCleanup, + triggerEvent, + triggerEvents, +} from "../helpers/utils"; + +const { Component, useState, xml } = owl; + +const serviceRegistry = registry.category("services"); + +let env; +let target; + +QUnit.module("Components", (hooks) => { + hooks.beforeEach(async () => { + serviceRegistry.add("hotkey", hotkeyService); + env = await makeTestEnv(); + target = getFixture(); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + }); + + QUnit.module("AutoComplete"); + + QUnit.test("can be rendered", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsOnce(target, ".o-autocomplete"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await click(target, ".o-autocomplete--input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + const options = [...target.querySelectorAll(".o-autocomplete--dropdown-item")]; + assert.deepEqual( + options.map((el) => el.textContent), + ["World", "Hello"] + ); + }); + + QUnit.test("select option", async (assert) => { + class Parent extends Component { + setup() { + this.state = useState({ + value: "Hello", + }); + } + get sources() { + return [ + { + options: [{ label: "World" }, { label: "Hello" }], + }, + ]; + } + onSelect(option) { + this.state.value = option.label; + assert.step(option.label); + } + } + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello"); + + await click(target, ".o-autocomplete--input"); + await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[0]); + assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "World"); + assert.verifySteps(["World"]); + + await click(target, ".o-autocomplete--input"); + await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1]); + assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello"); + assert.verifySteps(["Hello"]); + }); + + QUnit.test("open dropdown on input", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + await triggerEvent(target, ".o-autocomplete--input", "input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("close dropdown on escape keydown", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Escape" }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("scroll outside should close dropdown", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await click(target, ".o-autocomplete--input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, null, "scroll"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("scroll inside should keep dropdown open", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await click(target, ".o-autocomplete--input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, ".o-autocomplete--dropdown-menu", "scroll"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("losing focus should close dropdown", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, ".o-autocomplete--input", "blur"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + }); +});