[REF] web: Layout: simpler and more generic API

With owl2, the `el` of components if no longer available. One must
use a t-ref instead. However, higher order components don't have
their own html element. This was the case of the View component.

However, it makes sense for this component to be the owner of the
view root element, which was previously owned by the Layout.

This commit refactors the View/Layout architecture s.t. the root
element is now owned by the View, and simplifies/generalizes the
Layout in the process. The Layout is no longer tied to the notion
of views, and can thus be used in client actions as well.

closes odoo/odoo#85639

Related: odoo/enterprise#24903
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Michael (mcm) <mcm@odoo.com>
Co-authored-by: Samuel Degueldre <sad@odoo.com>
This commit is contained in:
Aaron Bohy
2022-03-03 09:10:50 +00:00
co-authored by Michael Samuel Degueldre
parent 3a97a750db
commit 3f086af2fd
15 changed files with 61 additions and 95 deletions
@@ -22,16 +22,6 @@ export class Layout extends LegacyComponent {
this.components = extractLayoutComponents(this.env.config);
this.display = display;
}
get className() {
const classes = this.props.className.split(" ");
if (this.props.useSampleModel) {
classes.push("o_view_sample_data");
}
if (this.props.viewType) {
classes.push(`o_${this.props.viewType}_view`);
}
return classes.join(" ");
}
get controlPanelSlots() {
const slots = { ...this.props.slots };
delete slots.default;
@@ -40,10 +30,7 @@ export class Layout extends LegacyComponent {
}
Layout.template = "web.Layout";
Layout.defaultProps = { className: "" };
Layout.props = {
className: { type: String, optional: true },
slots: { type: Object, optional: true },
viewType: { type: String, optional: true },
useSampleModel: { type: Boolean, optional: true },
};
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Layout" owl="1">
<t t-component="components.ControlPanel" slots="controlPanelSlots" t-if="display.controlPanel"/>
<div class="o_content" t-attf-class="{{props.className}}" t-att-class="{ o_component_with_search_panel: display.searchPanel }">
<t t-component="components.Banner" t-if="components.Banner and display.banner"/>
<t t-component="components.SearchPanel" t-if="display.searchPanel"/>
<t t-slot="default"/>
</div>
</t>
</templates>
@@ -106,7 +106,5 @@ WithSearch.props = {
activateFavorite: { type: Boolean, optional: true },
dynamicFilters: { type: Array, element: Object, optional: true },
searchMenuTypes: { type: Array, element: String, optional: true },
class: { type: String, optional: true },
};
WithSearch.template = "web.WithSearch";
@@ -5,10 +5,10 @@ import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
import { GroupByMenu } from "@web/search/group_by_menu/group_by_menu";
import { standardViewProps } from "@web/views/helpers/standard_view_props";
import { useSetupView } from "@web/views/helpers/view_hook";
import { Layout } from "@web/views/layout";
import { useModel } from "../helpers/model";
import { GraphArchParser } from "./graph_arch_parser";
import { GraphModel } from "./graph_model";
@@ -46,7 +46,7 @@
</t>
<t t-name="web.GraphView" owl="1">
<Layout viewType="'graph'" useSampleModel="model.useSampleModel" className="props.className">
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
</t>
@@ -19,7 +19,6 @@ export const standardViewProps = {
},
resModel: String,
arch: { type: String },
className: { type: String, optional: 1 },
comparison: { validate: () => true }, // fix problem with validation with type: [Object, null]
// Issue OWL: https://github.com/odoo/owl/issues/910
context: { type: Object },
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Layout" owl="1">
<div t-att-class="className">
<t t-component="components.ControlPanel" slots="controlPanelSlots" t-if="display.controlPanel">
<!-- Empty body to assign slot id to control panel -->
</t>
<div class="o_content" t-att-class="{ o_component_with_search_panel: display.searchPanel }">
<t t-component="components.Banner" t-if="components.Banner and display.banner" />
<t t-component="components.SearchPanel" t-if="display.searchPanel" />
<t t-slot="default" />
</div>
</div>
</t>
</templates>
@@ -6,10 +6,10 @@ import { _lt } from "@web/core/l10n/translation";
import { download } from "@web/core/network/download";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
import { useModel } from "@web/views/helpers/model";
import { standardViewProps } from "@web/views/helpers/standard_view_props";
import { useSetupView } from "@web/views/helpers/view_hook";
import { Layout } from "@web/views/layout";
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
import { PivotModel } from "@web/views/pivot/pivot_model";
import { PivotRenderer } from "@web/views/pivot/pivot_renderer";
@@ -17,7 +17,7 @@
</t>
<t t-name="web.PivotView" owl="1">
<Layout viewType="'pivot'" useSampleModel="model.useSampleModel" className="props.className">
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
</t>
+1 -2
View File
@@ -5,9 +5,9 @@ import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { useService } from "@web/core/utils/hooks";
import { deepCopy } from "@web/core/utils/objects";
import { extractLayoutComponents } from "@web/search/layout";
import { WithSearch } from "@web/search/with_search/with_search";
import { useActionLinks } from "@web/views/helpers/view_hook";
import { extractLayoutComponents } from "@web/views/layout";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { onWillUpdateProps, onWillStart, toRaw, useSubEnv } = owl;
@@ -255,7 +255,6 @@ export class View extends LegacyComponent {
const viewProps = {
info: { actionMenus, mode: this.props.display.mode },
arch,
className: "o_action o_view_controller",
fields,
resModel,
useSampleModel: false,
+3 -1
View File
@@ -2,7 +2,9 @@
<templates xml:space="preserve">
<t t-name="web.View" owl="1">
<WithSearch t-props="withSearchProps"/>
<div t-attf-class="o_action o_view_controller o_{{env.config.viewType}}_view">
<WithSearch t-props="withSearchProps"/>
</div>
</t>
<t t-name="web.ReportViewMeasures" owl="1">
@@ -3244,14 +3244,17 @@ QUnit.module("Views", (hooks) => {
noContentHelp: '<p class="abc">click to add a foo</p>',
});
assert.hasClass(target.querySelector(".o_graph_view"), "o_view_sample_data");
assert.hasClass(target.querySelector(".o_graph_view .o_content"), "o_view_sample_data");
assert.containsOnce(target, ".o_view_nocontent");
assert.containsOnce(target, ".o_graph_canvas_container canvas");
assert.hasClass(target.querySelector(".o_graph_renderer"), "o_sample_data_disabled");
await toggleFilterMenu(target);
await toggleMenuItem(target, "False Domain");
assert.doesNotHaveClass(target, "o_view_sample_data");
assert.doesNotHaveClass(
target.querySelector(".o_graph_view .o_content"),
"o_view_sample_data"
);
assert.containsNone(target, ".o_view_nocontent");
assert.containsOnce(target, ".o_graph_canvas_container canvas");
assert.doesNotHaveClass(
+8 -28
View File
@@ -4,7 +4,7 @@ import { getFixture, mount } from "@web/../tests/helpers/utils";
import { makeWithSearch, setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
import { dialogService } from "@web/core/dialog/dialog_service";
import { registry } from "@web/core/registry";
import { Layout } from "@web/views/layout";
import { Layout } from "@web/search/layout";
import { getDefaultConfig } from "@web/views/view";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
@@ -51,11 +51,9 @@ QUnit.module("Views", (hooks) => {
QUnit.module("Layout");
QUnit.test("Simple rendering", async (assert) => {
assert.expect(5);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout viewType="'toy'" useSampleModel="true">
<Layout className="'o_view_sample_data'">
<div class="toy_content" />
</Layout>`;
ToyComponent.components = { Layout };
@@ -63,7 +61,7 @@ QUnit.module("Views", (hooks) => {
const env = await makeTestEnv({ config: {} });
await mount(ToyComponent, getFixture(), { env });
assert.containsOnce(target, ".o_toy_view.o_view_sample_data");
assert.containsOnce(target, ".o_view_sample_data");
assert.containsNone(target, ".o_control_panel");
assert.containsNone(target, ".o_component_with_search_panel");
assert.containsNone(target, ".o_search_panel");
@@ -71,11 +69,9 @@ QUnit.module("Views", (hooks) => {
});
QUnit.test("Simple rendering: with search", async (assert) => {
assert.expect(6);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout viewType="'toy'">
<Layout>
<t t-set-slot="control-panel-top-right">
<div class="toy_search_bar" />
</t>
@@ -90,8 +86,6 @@ QUnit.module("Views", (hooks) => {
searchViewId: false,
});
assert.containsOnce(target, ".o_toy_view");
assert.containsNone(target, ".o_view_sample_data");
assert.containsOnce(target, ".o_control_panel .o_cp_top_right .toy_search_bar");
assert.containsOnce(target, ".o_component_with_search_panel .o_search_panel");
assert.containsNone(target, ".o_cp_searchview");
@@ -99,10 +93,7 @@ QUnit.module("Views", (hooks) => {
});
QUnit.test("Nested layouts", async (assert) => {
assert.expect(10);
// Component C: bottom (no control panel)
class ToyC extends Component {
setup() {
useChildSubEnv({
@@ -116,13 +107,12 @@ QUnit.module("Views", (hooks) => {
}
}
ToyC.template = xml`
<Layout viewType="'toy_c'">
<Layout className="'toy_c'">
<div class="toy_c_content" />
</Layout>`;
ToyC.components = { Layout };
// Component B: center (with custom search panel)
class SearchPanel extends Component {}
SearchPanel.template = xml`<div class="o_toy_search_panel" />`;
@@ -137,7 +127,7 @@ QUnit.module("Views", (hooks) => {
}
}
ToyB.template = xml`
<Layout viewType="'toy_b'">
<Layout className="'toy_b'">
<t t-set-slot="control-panel-top-right">
<div class="toy_b_breadcrumbs" />
</t>
@@ -149,7 +139,7 @@ QUnit.module("Views", (hooks) => {
class ToyA extends Component {}
ToyA.template = xml`
<Layout viewType="'toy_a'">
<Layout className="'toy_a'">
<t t-set-slot="control-panel-top-right">
<div class="toy_a_search" />
</t>
@@ -164,9 +154,7 @@ QUnit.module("Views", (hooks) => {
searchViewId: false,
});
assert.containsOnce(target, ".o_toy_a_view");
assert.containsNone(target, ".o_view_sample_data");
assert.containsOnce(target, ".o_content .o_toy_b_view .o_content .o_toy_c_view .o_content"); // Full chain of contents
assert.containsOnce(target, ".o_content.toy_a .o_content.toy_b .o_content.toy_c"); // Full chain of contents
assert.containsN(target, ".o_control_panel", 2); // Component C has hidden its control panel
assert.containsN(target, ".o_content.o_component_with_search_panel", 3);
assert.containsOnce(target, ".o_search_panel"); // Standard search panel
@@ -177,8 +165,6 @@ QUnit.module("Views", (hooks) => {
});
QUnit.test("Custom control panel", async (assert) => {
assert.expect(3);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
@@ -203,8 +189,6 @@ QUnit.module("Views", (hooks) => {
});
QUnit.test("Custom search panel", async (assert) => {
assert.expect(3);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
@@ -229,8 +213,6 @@ QUnit.module("Views", (hooks) => {
});
QUnit.test("Custom banner: no bannerRoute in env", async (assert) => {
assert.expect(2);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
@@ -254,8 +236,6 @@ QUnit.module("Views", (hooks) => {
});
QUnit.test("Custom banner: with bannerRoute in env", async (assert) => {
assert.expect(2);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
@@ -4702,13 +4702,16 @@ QUnit.module("Views", (hooks) => {
},
});
assert.hasClass(target.querySelector(".o_pivot_view"), "o_view_sample_data");
assert.hasClass(target.querySelector(".o_pivot_view .o_content"), "o_view_sample_data");
assert.containsOnce(target, ".o_view_nocontent .abc");
assert.containsOnce(target, "table.o_sample_data_disabled");
await removeFacet(target);
assert.doesNotHaveClass(target, "o_view_sample_data");
assert.doesNotHaveClass(
target.querySelector(".o_pivot_view .o_content"),
"o_view_sample_data"
);
assert.containsNone(target, ".o_view_nocontent .abc");
assert.containsOnce(target, "table");
assert.doesNotHaveClass(target.querySelector("table"), "o_sample_data_disabled");
+22 -23
View File
@@ -74,7 +74,7 @@ QUnit.module("Views", (hooks) => {
class ToyView extends Component {
setup() {
this.class = "o_toy_view";
this.class = "toy";
this.template = xml`${this.props.arch}`;
}
}
@@ -85,7 +85,7 @@ QUnit.module("Views", (hooks) => {
class ToyViewImp extends ToyView {
setup() {
super.setup();
this.class = "o_toy_view_imp";
this.class = "toy_imp";
}
}
@@ -114,15 +114,14 @@ QUnit.module("Views", (hooks) => {
////////////////////////////////////////////////////////////////////////////
QUnit.test("simple rendering", async function (assert) {
assert.expect(11);
assert.expect(10);
const ToyView = viewRegistry.get("toy");
patchWithCleanup(ToyView.prototype, {
setup() {
this._super();
const { arch, fields, info, className } = this.props;
const { arch, fields, info } = this.props;
assert.strictEqual(arch, serverData.views["animal,false,toy"]);
assert.strictEqual(className, "o_action o_view_controller");
assert.deepEqual(fields, serverData.models.animal.fields);
assert.strictEqual(info.actionMenus, undefined);
assert.strictEqual(this.env.config.viewId, false);
@@ -144,9 +143,9 @@ QUnit.module("Views", (hooks) => {
resModel: "animal",
type: "toy",
});
assert.containsOnce(target, ".o_toy_view");
assert.containsOnce(target, ".o_toy_view.o_action.o_view_controller");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
serverData.views["animal,false,toy"]
);
});
@@ -182,7 +181,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
serverData.views["animal,1,toy"]
);
});
@@ -221,7 +220,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
serverData.views["animal,1,toy"]
);
});
@@ -264,7 +263,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
serverData.views["animal,false,toy"]
);
}
@@ -310,7 +309,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
serverData.views["animal,1,toy"]
);
});
@@ -342,7 +341,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
`<toy>Specific arch content</toy>`
);
});
@@ -379,7 +378,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
serverData.views["animal,false,toy"]
);
});
@@ -420,7 +419,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
`<toy>Specific arch content</toy>`
);
}
@@ -459,7 +458,7 @@ QUnit.module("Views", (hooks) => {
});
assert.containsOnce(target, ".o_toy_view");
assert.strictEqual(
target.querySelector(".o_toy_view").innerHTML,
target.querySelector(".o_toy_view .toy").innerHTML,
`<toy>Specific arch content</toy>`
);
}
@@ -1205,9 +1204,9 @@ QUnit.module("Views", (hooks) => {
resModel: "animal",
type: "toy_imp",
});
assert.containsOnce(target, ".o_toy_view_imp");
assert.containsOnce(target, ".o_toy_view .toy_imp");
assert.strictEqual(
target.querySelector(".o_toy_view_imp").innerText,
target.querySelector(".o_toy_view .toy_imp").innerText,
"Arch content (id=false)"
);
});
@@ -1228,9 +1227,9 @@ QUnit.module("Views", (hooks) => {
type: "toy",
viewId: 2,
});
assert.containsOnce(target, ".o_toy_view_imp");
assert.containsOnce(target, ".o_toy_view .toy_imp");
assert.strictEqual(
target.querySelector(".o_toy_view_imp").innerText,
target.querySelector(".o_toy_view .toy_imp").innerText,
"Arch content (id=2)"
);
});
@@ -1247,9 +1246,9 @@ QUnit.module("Views", (hooks) => {
arch: `<toy js_class="toy_imp">Specific arch content for specific class</toy>`,
fields: {},
});
assert.containsOnce(target, ".o_toy_view_imp");
assert.containsOnce(target, ".o_toy_view .toy_imp");
assert.strictEqual(
target.querySelector(".o_toy_view_imp").innerText,
target.querySelector(".o_toy_view .toy_imp").innerText,
"Specific arch content for specific class"
);
});
@@ -1278,7 +1277,7 @@ QUnit.module("Views", (hooks) => {
type: "toy_2",
viewId: 2,
});
assert.containsOnce(target, ".o_toy_view_imp", "jsClass from arch prefered");
assert.containsOnce(target, ".o_toy_view .toy_imp", "jsClass from arch prefered");
}
);
@@ -1302,7 +1301,7 @@ QUnit.module("Views", (hooks) => {
arch: `<toy js_class="toy_imp"/>`,
fields: {},
});
assert.containsOnce(target, ".o_toy_view_imp", "jsClass from arch prefered");
assert.containsOnce(target, ".o_toy_view .toy_imp", "jsClass from arch prefered");
}
);