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 @@ + + + + + + + + + + +