diff --git a/addons/html_builder/static/src/core/building_blocks/basic_many2many.js b/addons/html_builder/static/src/core/building_blocks/basic_many2many.js
new file mode 100644
index 0000000000000..f51d3ba754b69
--- /dev/null
+++ b/addons/html_builder/static/src/core/building_blocks/basic_many2many.js
@@ -0,0 +1,91 @@
+import { Component, useRef, useState, onWillStart, onWillUpdateProps } from "@odoo/owl";
+import { useService, useAutofocus } from "@web/core/utils/hooks";
+import { debounce } from "@web/core/utils/timing";
+import { basicContainerBuilderComponentProps } from "./utils";
+import { DropdownItem } from "@web/core/dropdown/dropdown_item";
+import { Dropdown } from "@web/core/dropdown/dropdown";
+
+export class BasicMany2ManySearchInput extends Component {
+ static template = "html_builder.BasicMany2ManySearchInput";
+ static props = {
+ onSearch: Function,
+ };
+ setup() {
+ useAutofocus();
+ }
+}
+
+export class BasicMany2Many extends Component {
+ static template = "html_builder.BasicMany2Many";
+ static props = {
+ ...basicContainerBuilderComponentProps,
+ model: String,
+ fields: { type: Array, element: String, optional: true },
+ domain: { type: Array, optional: true },
+ limit: { type: Number, optional: true },
+ // createAction: { type: String, optional: true },
+ selection: { type: Array, element: Object },
+ setSelection: Function,
+ canCreate: { type: Boolean, optional: true },
+ };
+ static defaultProps = {
+ fields: [],
+ domain: [],
+ limit: 10,
+ canCreate: false,
+ };
+ static components = { Dropdown, DropdownItem, BasicMany2ManySearchInput };
+
+ setup() {
+ this.orm = useService("orm");
+ this.createInputRef = useRef("createInput");
+ this.state = useState({
+ searchResults: [],
+ });
+ this.onSearch = debounce(this.search.bind(this), 300);
+ onWillStart(async () => {
+ await this.handleProps(this.props);
+ });
+ onWillUpdateProps(async (newProps) => {
+ await this.handleProps(newProps);
+ });
+ }
+ async handleProps(props) {
+ this.state.searchResults = [];
+ }
+ search(ev) {
+ this._search(ev.target.value);
+ }
+ async _search(searchValue) {
+ const tuples = await this.orm.call(this.props.model, "name_search", [], {
+ name: searchValue,
+ args: Object.values(this.props.domain).filter((item) => item !== null),
+ operator: "ilike",
+ limit: this.props.limit + 1,
+ });
+ this.state.searchResults = [];
+ for (const tuple of tuples) {
+ this.state.searchResults.push({
+ id: tuple[0],
+ name: tuple[1],
+ });
+ }
+ /* TODO handle types
+ const records = await this.orm.read(
+ this.props.model,
+ tuples.map(([id, _name]) => id),
+ this.props.fields
+ );
+ */
+ }
+ select(entry) {
+ this.props.setSelection([...this.props.selection, entry]);
+ }
+ unselect(id) {
+ this.props.setSelection([...this.props.selection.filter((item) => item.id !== id)]);
+ }
+ create() {
+ // const name = this.createInputRef.el.value;
+ // TODO implement create ?
+ }
+}
diff --git a/addons/html_builder/static/src/core/building_blocks/basic_many2many.xml b/addons/html_builder/static/src/core/building_blocks/basic_many2many.xml
new file mode 100644
index 0000000000000..e84ccc429c64d
--- /dev/null
+++ b/addons/html_builder/static/src/core/building_blocks/basic_many2many.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_many2many.js b/addons/html_builder/static/src/core/building_blocks/builder_many2many.js
index c3dbc71b868be..a6aa171121093 100644
--- a/addons/html_builder/static/src/core/building_blocks/builder_many2many.js
+++ b/addons/html_builder/static/src/core/building_blocks/builder_many2many.js
@@ -1,6 +1,4 @@
-import { Component, useState } from "@odoo/owl";
-import { useService, useAutofocus } from "@web/core/utils/hooks";
-import { debounce } from "@web/core/utils/timing";
+import { Component } from "@odoo/owl";
import {
basicContainerBuilderComponentProps,
getAllActionsAndOperations,
@@ -8,8 +6,7 @@ import {
useDomState,
} from "./utils";
import { BuilderComponent } from "./builder_component";
-import { DropdownItem } from "@web/core/dropdown/dropdown_item";
-import { Dropdown } from "@web/core/dropdown/dropdown";
+import { BasicMany2Many } from "./basic_many2many";
export class BuilderMany2Many extends Component {
static template = "html_builder.BuilderMany2Many";
@@ -18,20 +15,18 @@ export class BuilderMany2Many extends Component {
model: String,
fields: { type: Array, element: String, optional: true },
domain: { type: Array, optional: true },
- limit: Number,
+ limit: { type: Number, optional: true },
id: { type: String, optional: true },
- // currently always fakem2m
- // currently always allowDelete
};
static defaultProps = {
...BuilderComponent.defaultProps,
fields: [],
domain: [],
+ limit: 10,
};
- static components = { BuilderComponent, Dropdown, DropdownItem };
+ static components = { BuilderComponent, BasicMany2Many };
setup() {
- this.orm = useService("orm");
useBuilderComponent();
const { getAllActions, callOperation } = getAllActionsAndOperations(this);
this.callOperation = callOperation;
@@ -49,66 +44,24 @@ export class BuilderMany2Many extends Component {
editingElement: el,
param: actionParam,
});
- const selection = JSON.parse(actionValue || "[]");
return {
- selection: selection,
+ selection: JSON.parse(actionValue || "[]"),
};
});
- this.state = useState({
- searchResults: [],
- });
- this.onSearch = debounce(this.search.bind(this), 300);
- // TODO focus on open dropdown, does not seem to work
- useAutofocus();
}
callApply(applySpecs) {
for (const applySpec of applySpecs) {
applySpec.apply({
editingElement: applySpec.editingElement,
param: applySpec.actionParam,
- value: JSON.stringify(this.selectionToApply),
+ value: this.selectionToApply,
loadResult: applySpec.loadResult,
dependencyManager: this.env.dependencyManager,
});
}
}
- unselect(id) {
- this.selectionToApply = [...this.domState.selection.filter((item) => item.id !== id)];
- this.callOperation(this.applyOperation.commit);
- }
- search(ev) {
- this._search(ev.target.value);
- }
- async _getSearchDomain() {
- // TODO
- return [];
- }
- async _search(needle) {
- const recTuples = await this.orm.call(this.props.model, "name_search", [], {
- name: needle,
- args: (
- await this._getSearchDomain()
- ).concat(Object.values(this.props.domain).filter((item) => item !== null)),
- operator: "ilike",
- limit: this.props.limit + 1,
- });
- this.state.searchResults.length = 0;
- for (const tuple of recTuples) {
- this.state.searchResults.push({
- id: tuple[0],
- name: tuple[1],
- });
- }
- /* TODO handle types
- const records = await this.orm.read(
- this.props.model,
- recTuples.map(([id, _name]) => id),
- this.props.fields
- );
- */
- }
- select(entry) {
- this.selectionToApply = [...this.domState.selection, entry];
+ setSelection(newSelection) {
+ this.selectionToApply = JSON.stringify(newSelection);
this.callOperation(this.applyOperation.commit);
}
}
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_many2many.xml b/addons/html_builder/static/src/core/building_blocks/builder_many2many.xml
index 2e77269fa0bdf..2b496d66db8f8 100644
--- a/addons/html_builder/static/src/core/building_blocks/builder_many2many.xml
+++ b/addons/html_builder/static/src/core/building_blocks/builder_many2many.xml
@@ -3,88 +3,15 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/addons/html_builder/static/src/core/building_blocks/model_many2many.js b/addons/html_builder/static/src/core/building_blocks/model_many2many.js
new file mode 100644
index 0000000000000..2faa9cdd5193e
--- /dev/null
+++ b/addons/html_builder/static/src/core/building_blocks/model_many2many.js
@@ -0,0 +1,72 @@
+import { Component, useState, onWillStart, onWillUpdateProps } from "@odoo/owl";
+import { useService } from "@web/core/utils/hooks";
+import { BuilderComponent } from "./builder_component";
+import { BasicMany2Many } from "./basic_many2many";
+
+export class ModelMany2Many extends Component {
+ static template = "html_builder.ModelMany2Many";
+ static props = {
+ //...basicContainerBuilderComponentProps,
+ baseModel: String,
+ recordId: Number,
+ m2oField: String,
+ fields: { type: Array, element: String, optional: true },
+ domain: { type: Array, optional: true },
+ limit: { type: Number, optional: true },
+ useModelEditState: Function,
+ createAction: { type: String, optional: true },
+ id: { type: String, optional: true },
+ // currently always allowDelete
+ };
+ static defaultProps = {
+ fields: [],
+ domain: [],
+ limit: 10,
+ };
+ static components = { BuilderComponent, BasicMany2Many };
+
+ setup() {
+ this.orm = useService("orm");
+ this.fields = useService("field");
+ // useBuilderComponent();
+ this.state = useState({
+ selection: undefined,
+ searchModel: undefined,
+ });
+ onWillStart(async () => {
+ await this.handleProps(this.props);
+ });
+ onWillUpdateProps(async (newProps) => {
+ await this.handleProps(newProps);
+ });
+ }
+ async handleProps(props) {
+ const [record] = await this.orm.read(props.baseModel, [props.recordId], [props.m2oField]);
+ const selectedRecordIds = record[props.m2oField];
+ // TODO: handle no record
+ const modelData = await this.fields.loadFields(props.baseModel, {
+ fieldNames: [props.m2oField],
+ });
+ // TODO: simultaneously fly both RPCs
+ this.state.searchModel = modelData[props.m2oField].relation;
+ const temporary = this.props.useModelEditState({
+ model: this.state.searchModel,
+ recordId: props.recordId,
+ });
+ if (temporary.selection === undefined) {
+ const storedSelection = await this.orm.read(this.state.searchModel, selectedRecordIds, [
+ "display_name",
+ ]);
+ temporary.selection = [...storedSelection];
+ for (const item of temporary.selection) {
+ item.name = item.display_name;
+ }
+ }
+ this.state.selection = temporary.selection;
+ }
+ setSelection(newSelection) {
+ this.state.selection.length = 0;
+ this.state.selection.push(...newSelection);
+ // TODO participate in history
+ }
+}
diff --git a/addons/html_builder/static/src/core/building_blocks/model_many2many.xml b/addons/html_builder/static/src/core/building_blocks/model_many2many.xml
new file mode 100644
index 0000000000000..17c72b6836890
--- /dev/null
+++ b/addons/html_builder/static/src/core/building_blocks/model_many2many.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/html_builder/static/src/core/default_builder_components.js b/addons/html_builder/static/src/core/default_builder_components.js
index fbb4b89782d12..702f7b7084fd1 100644
--- a/addons/html_builder/static/src/core/default_builder_components.js
+++ b/addons/html_builder/static/src/core/default_builder_components.js
@@ -11,7 +11,9 @@ import { BuilderTextInput } from "./building_blocks/builder_text_input";
import { BuilderCheckbox } from "./building_blocks/builder_checkbox";
import { BuilderRange } from "./building_blocks/builder_range";
import { BuilderContext } from "./building_blocks/builder_context";
+import { BasicMany2Many } from "./building_blocks/basic_many2many";
import { BuilderMany2Many } from "./building_blocks/builder_many2many";
+import { ModelMany2Many } from "./building_blocks/model_many2many";
export const defaultBuilderComponents = {
BuilderContext,
@@ -27,5 +29,7 @@ export const defaultBuilderComponents = {
BuilderSelect,
BuilderSelectItem,
BuilderCheckbox,
+ BasicMany2Many,
BuilderMany2Many,
+ ModelMany2Many,
};
diff --git a/addons/html_builder/static/tests/custom_tab/builder_components/basic_many2many.test.js b/addons/html_builder/static/tests/custom_tab/builder_components/basic_many2many.test.js
new file mode 100644
index 0000000000000..967d1cc9cdce2
--- /dev/null
+++ b/addons/html_builder/static/tests/custom_tab/builder_components/basic_many2many.test.js
@@ -0,0 +1,72 @@
+import { expect, test } from "@odoo/hoot";
+import { animationFrame } from "@odoo/hoot-mock";
+import { Component, reactive, xml } from "@odoo/owl";
+import { delay } from "@web/core/utils/concurrency";
+import { contains, onRpc } from "@web/../tests/web_test_helpers";
+import { addOption, defineWebsiteModels, setupWebsiteBuilder } from "../../helpers";
+import { defaultBuilderComponents } from "@html_builder/core/default_builder_components";
+
+defineWebsiteModels();
+
+test("basic many2many: find tag, select tag, unselect tag", async () => {
+ onRpc("/web/dataset/call_kw/test/name_search", async (args) => [
+ [1, "First"],
+ [2, "Second"],
+ [3, "Third"],
+ ]);
+ class TestComponent extends Component {
+ static template = xml``;
+ static props = {
+ selection: Array,
+ setSelection: Function,
+ };
+ static components = { ...defaultBuilderComponents };
+ }
+ const selection = reactive([]);
+ addOption({
+ selector: ".test-options-target",
+ Component: TestComponent,
+ props: {
+ selection: selection,
+ setSelection(newSelection) {
+ selection.length = 0;
+ for (const item of newSelection) {
+ selection.push(item);
+ }
+ },
+ },
+ });
+ await setupWebsiteBuilder(`
b
`);
+
+ await contains(":iframe .test-options-target").click();
+ expect(".options-container").toBeDisplayed();
+ expect("table tr").toHaveCount(0);
+ expect(selection).toEqual([]);
+
+ await contains(".btn.o-dropdown").click();
+ expect("input").toHaveCount(1);
+ await contains("input").click();
+ await delay(300); // debounce
+ await animationFrame();
+ expect("span.o-dropdown-item").toHaveCount(3);
+ await contains("span.o-dropdown-item").click();
+ expect(selection).toEqual([{ id: 1, name: "First" }]);
+ expect("table tr").toHaveCount(1);
+
+ await contains(".btn.o-dropdown").click();
+ await contains("input[placeholder]").click();
+ await delay(300); // debounce
+ await animationFrame();
+ expect("span.o-dropdown-item").toHaveCount(2);
+ await contains("span.o-dropdown-item").click();
+ expect(selection).toEqual([
+ { id: 1, name: "First" },
+ { id: 2, name: "Second" },
+ ]);
+ expect("table tr").toHaveCount(2);
+
+ await contains("button.fa-minus").click();
+ expect(selection).toEqual([{ id: 2, name: "Second" }]);
+ expect("table tr").toHaveCount(1);
+ expect("table input").toHaveValue("Second");
+});
diff --git a/addons/html_builder/static/tests/custom_tab/builder_components/builder_many2many.test.js b/addons/html_builder/static/tests/custom_tab/builder_components/builder_many2many.test.js
index 2220a2273e04a..650336fc28fe2 100644
--- a/addons/html_builder/static/tests/custom_tab/builder_components/builder_many2many.test.js
+++ b/addons/html_builder/static/tests/custom_tab/builder_components/builder_many2many.test.js
@@ -40,6 +40,9 @@ test("many2many: find tag, select tag, unselect tag", async () => {
expect("table tr").toHaveCount(1);
await contains(".btn.o-dropdown").click();
+ await contains("input[placeholder]").click();
+ await delay(300); // debounce
+ await animationFrame();
expect("span.o-dropdown-item").toHaveCount(2);
await contains("span.o-dropdown-item").click();
expect(editableContent).toHaveInnerHTML(
diff --git a/addons/html_builder/static/tests/custom_tab/builder_components/model_many2many.test.js b/addons/html_builder/static/tests/custom_tab/builder_components/model_many2many.test.js
new file mode 100644
index 0000000000000..788511b61509f
--- /dev/null
+++ b/addons/html_builder/static/tests/custom_tab/builder_components/model_many2many.test.js
@@ -0,0 +1,91 @@
+import { expect, test } from "@odoo/hoot";
+import { animationFrame } from "@odoo/hoot-mock";
+import { Component, reactive, xml } from "@odoo/owl";
+import { delay } from "@web/core/utils/concurrency";
+import { contains, defineModels, fields, models, onRpc } from "@web/../tests/web_test_helpers";
+import { addOption, defineWebsiteModels, setupWebsiteBuilder } from "../../helpers";
+import { defaultBuilderComponents } from "@html_builder/core/default_builder_components";
+
+class Test extends models.Model {
+ _name = "test";
+}
+class TestBase extends models.Model {
+ _name = "test.base";
+ _records = [
+ {
+ id: 1,
+ rel: [],
+ },
+ ];
+ rel = fields.Many2many({
+ relation: "test",
+ string: "Test",
+ });
+}
+
+defineWebsiteModels();
+defineModels([Test, TestBase]);
+
+test("model many2many: find tag, select tag, unselect tag", async () => {
+ onRpc("/web/dataset/call_kw/test/name_search", async (args) => [
+ [1, "First"],
+ [2, "Second"],
+ [3, "Third"],
+ ]);
+ class TestComponent extends Component {
+ static template = xml``;
+ static props = {
+ useModelEditState: Function,
+ };
+ static components = { ...defaultBuilderComponents };
+ }
+ const temporary = reactive({
+ selection: undefined,
+ });
+ const useModelEditState = ({ model, recordId }) => temporary;
+ addOption({
+ selector: ".test-options-target",
+ Component: TestComponent,
+ props: {
+ useModelEditState: useModelEditState,
+ },
+ });
+ await setupWebsiteBuilder(`b
`);
+
+ await contains(":iframe .test-options-target").click();
+ expect(".options-container").toBeDisplayed();
+ expect("table tr").toHaveCount(0);
+ expect(temporary.selection).toEqual([]);
+
+ await contains(".btn.o-dropdown").click();
+ expect("input").toHaveCount(1);
+ await contains("input").click();
+ await delay(300); // debounce
+ await animationFrame();
+ expect("span.o-dropdown-item").toHaveCount(3);
+ await contains("span.o-dropdown-item").click();
+ expect(temporary.selection).toEqual([{ id: 1, name: "First" }]);
+ expect("table tr").toHaveCount(1);
+
+ await contains(".btn.o-dropdown").click();
+ await contains("input[placeholder]").click();
+ await delay(300); // debounce
+ await animationFrame();
+ expect("span.o-dropdown-item").toHaveCount(2);
+ await contains("span.o-dropdown-item").click();
+ expect(temporary.selection).toEqual([
+ { id: 1, name: "First" },
+ { id: 2, name: "Second" },
+ ]);
+ expect("table tr").toHaveCount(2);
+
+ await contains("button.fa-minus").click();
+ expect(temporary.selection).toEqual([{ id: 2, name: "Second" }]);
+ expect("table tr").toHaveCount(1);
+ expect("table input").toHaveValue("Second");
+
+ await contains(".o-snippets-tabs button").click();
+ await contains(".o-snippets-tabs button:nth-child(2)").click();
+ expect("table tr").toHaveCount(1);
+ expect("table input").toHaveValue("Second");
+});
diff --git a/addons/website_blog/static/src/plugins/blog_post_tags_option.js b/addons/website_blog/static/src/plugins/blog_post_tags_option.js
new file mode 100644
index 0000000000000..fd7f20f53b276
--- /dev/null
+++ b/addons/website_blog/static/src/plugins/blog_post_tags_option.js
@@ -0,0 +1,51 @@
+import { Plugin } from "@html_editor/plugin";
+import { Component, onWillStart, useState } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { Cache } from "@web/core/utils/cache";
+import { defaultBuilderComponents } from "@html_builder/core/default_builder_components";
+import { useDomState } from "@html_builder/core/building_blocks/utils";
+
+class BlogPostTagsOptionPlugin extends Plugin {
+ static id = "BlogPostTagsOption";
+ resources = {
+ builder_options: {
+ selector: ".o_wblog_post_page_cover[data-res-model='blog.post']",
+ OptionComponent: BlogPostTagsOption,
+ props: {
+ useModelEditState: this.useModelEditState.bind(this),
+ },
+ cleanForSave: () => {
+ // keep track of temporary edited value
+ // clean up temporary edited value
+ },
+ },
+ };
+ setup() {
+ this.temporaryCache = new Cache(() => ({ selection: undefined }), JSON.stringify);
+ }
+ destroy() {
+ this.temporaryCache.invalidate();
+ }
+ useModelEditState({ model, recordId }) {
+ return this.temporaryCache.read({ model, recordId });
+ }
+}
+
+registry
+ .category("website-plugins")
+ .add(BlogPostTagsOptionPlugin.id, BlogPostTagsOptionPlugin);
+
+export class BlogPostTagsOption extends Component {
+ static template = "website_blog.BlogPostTagsOption";
+ static components = { ...defaultBuilderComponents };
+ static props = {
+ useModelEditState: Function,
+ };
+ setup() {
+ this.domState = useDomState((el) => {
+ return {
+ blogId: parseInt(el.dataset.resId),
+ };
+ });
+ }
+}
diff --git a/addons/website_blog/static/src/plugins/blog_post_tags_option.xml b/addons/website_blog/static/src/plugins/blog_post_tags_option.xml
new file mode 100644
index 0000000000000..1b8600f613186
--- /dev/null
+++ b/addons/website_blog/static/src/plugins/blog_post_tags_option.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+