[REF] web(site): migrate jQueryUI urlautocomplete to owl component

*: web, website, website_form_project

Remove the jQuery UI Widget `urlautocomplete` by using made some
refactor to allow usage of the owl component.

task-3439226

Part-of: odoo/odoo#139209
This commit is contained in:
Romain Estievenart
2023-11-02 21:02:44 +00:00
parent fb215afb82
commit 86a9171ec7
16 changed files with 416 additions and 120 deletions
@@ -64,7 +64,7 @@ export class AutoComplete extends Component {
// position and size
if (this.props.dropdown) {
usePosition("sourcesList", () => this.inputRef.el, {
usePosition("sourcesList", () => this.targetDropdown, {
position: "bottom-start",
});
} else {
@@ -72,6 +72,10 @@ export class AutoComplete extends Component {
}
}
get targetDropdown() {
return this.inputRef.el;
}
get isOpened() {
return this.state.open;
}
@@ -263,6 +267,7 @@ export class AutoComplete extends Component {
this.inputRef.el.setSelectionRange(0, this.inputRef.el.value.length);
this.props.onFocus(ev);
}
get autoCompleteRootClass() {
let classList = "";
if (this.props.class) {
@@ -274,6 +279,16 @@ export class AutoComplete extends Component {
return classList;
}
get ulDropdownClass() {
let classList = "";
if (this.props.dropdown) {
classList += " dropdown-menu ui-autocomplete";
} else {
classList += " list-group";
}
return classList;
}
async onInputKeydown(ev) {
const hotkey = getActiveHotkey(ev);
const isSelectKey = hotkey === "enter" || hotkey === "tab";
@@ -20,10 +20,7 @@
/>
<t t-if="displayOptions">
<ul class="o-autocomplete--dropdown-menu ui-widget show"
t-att-class="{
'dropdown-menu ui-autocomplete': props.dropdown,
'list-group': !props.dropdown
}"
t-att-class="ulDropdownClass"
t-on-mousedown.prevent="" t-ref="sourcesList">
<t t-foreach="sources" t-as="source" t-key="source.id">
<t t-if="source.isLoading">
+4
View File
@@ -131,6 +131,8 @@
'website/static/src/scss/website.ui.scss',
'website/static/src/libs/zoomodoo/zoomodoo.js',
'website/static/src/js/utils.js',
'web/static/src/core/autocomplete/*',
'website/static/src/components/autocomplete_with_pages/*',
'website/static/src/js/tours/tour_utils.js',
'website/static/src/js/content/website_root.js',
'website/static/src/js/widgets/dialog.js',
@@ -193,6 +195,8 @@
'website/static/src/components/views/*',
'website/static/src/services/website_service.js',
'website/static/src/js/utils.js',
'web/static/src/core/autocomplete/*',
'website/static/src/components/autocomplete_with_pages/*',
'website/static/src/xml/website.xml',
# Don't include dark mode files in light mode
+3 -2
View File
@@ -366,10 +366,11 @@ class Website(Home):
for name, url, mod in current_website.get_suggested_controllers():
if needle.lower() in name.lower() or needle.lower() in url.lower():
module_sudo = mod and request.env.ref('base.module_%s' % mod, False).sudo()
icon = mod and "<img src='%s' width='24px' height='24px' class='mr-2 rounded' /> " % (module_sudo and module_sudo.icon or mod) or ''
icon = mod and '%s' % (module_sudo and module_sudo.icon or mod) or ''
suggested_controllers.append({
'value': url,
'label': '%s%s (%s)' % (icon, url, name),
'icon': icon,
'label': '%s (%s)' % (url, name),
})
return {
@@ -0,0 +1,156 @@
/** @odoo-module **/
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { useEffect } from "@odoo/owl";
export class AutoCompleteWithPages extends AutoComplete {
static template = "website.AutoCompleteWithPages";
setup() {
super.setup();
useEffect(
(input, inputRef) => {
if (inputRef) {
inputRef.value = input.value;
}
const targetBlur = this.onInputBlur.bind(this);
const targetClick = this._syncInputClick.bind(this);
const targetChange = this.onInputChange.bind(this);
const targetInput = this._syncInputValue.bind(this);
const targetKeydown = this.onInputKeydown.bind(this);
const targetFocus = this.onInputFocus.bind(this);
input.addEventListener("blur", targetBlur);
input.addEventListener("click", targetClick);
input.addEventListener("change", targetChange);
input.addEventListener("input", targetInput);
input.addEventListener("keydown", targetKeydown);
input.addEventListener("focus", targetFocus);
return () => {
input.removeEventListener("blur", targetBlur);
input.removeEventListener("click", targetClick);
input.removeEventListener("change", targetChange);
input.removeEventListener("input", targetInput);
input.removeEventListener("keydown", targetKeydown);
input.removeEventListener("focus", targetFocus);
};
},
() => [this.targetDropdown, this.inputRef.el]
);
}
get dropdownOptions() {
if (this.props.dropdownOptions) {
return {
...super.dropdownOptions,
...this.props.dropdownOptions,
};
}
return super.dropdownOptions;
}
get ulDropdownClass() {
let classList = super.ulDropdownClass;
if (this.props.dropdownClass) {
classList += ` ${this.props.dropdownClass}`;
}
return classList;
}
get targetDropdown() {
return this.props.targetDropdown;
}
_syncInputClick(ev) {
ev.stopPropagation();
this.onInputClick(ev);
}
async _syncInputValue() {
if (this.inputRef.el) {
this.inputRef.el.value = this.targetDropdown.value;
this.onInput();
}
}
/**
*
* @param indices
* @return {boolean}
* @private
*/
_isCategory(indices) {
const [sourceIndex, optionIndex] = indices;
return !!this.sources[sourceIndex]?.options[optionIndex]?.separator;
}
/**
* @override
*/
onOptionMouseEnter(indices) {
if (!this._isCategory(indices)) {
return super.onOptionMouseEnter(...arguments);
}
}
/**
* @override
*/
onOptionMouseLeave(indices) {
if (!this._isCategory(indices)) {
return super.onOptionMouseLeave(...arguments);
}
}
isActiveSourceOption(indices) {
if (!this._isCategory(indices)) {
return super.isActiveSourceOption(...arguments);
}
}
/**
* @override
*/
selectOption(indices) {
if (!this._isCategory(indices)) {
const [sourceIndex, optionIndex] = indices;
const { value } = Object.getPrototypeOf(this.sources[sourceIndex].options[optionIndex]);
this.targetDropdown.value = value;
return super.selectOption(...arguments);
}
}
/**
* @override
*/
navigate(direction) {
super.navigate(direction);
if (direction !== 0 && this.state.activeSourceOption) {
let [sourceIndex, optionIndex] = this.state.activeSourceOption;
const option = this.sources[sourceIndex]?.options[optionIndex];
if (option) {
if (!!option.separator) {
this.navigate(direction);
}
const suggestion = Object.getPrototypeOf(option);
if (suggestion && suggestion.value) {
this.inputRef.el.value = suggestion.value;
}
}
}
}
/**
* @override
*/
close() {
this.props.onInput({
inputValue: this.inputRef.el.value,
});
super.close();
}
}
AutoCompleteWithPages.props = {
...AutoComplete.props,
targetDropdown: { type: HTMLElement },
dropdownClass: { type: String, optional: true },
dropdownOptions: { type: Object, optional: true },
};
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="website.AutoCompleteWithPages" t-inherit="web.AutoComplete">
<xpath expr="//input[@t-ref='input']" position="attributes">
<attribute name="class" add="d-none" separator=" " />
</xpath>
</t>
</templates>
@@ -0,0 +1,95 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { AutoCompleteWithPages } from "@website/components/autocomplete_with_pages/autocomplete_with_pages";
export class UrlAutoComplete extends Component {
static template = "website.UrlAutoComplete";
static components = { AutoCompleteWithPages };
setup() {
super.setup();
this.rpc = useService("rpc");
}
_mapItemToSuggestion(item) {
return {
...item,
classList: item.separator ? "ui-autocomplete-category" : "ui-autocomplete-item",
};
}
get dropdownClass() {
const classList = [];
for (const key in this.props.options?.classes) {
classList.push(key, this.props.options.classes[key]);
}
return classList.join(" ")
}
get dropdownOptions() {
const options = {};
if (this.props.options?.position) {
options.position = this.props.options?.position;
}
return options;
}
get sources() {
return [
{
optionTemplate: "website.AutoCompleteWithPagesItem",
options: async (term) => {
if (term[0] === "#") {
const anchors = await this.props.loadAnchors(
term,
this.props.options && this.props.options.body
);
return anchors.map((anchor) =>
this._mapItemToSuggestion({ label: anchor, value: anchor })
);
} else if (term.startsWith("http") || term.length === 0) {
// avoid useless call to /website/get_suggested_links
return [];
}
if (this.props.options.isDestroyed?.()) {
return [];
}
const res = await this.rpc("/website/get_suggested_links", {
needle: term,
limit: 15,
});
let choices = res.matching_pages;
res.others.forEach((other) => {
if (other.values.length) {
choices = choices.concat(
[{ separator: other.title, label: other.title }],
other.values
);
}
});
return choices.map(this._mapItemToSuggestion);
},
},
];
}
onSelect(selectedSubjection, { input }) {
const { value } = Object.getPrototypeOf(selectedSubjection);
input.value = value;
this.props.targetDropdown.value = value;
this.props.options.urlChosen?.();
}
onInput({ inputValue }) {
this.props.targetDropdown.value = inputValue;
this.props.options.urlChosen?.();
}
}
UrlAutoComplete.props = {
options: { type: Object },
loadAnchors: { type: Function },
targetDropdown: { type: HTMLElement },
};
@@ -0,0 +1,6 @@
.o-autocomplete {
.ui-autocomplete-category > .dropdown-item:hover {
background-color: initial;
cursor: default;
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="website.UrlAutoComplete">
<AutoCompleteWithPages
onInput.bind="onInput"
onSelect.bind="onSelect"
dropdownClass="dropdownClass"
dropdownOptions="dropdownOptions"
sources="sources"
targetDropdown="props.targetDropdown"/>
</t>
<t t-name="website.AutoCompleteWithPagesItem">
<div t-att-class="{
'fw-bold text-capitalize p-2': option.separator,
}">
<t t-if="option.icon and option.icon.length">
<img t-att-src="option.icon" width="24px" height="24px" class="me-2 rounded"/>
</t>
<t t-out="option.label"/>
</div>
</t>
</templates>
@@ -33,7 +33,6 @@ const useControlledInput = (initialValue, validate) => {
export class MenuDialog extends Component {
setup() {
this.rpc = useService('rpc');
this.website = useService('website');
this.title = _t("Add a menu item");
useAutofocus();
@@ -42,20 +41,23 @@ export class MenuDialog extends Component {
this.url = useControlledInput(this.props.url, value => !!value);
this.urlInputRef = useRef('url-input');
useEffect(() => {
const $input = $(this.urlInputRef.el);
useEffect((input) => {
if (!input) {
return;
}
const options = {
body: this.website.pageDocument.body,
position: "bottom-fit",
classes: {
'ui-autocomplete': 'o_edit_menu_autocomplete'
},
urlChosen: () => {
this.url.input.value = this.urlInputRef.el.value;
this.url.input.value = input.value;
},
};
wUtils.autocompleteWithPages(this.rpc.bind(this), $input, options);
return () => $input.urlcomplete('destroy');
}, () => []);
const unmountAutocompleteWithPages = wUtils.autocompleteWithPages(input, options);
return () => unmountAutocompleteWithPages();
}, () => [this.urlInputRef.el]);
}
onClickOk() {
@@ -14,7 +14,11 @@
border-bottom: $o-we-sidebar-content-field-border-width solid lighten($o-we-sidebar-content-field-dropdown-border-color, 15%);
border-radius: $o-we-sidebar-content-field-border-radius;
background-color: $o-we-sidebar-content-field-clickable-bg;
color: $o-we-sidebar-content-field-clickable-color;
> .dropdown-item,
> .dropdown-item:focus,
> .dropdown-item.ui-state-active {
color: $o-we-sidebar-content-field-clickable-color;
}
&.ui-menu-item {
> div {
white-space: nowrap;
@@ -29,6 +33,9 @@
}
&.ui-autocomplete-category {
background-color: $o-we-bg-lighter;
a:hover > {
color: $o-we-sidebar-content-field-clickable-color;
}
}
}
}
@@ -158,3 +165,12 @@
transition: transform ease 400ms;
}
}
.ui-widget.ui-autocomplete.o_website_ui_autocomplete .ui-menu-item {
> a:hover {
color: $o-we-sidebar-content-field-clickable-color;
}
&.ui-autocomplete-category > a:hover {
cursor: default;
}
}
+16 -14
View File
@@ -2,26 +2,28 @@
import { LinkDialog } from "@web_editor/js/wysiwyg/widgets/link_dialog";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import wUtils from "@website/js/utils";
import { useEffect } from '@odoo/owl';
patch(LinkDialog.prototype, {
setup() {
super.setup(...arguments);
this.rpc = useService("rpc");
},
/**
* Allows the URL input to propose existing website pages.
*
* @override
*/
async start() {
const options = {
body: this.$link && this.$link[0].ownerDocument.body,
urlChosen: () => this.__onURLInput(),
};
const result = await super.start(...arguments);
wUtils.autocompleteWithPages(this.rpc.bind(this), this.$el.find('input[name="url"]'), options);
return result;
},
setup() {
super.setup();
useEffect(($link, container) => {
const input = container?.querySelector(`input[name="url"]`);
if (!input) {
return;
}
const options = {
body: $link && $link[0].ownerDocument.body,
urlChosen: () => this.__onURLInput(),
};
const unmountAutocompleteWithPages = wUtils.autocompleteWithPages(input, options);
return () => unmountAutocompleteWithPages();
}, () => [this.$link, this.linkComponentWrapperRef.el]);
}
});
@@ -104,16 +104,23 @@ const UrlPickerUserValueWidget = InputUserValueWidget.extend({
this.el.classList.add('o_we_large');
this.inputEl.classList.add('text-start');
const options = {
position: {
collision: 'flip flipfit',
},
classes: {
"ui-autocomplete": 'o_website_ui_autocomplete'
},
body: this.getParent().$target[0].ownerDocument.body,
urlChosen: this._onWebsiteURLChosen.bind(this),
};
wUtils.autocompleteWithPages(this.rpc.bind(this), $(this.inputEl), options);
this.unmountAutocompleteWithPages = wUtils.autocompleteWithPages(this.inputEl, options);
},
open() {
this._super(...arguments);
document.querySelector(".o_website_ui_autocomplete")?.classList?.remove("d-none");
},
close() {
this._super(...arguments);
document.querySelector(".o_website_ui_autocomplete")?.classList?.add("d-none");
},
//--------------------------------------------------------------------------
@@ -140,6 +147,11 @@ const UrlPickerUserValueWidget = InputUserValueWidget.extend({
window.open(this._value, '_blank');
}
},
destroy() {
this.unmountAutocompleteWithPages?.();
this.unmountAutocompleteWithPages = null;
this._super(...arguments);
}
});
const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({
@@ -2,19 +2,14 @@
import { LinkTools } from '@web_editor/js/wysiwyg/widgets/link_tools';
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import {status} from '@odoo/owl';
import { status, useEffect } from '@odoo/owl';
import wUtils from "@website/js/utils";
import { debounce } from "@web/core/utils/timing";
const LINK_DEBOUNCE = 1000;
patch(LinkTools.prototype, {
setup() {
this.rpc = useService('rpc');
return super.setup(...arguments);
},
/**
*
* @override
@@ -30,22 +25,29 @@ patch(LinkTools.prototype, {
*/
async start() {
var def = await super.start(...arguments);
const options = {
position: {
collision: 'flip flipfit',
},
classes: {
"ui-autocomplete": 'o_website_ui_autocomplete'
},
body: this.$editable[0].ownerDocument.body,
urlChosen: this._onAutocompleteClose.bind(this),
isDestroyed: () => status(this) === 'destroyed',
};
wUtils.autocompleteWithPages(this.rpc.bind(this), this.$el.find('input[name="url"]'), options);
this._adaptPageAnchor();
return def;
},
setup() {
super.setup();
useEffect((container) => {
const input = container?.querySelector(`input[name="url"]`);
if (!input) {
return;
}
const options = {
classes: {
"ui-autocomplete": 'o_website_ui_autocomplete'
},
body: this.$editable[0].ownerDocument.body,
urlChosen: this._onAutocompleteClose.bind(this),
isDestroyed: () => status(this) === 'destroyed',
};
const unmountAutocompleteWithPages = wUtils.autocompleteWithPages(input, options);
return () => unmountAutocompleteWithPages();
}, () => [this.linkComponentWrapperRef.el]);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
+24 -69
View File
@@ -3,7 +3,9 @@
import { intersection } from "@web/core/utils/arrays";
import { _t } from "@web/core/l10n/translation";
import { renderToElement } from "@web/core/utils/render";
import { App, Component } from "@odoo/owl";
import { templates } from "@web/core/assets";
import { UrlAutoComplete } from "@website/components/autocomplete_with_pages/url_autocomplete";
/**
* Allows to load anchors from a page.
@@ -45,77 +47,30 @@ function loadAnchors(url, body) {
/**
* Allows the given input to propose existing website URLs.
*
* @param {Function} rpc
* @param {jQuery} $input
* @param {HTMLInputElement} input
*/
function autocompleteWithPages(rpc, $input, options) {
$.widget("website.urlcomplete", $.ui.autocomplete, {
options: options || {},
_create: function () {
this._super();
this.widget().menu("option", "items", "> :not(.ui-autocomplete-category)");
},
_renderMenu: function (ul, items) {
const self = this;
items.forEach(item => {
if (item.separator) {
self._renderSeparator(ul, item);
}
else {
self._renderItem(ul, item);
}
});
},
_renderSeparator: function (ul, item) {
return $("<li class='ui-autocomplete-category fw-bold text-capitalize p-2'>")
.append(`<div>${item.separator}</div>`)
.appendTo(ul);
},
_renderItem: function (ul, item) {
return $("<li>")
.data('ui-autocomplete-item', item)
.append(`<div>${item.label}</div>`)
.appendTo(ul);
},
});
$input.urlcomplete({
source: function (request, response) {
if (request.term[0] === '#') {
loadAnchors(request.term, options && options.body).then(function (anchors) {
response(anchors);
});
} else if (request.term.startsWith('http') || request.term.length === 0) {
// avoid useless call to /website/get_suggested_links
response();
} else {
if (options.isDestroyed?.()) {
return;
}
return rpc('/website/get_suggested_links', {
needle: request.term,
limit: 15,
}).then(function (res) {
let choices = res.matching_pages;
res.others.forEach(other => {
if (other.values.length) {
choices = choices.concat(
[{separator: other.title}],
other.values,
);
}
});
response(choices);
});
}
},
select: function (ev, ui) {
// choose url in dropdown with arrow change ev.target.value without trigger_up
// so cannot check here if value has been updated
ev.target.value = ui.item.value;
options.urlChosen?.();
ev.preventDefault();
function autocompleteWithPages(input, options= {}) {
const owlApp = new App(UrlAutoComplete, {
env: Component.env,
dev: Component.env.debug,
templates,
props: {
options,
loadAnchors,
targetDropdown: input,
},
translatableAttributes: ["data-tooltip"],
translateFn: _t,
});
const container = document.createElement("div");
container.classList.add("ui-widget","ui-autocomplete", "ui-widget-content");
document.body.appendChild(container);
owlApp.mount(container)
return () => {
owlApp.destroy();
container.remove();
}
}
/**
@@ -28,6 +28,8 @@ Generate tasks in Project app from a form published on your website. This module
# utils.js in its assets, otherwise, there will be an unmet
# dependency.
'website/static/src/js/utils.js',
'web/static/src/core/autocomplete/*',
'website/static/src/components/autocomplete_with_pages/*',
],
},
'license': 'LGPL-3',