[FW][MERGE] web,*: introduce Layout component

This PR introduces a new component: the layout component.
Its purpose is to make abstraction of most generic components and
classes when writing a view template or an action.

To make use of this component, we also needed a new system to pass slot
from the parent of the layout to the control panel. This was done in a
hacky way and is meant to be replaced as soon as Owl provides a built-in
way to pass slots.

The control panel has also been slightly tweaked to default its props
from an environment's newly introduced key: the 'config'. This has been
done to reduce the amount of props passed to the layout and search
subcomponents.

closes odoo/odoo#77463

Forward-port-of: odoo/odoo#76140
Related: odoo/enterprise#21295
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Odoo's Mergebot
2021-09-30 12:38:32 +02:00
committed by GitHub
43 changed files with 2077 additions and 1613 deletions
@@ -36,10 +36,10 @@ ImportRecords.template = "base_import.ImportRecords";
const importRecordsItem = {
Component: ImportRecords,
groupNumber: 4,
isDisplayed: ({ isSmall, searchModel }) =>
isDisplayed: ({ config, isSmall }) =>
!isSmall &&
searchModel.action.type === "ir.actions.act_window" &&
["kanban", "list"].includes(searchModel.view.type)
config.actionType === "ir.actions.act_window" &&
["kanban", "list"].includes(config.viewType)
// TODO: add arch info to searchModel?
// !!JSON.parse(env.view.arch.attrs.import || "1") &&
// !!JSON.parse(env.view.arch.attrs.create || "1"),
@@ -24,7 +24,7 @@ export class AddToBoard extends Component {
setup() {
this.notification = useService("notification");
this.rpc = useService("rpc");
this.state = useState({ name: this.env.searchModel.displayName });
this.state = useState({ name: this.env.config.displayName });
useAutofocus();
}
@@ -34,39 +34,19 @@ export class AddToBoard extends Component {
//---------------------------------------------------------------------
async addToBoard() {
const {
action,
displayName,
domain,
context,
groupBy,
orderedBy,
view,
} = this.env.searchModel;
// Retrieves view context
const fns = this.env.__getContext__.callbacks;
const viewContext = Object.assign({}, ...fns.map((fn) => fn()));
const { context, domain } = this.env.searchModel.getIrFilterValues();
const contextToSave = {
...context,
group_by: groupBy,
orderedBy,
orderedBy: this.env.searchModel.orderBy,
dashboard_merge_domains_contexts: false,
...viewContext,
};
const detailedComparison = this.env.searchModel.getFullComparison();
if (detailedComparison) {
contextToSave.comparison = detailedComparison;
}
const result = await this.rpc("/board/add_to_dashboard", {
action_id: action.id,
action_id: this.env.config.actionId,
context_to_save: contextToSave,
domain,
name: this.state.name,
view_mode: view.type,
view_mode: this.env.config.viewType,
});
if (result) {
@@ -77,12 +57,11 @@ export class AddToBoard extends Component {
type: "warning",
}
);
this.state.name = displayName;
this.state.name = this.env.config.displayName;
} else {
this.notification.add(
this.env._t("Could not add filter to dashboard"),
{ type: "danger" }
);
this.notification.add(this.env._t("Could not add filter to dashboard"), {
type: "danger",
});
}
}
@@ -106,7 +85,7 @@ AddToBoard.template = "board.AddToBoard";
const addToBoardItem = {
Component: AddToBoard,
groupNumber: 4,
isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window",
isDisplayed: ({ config }) => config.actionType === "ir.actions.act_window",
};
favoriteMenuRegistry.add("add-to-board", addToBoardItem, { sequence: 10 });
File diff suppressed because it is too large Load Diff
+12 -13
View File
@@ -1,24 +1,23 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import AbstractView from "web.AbstractView";
import AbstractModel from "web.AbstractModel";
import { controlPanel as cpHelpers } from "web.test_utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { dialogService } from "@web/core/dialog/dialog_service";
import { legacyExtraNextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import legacyViewRegistry from "web.view_registry";
import { makeView } from "@web/../tests/views/helpers";
import { mock } from "web.test_utils";
import { registry } from "@web/core/registry";
import {
setupControlPanelServiceRegistry,
switchView,
toggleFilterMenu,
toggleGroupByMenu,
toggleMenuItem,
toggleMenuItemOption,
toggleMenuItemOption
} from "@web/../tests/search/helpers";
import { makeView } from "@web/../tests/views/helpers";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { dialogService } from "@web/core/dialog/dialog_service";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import AbstractModel from "web.AbstractModel";
import AbstractView from "web.AbstractView";
import { controlPanel as cpHelpers, mock } from "web.test_utils";
import legacyViewRegistry from "web.view_registry";
const patchDate = mock.patchDate;
@@ -88,7 +87,7 @@ QUnit.module("Views", (hooks) => {
const forecastGraph = await makeView({
resModel: "foo",
type: "forecast_graph",
type: "graph",
serverData,
searchViewId: false,
context: {
@@ -133,7 +132,7 @@ QUnit.module("Views", (hooks) => {
await makeView({
resModel: "foo",
type: "forecast_graph",
type: "graph",
serverData,
searchViewId: false,
context: {
@@ -45,7 +45,7 @@ AddToGoogleSpreadsheet.template = "google_spreadsheet.AddToGoogleSpreadsheet";
const addToGoogleSpreadsheetItem = {
Component: AddToGoogleSpreadsheet,
groupNumber: 4,
isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window",
isDisplayed: ({ config }) => config.actionType === "ir.actions.act_window",
};
favoriteMenuRegistry.add("add-to-google-spreadsheet", addToGoogleSpreadsheetItem, { sequence: 20 });
@@ -1,12 +1,12 @@
/** @odoo-module **/
import { GraphView } from "@web/views/graph/graph_view";
import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel";
import { registry } from "@web/core/registry";
import { GraphView } from "@web/views/graph/graph_view";
const viewRegistry = registry.category("views");
class ProjectGraphView extends GraphView {}
ProjectGraphView.components = { ...GraphView.components, ControlPanel: ProjectControlPanel };
ProjectGraphView.ControlPanel = ProjectControlPanel;
viewRegistry.add("project_graph", ProjectGraphView);
@@ -1,12 +1,12 @@
/** @odoo-module **/
import { PivotView } from "@web/views/pivot/pivot_view";
import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel";
import { registry } from "@web/core/registry";
import { PivotView } from "@web/views/pivot/pivot_view";
const viewRegistry = registry.category("views");
class ProjectPivotView extends PivotView {}
ProjectPivotView.components = { ...PivotView.components, ControlPanel: ProjectControlPanel };
ProjectPivotView.ControlPanel = ProjectControlPanel;
viewRegistry.add("project_pivot", ProjectPivotView);
@@ -1,9 +1,9 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { GraphView } from "@web/views/graph/graph_view";
import { GraphArchParser } from "@web/views/graph/graph_arch_parser";
import { registry } from "@web/core/registry";
import { GraphArchParser } from "@web/views/graph/graph_arch_parser";
import { GraphView } from "@web/views/graph/graph_view";
const viewRegistry = registry.category("views");
@@ -29,6 +29,6 @@ class ProjectRatingArchParser extends GraphArchParser {
// Would it be not better achiedved by using a proper arch directly?
class ProjectRatingGraphView extends GraphView {}
ProjectRatingGraphView.archParser = ProjectRatingArchParser;
ProjectRatingGraphView.ArchParser = ProjectRatingArchParser;
viewRegistry.add("project_rating_graph", ProjectRatingGraphView);
@@ -1,9 +1,9 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { PivotView } from "@web/views/pivot/pivot_view";
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
import { registry } from "@web/core/registry";
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
import { PivotView } from "@web/views/pivot/pivot_view";
const viewRegistry = registry.category("views");
@@ -29,6 +29,6 @@ class ProjectRatingArchParser extends PivotArchParser {
// Would it be not better achiedved by using a proper arch directly?
class ProjectRatingPivotView extends PivotView {}
ProjectRatingPivotView.archParser = ProjectRatingArchParser;
ProjectRatingPivotView.ArchParser = ProjectRatingArchParser;
viewRegistry.add("project_rating_pivot", ProjectRatingPivotView);
@@ -9,7 +9,7 @@ export class ProjectControlPanel extends ControlPanel {
this.orm = useService("orm");
this.user = useService("user");
const { active_id, show_project_update } = this.env.searchModel.globalContext;
this.showProjectUpdate = this.env.searchModel.view.type === "form" || show_project_update;
this.showProjectUpdate = this.env.config.viewType === "form" || show_project_update;
this.projectId = this.showProjectUpdate ? active_id : false;
}
@@ -48,4 +48,5 @@ export class ProjectControlPanel extends ControlPanel {
});
}
}
ProjectControlPanel.template = "project.ProjectControlPanel";
+7 -5
View File
@@ -51,10 +51,11 @@ function registerView(name, LegacyView) {
searchPanel = globalState.searchPanel;
}
this.viewParams = Object.assign({}, this.props.actionFlags, {
const { actionFlags, breadcrumbs = [] } = this.env.config;
this.viewParams = Object.assign({}, actionFlags, {
action: this.props.action,
// legacy views automatically add the last part of the breadcrumbs
breadcrumbs: breadcrumbsToLegacy(this.props.breadcrumbs),
breadcrumbs: breadcrumbsToLegacy(breadcrumbs),
modelName: this.props.resModel,
currentId: this.props.resId,
controllerState: {
@@ -99,7 +100,7 @@ function registerView(name, LegacyView) {
context: this.props.context,
};
const options = {
actionId: this.props.action.id,
actionId: this.env.config.actionId,
loadActionMenus: this.props.loadActionMenus,
loadIrFilters: this.props.loadIrFilters,
};
@@ -120,10 +121,11 @@ function registerView(name, LegacyView) {
viewFields: result.fields_views.search.fields,
});
}
const { viewSwitcherEntries = [] } = this.env.config;
const views = this.viewParams.action.views
.filter(([vid, vtype]) => vtype !== "search")
.filter(([, vtype]) => vtype !== "search")
.map(([vid, vtype]) => {
const view = this.props.viewSwitcherEntries.find((v) => v.type === vtype);
const view = viewSwitcherEntries.find((v) => v.type === vtype);
if (view) {
return Object.assign({}, view, { viewID: vid });
} else {
@@ -1,11 +1,11 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { ComparisonMenu } from "../comparison_menu/comparison_menu";
import { FavoriteMenu } from "../favorite_menu/favorite_menu";
import { FilterMenu } from "../filter_menu/filter_menu";
import { GroupByMenu } from "../group_by_menu/group_by_menu";
import { SearchBar } from "../search_bar/search_bar";
import { useService } from "@web/core/utils/hooks";
const { Component } = owl;
@@ -21,6 +21,44 @@ export class ControlPanel extends Component {
this.actionService = useService("action");
}
/**
* !!! What follows is a hack, do not copy it !!!
*
* Duplicates the slots defined for the parent s.t. they are also available
* for the current control panel.
*
* This hack is necessary since Owl does not support manual slots
* assignment/transfer yet. This must be removed as soon as Owl implements
* such a system.
*
* @strongly_discouraged_override
*/
__render() {
const { slots } = this.env.qweb.constructor;
const { __owl__ } = this;
const originalSlots = {};
const transferredSlotNames = [
"control-panel-top-left",
"control-panel-top-right",
"control-panel-bottom-left",
"control-panel-bottom-right",
];
for (const slotName of transferredSlotNames) {
const parentSlotkey = `${__owl__.parent.__owl__.slotId}_${slotName}`;
if (parentSlotkey in slots) {
const cpSlotKey = `${__owl__.slotId}_${slotName}`;
originalSlots[cpSlotKey] = slots[cpSlotKey];
slots[cpSlotKey] = function (scope, extra) {
slots[parentSlotkey].call(this, __owl__.parent.__owl__.scope, extra);
};
}
}
const res = super.__render(...arguments);
// Clean up
Object.assign(slots, originalSlots);
return res;
}
/**
* @returns {Object}
*/
@@ -78,25 +116,3 @@ export class ControlPanel extends Component {
ControlPanel.components = { ComparisonMenu, FavoriteMenu, FilterMenu, GroupByMenu, SearchBar };
ControlPanel.template = "web.ControlPanel";
// ControlPanel.props = {
// breadcrumbs: { type: Array, element: { jsId: String, name: String }, optional: true },
// display: { type: Object, optional: true },
// displayName: { type: String, optional: true },
// viewSwitcherEntries: {
// type: Array,
// element: {
// type: Object,
// shape: {
// active: { type: Boolean, optional: true },
// icon: String,
// multiRecord: { type: Boolean, optional: true },
// name: [Object, String],
// type: String,
// },
// },
// optional: true,
// },
// };
ControlPanel.defaultProps = {
breadcrumbs: [],
};
@@ -30,9 +30,9 @@
<div class="o_cp_pager"/>
<t t-if="props.viewSwitcherEntries and props.viewSwitcherEntries.length > 1">
<t t-if="(env.config.viewSwitcherEntries or []).length">
<nav class="btn-group o_cp_switch_buttons">
<t t-foreach="props.viewSwitcherEntries" t-as="view" t-key="view.type">
<t t-foreach="env.config.viewSwitcherEntries" t-as="view" t-key="view.type">
<button class="btn btn-secondary fa fa-lg o_switch_view "
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
t-att-data-tooltip="view.name"
@@ -48,7 +48,7 @@
<t t-name="web.Breadcrumbs" owl="1">
<ol class="breadcrumb">
<t t-foreach="props.breadcrumbs or []" t-as="breadcrumb" t-key="breadcrumb.jsId">
<t t-foreach="env.config.breadcrumbs or []" t-as="breadcrumb" t-key="breadcrumb.jsId">
<li class="breadcrumb-item"
t-att-class="{ o_back_button: breadcrumb_last}"
t-on-click.prevent="onBreadcrumbClicked(breadcrumb.jsId)"
@@ -59,7 +59,7 @@
</li>
</t>
<li class="breadcrumb-item active">
<t t-if="props.displayName" t-esc="props.displayName"/>
<t t-if="env.config.displayName" t-esc="env.config.displayName" />
<em t-else="" class="text-warning">Unnamed</em>
</li>
</ol>
@@ -14,7 +14,7 @@ export class CustomFavoriteItem extends Component {
this.descriptionRef = useRef("description");
useAutofocus();
this.state = useState({
description: this.env.searchModel.displayName,
description: this.env.config.displayName,
isDefault: false,
isShared: false,
});
@@ -46,7 +46,7 @@ export class CustomFavoriteItem extends Component {
this.env.searchModel.createNewFavorite({ description, isDefault, isShared });
Object.assign(this.state, {
description: this.env.searchModel.displayName,
description: this.env.config.displayName,
isDefault: false,
isShared: false,
});
+8 -14
View File
@@ -4,6 +4,7 @@ import { makeContext } from "@web/core/context";
import { Domain } from "@web/core/domain";
import { evaluateExpr } from "@web/core/py_js/py";
import { sortBy } from "@web/core/utils/arrays";
import { deepCopy } from "@web/core/utils/objects";
import { SearchArchParser } from "./search_arch_parser";
import {
constructDateDomain,
@@ -15,7 +16,6 @@ import {
yearSelected,
} from "./utils/dates";
import { FACET_ICONS } from "./utils/misc";
import { deepCopy } from "@web/core/utils/objects";
const { DateTime } = luxon;
const EventBus = owl.core.EventBus;
@@ -190,11 +190,9 @@ export class SearchModel extends EventBus {
* @param {number|false} [config.searchViewId=false]
* @param {Object[]} [config.irFilters=[]]
*
* @param {Object} [config.action={id:false,views:[]}]
* @param {boolean} [config.activateFavorite=true]
* @param {Object | null} [config.comparison]
* @param {Object} [config.context={}]
* @param {String} [config.displayName=""]
* @param {Array} [config.domain=[]]
* @param {Array} [config.dynamicFilters=[]]
* @param {string[]} [config.groupBy=[]]
@@ -203,7 +201,6 @@ export class SearchModel extends EventBus {
* @param {string[]} [config.orderBy=[]]
* @param {string[]} [config.searchMenuTypes=["filter", "groupBy", "favorite"]]
* @param {Object} [config.state]
* @param {Object} [config.view={id:false}]
*/
async load(config) {
const { resModel } = config;
@@ -212,12 +209,6 @@ export class SearchModel extends EventBus {
}
this.resModel = resModel;
const { action, displayName, view } = config;
this.action = action || { id: false, views: [] };
this.displayName = displayName || "";
this.view = view || { id: false };
// used to avoid useless recomputations
this._reset();
@@ -245,7 +236,7 @@ export class SearchModel extends EventBus {
views: [[searchViewId, "search"]],
},
{
actionId: this.action.id,
actionId: this.env.config.actionId,
loadIrFilters: loadIrFilters || false,
}
);
@@ -322,7 +313,6 @@ export class SearchModel extends EventBus {
const { labels, preSearchItems, searchPanelInfo, sections } = parser.parse();
this.searchPanelInfo = { ...searchPanelInfo, shouldReload: false };
this.display = this._getDisplay(config.display);
await Promise.all(labels.map((cb) => cb(this.orm)));
@@ -355,6 +345,7 @@ export class SearchModel extends EventBus {
/** @type Map<number,Section> */
this.sections = new Map(sections || []);
this.display = this._getDisplay(config.display);
if (this.display.searchPanel) {
/** @type DomainListRepr */
@@ -1381,11 +1372,14 @@ export class SearchModel extends EventBus {
*/
_getDisplay(display = {}) {
const { viewTypes } = this.searchPanelInfo;
const { bannerRoute, viewType } = this.env.config;
return {
controlPanel: "controlPanel" in display ? display.controlPanel : {},
searchPanel:
(!this.view.type || viewTypes.includes(this.view.type)) &&
this.sections.size &&
(!viewType || viewTypes.includes(viewType)) &&
("searchPanel" in display ? display.searchPanel : true),
banner: Boolean(bannerRoute),
};
}
@@ -1763,7 +1757,7 @@ export class SearchModel extends EventBus {
};
const irFilter = {
name: description,
action_id: this.action.id,
action_id: this.env.config.actionId,
model_id: this.resModel,
domain,
is_default: isDefault,
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { useBus, useEffect, useService } from "@web/core/utils/hooks";
import { useBus, useService } from "@web/core/utils/hooks";
import { SearchModel } from "@web/search/search_model";
import { CallbackRecorder, useSetupAction } from "@web/webclient/actions/action_hook";
@@ -15,40 +15,24 @@ export class WithSearch extends Component {
this.Component = this.props.Component;
if (!this.env.__getContext__) {
useSubEnv({
__getContext__: new CallbackRecorder(),
});
useSubEnv({ __getContext__: new CallbackRecorder() });
}
const SearchModelClass = this.Component.SearchModel || SearchModel;
this.searchModel = new SearchModelClass(this.env, {
this.env.searchModel = new SearchModelClass(this.env, {
user: useService("user"),
orm: useService("orm"),
view: useService("view"),
});
useBus(this.searchModel, "update", () => this.render());
useSubEnv({
searchModel: this.searchModel,
});
useBus(this.env.searchModel, "update", this.render);
useSetupAction({
getGlobalState: () => {
return {
searchModel: JSON.stringify(this.searchModel.exportState()),
searchModel: JSON.stringify(this.env.searchModel.exportState()),
};
},
});
useEffect(() => {
if (!this.searchModel.display.searchPanel) {
return;
}
// TODO: add better way to retrieve o_content
const [content] = this.el.getElementsByClassName("o_content");
if (content) {
content.classList.add("o_component_with_search_panel");
}
});
}
async willStart() {
@@ -57,7 +41,7 @@ export class WithSearch extends Component {
config.state = JSON.parse(config.globalState.searchModel);
delete config.globalState;
}
await this.searchModel.load(config);
await this.env.searchModel.load(config);
}
async willUpdateProps(nextProps) {
@@ -67,7 +51,7 @@ export class WithSearch extends Component {
config[key] = nextProps[key];
}
}
await this.searchModel.reload(config);
await this.env.searchModel.reload(config);
}
//-------------------------------------------------------------------------
@@ -77,20 +61,18 @@ export class WithSearch extends Component {
get componentProps() {
const componentProps = { ...this.props.componentProps };
for (const key of SEARCH_KEYS) {
componentProps[key] = this.searchModel[key];
componentProps[key] = this.env.searchModel[key];
}
componentProps.info = componentProps.info || {};
for (const key of OTHER_SEARCH_KEYS) {
componentProps.info[key] = this.searchModel[key];
componentProps.info[key] = this.env.searchModel[key];
}
return componentProps;
}
}
WithSearch.defaultProps = {
action: { id: false, views: [] },
componentProps: {},
view: { id: false },
};
WithSearch.props = {
Component: Function,
@@ -98,25 +80,6 @@ WithSearch.props = {
resModel: String,
action: {
type: Object,
shape: {
id: [Number, false],
type: { type: [String, false], optional: true },
views: { type: Array, element: [Number, String, false], optional: true },
},
optional: true,
},
displayName: { type: String, optional: true },
view: {
type: Object,
shape: {
id: [Number, false],
type: { type: [String, false], optional: true },
},
optional: true,
},
globalState: { type: Object, optional: true },
display: { type: Object, optional: true },
+1 -4
View File
@@ -44,15 +44,12 @@ export function editView({ accessRights, component, env }) {
if (!accessRights.canEditView) {
return null;
}
let type;
let { viewId } = component.props.info || {}; // fallback is there for legacy
let { viewId, viewType: type } = component.env.config || {}; // fallback is there for legacy
if ("viewInfo" in component.props) {
// legacy
viewId = component.props.viewInfo.view_id;
type = component.props.viewInfo.type;
type = type === "tree" ? "list" : type;
} else {
type = component.constructor.type;
}
const displayName = type[0].toUpperCase() + type.slice(1);
const description = env._t("Edit View: ") + displayName;
@@ -6,16 +6,16 @@
<table>
<thead>
<tr>
<th class="o_measure" t-esc="measure"/>
<th class="o_measure" t-esc="measure" />
</tr>
</thead>
<tbody>
<tr t-foreach="tooltipItems" t-as="tooltipItem" t-key="tooltipItem.id">
<td>
<span class="o_square" t-attf-style="background-color: {{ tooltipItem.boxColor }}"/>
<span class="o_label" t-attf-style="max-width: {{ maxWidth }}" t-esc="tooltipItem.label"/>
<span class="o_square" t-attf-style="background-color: {{ tooltipItem.boxColor }}" />
<span class="o_label" t-attf-style="max-width: {{ maxWidth }}" t-esc="tooltipItem.label" />
</td>
<td class="o_value" t-esc="tooltipItem.value"/>
<td class="o_value" t-esc="tooltipItem.value" />
</tr>
</tbody>
</table>
@@ -25,9 +25,9 @@
<t t-name="web.GraphRenderer" owl="1">
<div class="o_graph_renderer">
<div class="o_graph_canvas_container" t-ref="container">
<canvas t-ref="canvas"/>
<canvas t-ref="canvas" />
</div>
</div>
</t>
</templates>
</templates>
@@ -1,17 +1,16 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { SearchPanel } from "@web/search/search_panel/search_panel";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
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";
import { GraphRenderer } from "./graph_renderer";
import { GroupByMenu } from "@web/search/group_by_menu/group_by_menu";
import { registry } from "@web/core/registry";
import { standardViewProps } from "@web/views/helpers/standard_view_props";
import { useModel } from "../helpers/model";
import { useService } from "@web/core/utils/hooks";
import { useSetupView } from "@web/views/helpers/view_hook";
const viewRegistry = registry.category("views");
@@ -74,7 +73,7 @@ export class GraphView extends Component {
const { context, resModel, title } = this.model.metaData;
const views = {};
for (const [viewId, viewType] of this.props.info.views || []) {
for (const [viewId, viewType] of this.env.config.views || []) {
views[viewType] = viewId;
}
function getView(viewType) {
@@ -131,7 +130,7 @@ export class GraphView extends Component {
GraphView.template = "web.GraphView";
GraphView.buttonTemplate = "web.GraphView.Buttons";
GraphView.components = { ControlPanel, GroupByMenu, Renderer: GraphRenderer, SearchPanel };
GraphView.components = { GroupByMenu, Renderer: GraphRenderer, Layout };
GraphView.defaultProps = {
additionalMeasures: [],
@@ -1,4 +1,4 @@
.o_graph_view {
.o_graph_view > .o_content > .o_renderer {
height: 100%;
.o_graph_canvas_container {
@@ -46,31 +46,26 @@
</t>
<t t-name="web.GraphView" owl="1">
<div class="o_graph_view" t-att-class="{ o_view_sample_data: model.useSampleModel }">
<ControlPanel t-if="env.searchModel.display.controlPanel" t-props="props.info">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
<Layout viewType="'graph'" useSampleModel="model.useSampleModel">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
</t>
<t t-if="model.data">
<t t-if="model.useSampleModel and props.info.noContentHelp" t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.info.noContentHelp"/>
</t>
</ControlPanel>
<div class="o_content">
<SearchPanel t-if="env.searchModel.display.searchPanel" />
<t t-if="model.data">
<t t-if="model.useSampleModel and props.info.noContentHelp" t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.info.noContentHelp"/>
</t>
<Renderer
class="o_renderer"
model="model"
t-att-class="{ o_sample_data_disabled: model.useSampleModel }"
onGraphClicked="(domain) => onGraphClicked(domain)"
/>
</t>
<t t-else="" t-call="web.NoContentHelper">
<t t-set="title">Invalid data</t>
<t t-set="description">Pie chart cannot mix positive and negative numbers. Try to change your domain to only display positive results</t>
</t>
</div>
</div>
<Renderer
class="o_renderer"
model="model"
t-att-class="{ o_sample_data_disabled: model.useSampleModel }"
onGraphClicked="(domain) => onGraphClicked(domain)"
/>
</t>
<t t-else="" t-call="web.NoContentHelper">
<t t-set="title">Invalid data</t>
<t t-set="description">Pie chart cannot mix positive and negative numbers. Try to change your domain to only display positive results</t>
</t>
</Layout>
</t>
</templates>
+32
View File
@@ -0,0 +1,32 @@
/** @odoo-module **/
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { SearchPanel } from "@web/search/search_panel/search_panel";
const { Component } = owl;
/**
* @param {Object} params
* @returns {Object}
*/
export const extractLayoutComponents = (params) => {
return {
ControlPanel: params.ControlPanel || ControlPanel,
SearchPanel: params.SearchPanel || SearchPanel,
Banner: params.Banner || false,
};
};
export class Layout extends Component {
setup() {
const { display = {} } = this.env.searchModel || {};
this.components = extractLayoutComponents(this.env.config);
this.display = display;
}
}
Layout.template = "web.Layout";
Layout.props = {
viewType: { type: String, optional: true },
useSampleModel: { type: Boolean, optional: true },
};
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Layout" owl="1">
<div t-att-class="{ o_view_sample_data: props.useSampleModel }" t-attf-class="{{ props.viewType ? `o_${props.viewType}_view` : '' }}">
<t t-component="components.ControlPanel" 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>
@@ -12,14 +12,14 @@ export class OnboardingBanner extends owl.Component {
useActionLinks({
resModel,
reload: async () => {
this.bannerHTML = await this.loadBanner(this.props.bannerRoute);
this.bannerHTML = await this.loadBanner(this.env.config.bannerRoute);
this.render();
},
});
}
async willStart() {
this.bannerHTML = await this.loadBanner(this.props.bannerRoute);
this.bannerHTML = await this.loadBanner(this.env.config.bannerRoute);
}
async loadBanner(bannerRoute) {
@@ -47,4 +47,6 @@ export class OnboardingBanner extends owl.Component {
return new XMLSerializer().serializeToString(banner);
}
}
OnboardingBanner.template = owl.tags.xml`<div t-raw="bannerHTML" />`;
OnboardingBanner.props = {};
@@ -4,11 +4,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 { ControlPanel } from "@web/search/control_panel/control_panel";
import { SearchPanel } from "@web/search/search_panel/search_panel";
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";
@@ -130,8 +129,9 @@ export class PivotView extends Component {
});
// retrieve form and list view ids from the action
const { views = [] } = this.env.config;
this.views = ["list", "form"].map((viewType) => {
const view = this.props.info.views.find((view) => view[1] === viewType);
const view = views.find((view) => view[1] === viewType);
return [view ? view[0] : false, viewType];
});
@@ -157,7 +157,7 @@ export class PivotView extends Component {
PivotView.template = "web.PivotView";
PivotView.buttonTemplate = "web.PivotView.Buttons";
PivotView.components = { ControlPanel, SearchPanel, Renderer: PivotRenderer };
PivotView.components = { Renderer: PivotRenderer, Layout };
PivotView.props = {
...standardViewProps,
@@ -17,30 +17,26 @@
</t>
<t t-name="web.PivotView" owl="1">
<div class="o_pivot_view" t-att-class="{o_view_sample_data: model.useSampleModel }">
<ControlPanel t-if="env.searchModel.display.controlPanel" t-props="props.info">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
<Layout viewType="'pivot'" useSampleModel="model.useSampleModel">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
</t>
<t t-set="displayNoContent" t-value="
props.info.noContentHelp !== false and (
!(model.hasData() and model.metaData.activeMeasures.length) or
model.useSampleModel
)"
/>
<t t-if="displayNoContent">
<t t-if="props.info.noContentHelp" t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.info.noContentHelp"/>
</t>
</ControlPanel>
<div class="o_content">
<SearchPanel t-if="env.searchModel.display.searchPanel" />
<t t-set="displayNoContent" t-value="
props.info.noContentHelp !== false and (
!(model.hasData() and model.metaData.activeMeasures.length) or
model.useSampleModel
)" />
<t t-if="displayNoContent">
<t t-if="props.info.noContentHelp" t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.info.noContentHelp"/>
</t>
<t t-else="" t-call="web.NoContentHelper"/>
</t>
<t t-if="model.hasData() and model.metaData.activeMeasures.length">
<Renderer model="model" onCellClicked="cell => onOpenView(cell)"/>
</t>
</div>
</div>
<t t-else="" t-call="web.NoContentHelper"/>
</t>
<t t-if="model.hasData() and model.metaData.activeMeasures.length">
<Renderer model="model" onCellClicked="cell => onOpenView(cell)"/>
</t>
</Layout>
</t>
</templates>
+41 -64
View File
@@ -2,21 +2,22 @@
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { KeepLast } from "@web/core/utils/concurrency";
import { useService } from "@web/core/utils/hooks";
import { deepCopy } from "@web/core/utils/objects";
import { WithSearch } from "@web/search/with_search/with_search";
import { useActionLinks } from "@web/views/helpers/view_hook";
import { extractLayoutComponents } from "@web/views/layout";
const viewRegistry = registry.category("views");
const { Component } = owl;
const { Component, hooks } = owl;
const { useSubEnv } = hooks;
/** @typedef {Object} ViewProps
* @property {string} resModel
* @property {string} type
*
* @property {Array[]} [views]
*
* @property {string} [arch] if given, fields must be given too /\ no post processing is done (evaluation of "groups" attribute,...)
* @property {Object} [fields] if given, arch must be given too
* @property {number|false} [viewId]
@@ -35,18 +36,11 @@ const { Component } = owl;
* @property {string[]} [groupBy]
* @property {string[]} [orderBy]
*
* @property {Object} [action={}]
* @property {Object} [actionFlags={}]
* @property {string} [displayName]
*
* @property {boolean} [useSampleModel]
* @property {string} [noContentHelp]
*
* @property {Object} [display={}] to rework
*
* @property {Object[]} [breadcrumbs]
* @property {Object[]} [viewSwitcherEntries]
*
* manipulated by withSearch
*
* @property {boolean} [activateFavorite]
@@ -55,12 +49,10 @@ const { Component } = owl;
* @property {Object} [globalState]
*/
const STANDARD_PROPS = new Set([
const STANDARD_PROPS = [
"resModel",
"type",
"views",
"arch",
"fields",
"viewId",
@@ -79,17 +71,10 @@ const STANDARD_PROPS = new Set([
"groupBy",
"orderBy",
"action",
"actionFlags",
"displayName",
"useSampleModel",
"noContentHelp",
"breadcrumbs",
"display",
"viewSwitcherEntries",
"globalState",
"activateFavorite",
@@ -97,7 +82,7 @@ const STANDARD_PROPS = new Set([
// LEGACY: remove this later (clean when mappings old state <-> new state are established)
"searchPanel",
"searchModel",
]);
];
export class View extends Component {
setup() {
@@ -117,11 +102,11 @@ export class View extends Component {
}
this.viewService = useService("view");
this.withSearchProps = null;
owl.hooks.useSubEnv({
useSubEnv({
keepLast: new KeepLast(),
config: { ...(this.env.config || {}) },
});
useActionLinks({ resModel });
}
@@ -132,16 +117,16 @@ export class View extends Component {
const type = ViewClass.type;
// determine views for which descriptions should be obtained
let { views, viewId, searchViewId } = this.props;
let { viewId, searchViewId } = this.props;
views = JSON.parse(JSON.stringify(views));
const view = views.find((v) => v[1] === type);
if (view) {
const views = deepCopy(this.env.config.views || []);
const view = views.find((v) => v[1] === type) || [];
if (view.length) {
view[0] = viewId !== undefined ? viewId : view[0];
viewId = view[0];
} else {
views.push([viewId || false, type]); // viewId will remain undefined if not specified and loadView=false
view.push(viewId || false, type);
views.push(view); // viewId will remain undefined if not specified and loadView=false
}
const searchView = views.find((v) => v[1] === "search");
@@ -154,26 +139,27 @@ export class View extends Component {
// searchViewId will remains undefined if loadSearchView=false
// prepare view description
const { action, context, resModel, loadActionMenus, loadIrFilters } = this.props;
const { context, resModel, loadActionMenus, loadIrFilters } = this.props;
let { arch, fields, searchViewArch, searchViewFields, irFilters, actionMenus } = this.props;
let loadView = !arch || (!actionMenus && loadActionMenus);
let loadSearchView =
(searchViewId !== undefined && !searchViewArch) || (!irFilters && loadIrFilters);
let viewDescription = { resModel, type };
let viewDescription = { viewId, resModel, type };
let searchViewDescription;
if (loadView || loadSearchView) {
// view description (or search view description if required) is incomplete
// a loadViews is done to complete the missing information
const viewDescriptions = await this.viewService.loadViews(
{ context, resModel, views },
{ actionId: action.id, loadActionMenus, loadIrFilters }
{ actionId: this.env.config.actionId, loadActionMenus, loadIrFilters }
);
// Note: if this.props.views is different from views, the cached descriptions
// will certainly not be reused! (but for the standard flow this will work as
// before)
viewDescription = viewDescriptions[type];
view[0] = viewDescription.viewId;
searchViewDescription = viewDescriptions.search;
if (loadSearchView) {
if (!searchViewArch) {
@@ -184,6 +170,7 @@ export class View extends Component {
irFilters = searchViewDescription.irFilters;
}
}
this.env.config.views = views;
}
if (!arch) {
@@ -197,33 +184,28 @@ export class View extends Component {
const parser = new DOMParser();
const xml = parser.parseFromString(arch, "text/xml");
const rootNode = xml.documentElement;
const rootAttrs = {};
for (const attrName of rootNode.getAttributeNames()) {
rootAttrs[attrName] = rootNode.getAttribute(attrName);
}
const subType = rootNode.getAttribute("js_class");
const bannerRoute = rootNode.getAttribute("banner_route");
const sample = rootNode.getAttribute("sample");
// determine ViewClass to instantiate (if not already done)
if (rootAttrs.js_class) {
ViewClass = viewRegistry.get(rootAttrs.js_class);
if (subType) {
ViewClass = viewRegistry.get(subType);
}
const bannerRoute = rootAttrs.banner_route;
Object.assign(this.env.config, {
viewId: viewDescription.viewId,
viewType: type,
viewSubType: subType,
bannerRoute,
...extractLayoutComponents(ViewClass),
});
// prepare the view props
let viewProps = {
info: {
actionFlags: this.props.actionFlags,
action: this.props.action,
viewId: viewDescription.viewId,
views,
mode: this.props.display.mode,
actionMenus,
breadcrumbs: this.props.breadcrumbs,
viewSwitcherEntries: this.props.viewSwitcherEntries,
displayName: this.props.displayName,
},
const viewProps = {
info: { actionMenus, mode: this.props.display.mode },
arch,
bannerRoute,
fields,
resModel,
useSampleModel: false,
@@ -234,12 +216,12 @@ export class View extends Component {
if ("useSampleModel" in this.props) {
viewProps.useSampleModel = this.props.useSampleModel;
} else if (rootAttrs.sample) {
viewProps.useSampleModel = Boolean(evaluateExpr(rootAttrs.sample));
} else if (sample) {
viewProps.useSampleModel = Boolean(evaluateExpr(sample));
}
for (const key in this.props) {
if (!STANDARD_PROPS.has(key)) {
if (!STANDARD_PROPS.includes(key)) {
viewProps[key] = this.props[key];
}
}
@@ -254,13 +236,11 @@ export class View extends Component {
}
// prepare the WithSearh component props
this.withSearchProps = { ...this.props };
Object.assign(this.withSearchProps, {
this.withSearchProps = {
...this.props,
Component: ViewClass,
componentProps: viewProps,
view: { id: viewId, type },
});
};
if (searchViewId !== undefined) {
this.withSearchProps.searchViewId = searchViewId;
@@ -298,13 +278,10 @@ export class View extends Component {
View.template = "web.View";
View.components = { WithSearch };
View.defaultProps = {
action: { id: false, views: [] },
display: {},
context: {},
actionFlags: {},
loadActionMenus: false,
loadIrFilters: false,
views: [],
};
View.searchMenuTypes = ["filter", "groupBy", "favorite"];
@@ -1,18 +1,18 @@
/** @odoo-module **/
import { cleanDomFromBootstrap } from "@web/legacy/utils";
import { browser } from "@web/core/browser/browser";
import { useBus } from "@web/core/utils/hooks";
import { makeContext } from "@web/core/context";
import { useDebugCategory } from "@web/core/debug/debug_context";
import { download } from "@web/core/network/download";
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { useBus } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { useDebugCategory } from "@web/core/debug/debug_context";
import { cleanDomFromBootstrap } from "@web/legacy/utils";
import { View } from "@web/views/view";
import { ActionDialog } from "./action_dialog";
import { CallbackRecorder } from "./action_hook";
import { View } from "@web/views/view";
const { Component, hooks, tags } = owl;
const { useRef, useSubEnv } = hooks;
@@ -307,10 +307,19 @@ function makeActionManager(env) {
/**
* @param {ClientAction | ActWindowAction} action
* @param {Object} props
* @returns {ActionProps}
* @returns {{ props: ActionProps, config: Config }}
*/
function _getActionProps(action, props) {
return Object.assign({}, props, { action, actionId: action.id });
function _getActionInfo(action, props) {
return {
props: Object.assign({}, props, { action, actionId: action.id }),
config: {
actionId: action.id,
actionType: action.type,
actionFlags: action.flags,
displayName: action.display_name || action.name || "",
views: action.views,
},
};
}
/**
@@ -366,9 +375,9 @@ function makeActionManager(env) {
* @param {ActWindowAction} action
* @param {BaseView[]} views
* @param {Object} props
* @returns {ViewProps}
* @returns {{ props: ViewProps, config: Config }}
*/
function _getViewProps(view, action, views, props = {}) {
function _getViewInfo(view, action, views, props = {}) {
const target = action.target;
const viewSwitcherEntries = views
.filter((v) => v.multiRecord === view.multiRecord)
@@ -385,25 +394,15 @@ function makeActionManager(env) {
return viewSwitcherEntry;
});
const context = action.context || {};
const flags = action.flags || {};
const viewProps = Object.assign({}, props, {
actionFlags: Object.assign({}, flags, flags[view.type]), // review system
action: {
id: action.id || false,
type: action.type || false,
views: action.views || [],
},
context,
display: { mode: target === "new" ? "inDialog" : target },
displayName: action.display_name || action.name,
domain: action.domain || [],
groupBy: action.context.group_by || [],
loadActionMenus: target !== "new" && target !== "inline",
loadIrFilters: action.views.some((v) => v[1] === "search"),
resModel: action.res_model,
type: view.type,
views: action.views,
viewSwitcherEntries,
});
if (target === "inline") {
@@ -433,11 +432,25 @@ function makeActionManager(env) {
// LEGACY CODE COMPATIBILITY: remove when all views will be written in owl
if (view.isLegacy) {
const legacyActionInfo = { ...action, ...viewProps.action };
Object.assign(viewProps, { action: legacyActionInfo, View: view });
Object.assign(viewProps, {
action: legacyActionInfo,
View: view,
views: action.views,
});
}
// END LEGACY CODE COMPATIBILITY
return viewProps;
return {
props: viewProps,
config: {
actionId: action.id,
actionType: action.type,
actionFlags: action.flags,
displayName: action.display_name || action.name || "",
views: action.views,
viewSwitcherEntries,
},
};
}
/**
@@ -502,11 +515,24 @@ function makeActionManager(env) {
});
const action = controller.action;
// Compute breadcrumbs
const index = _computeStackIndex(options);
const controllerArray = [controller];
if (options.lazyController) {
controllerArray.unshift(options.lazyController);
}
const nextStack = controllerStack.slice(0, index).concat(controllerArray);
controller.config.breadcrumbs = _getBreadcrumbs(nextStack.slice(0, -1));
if (controller.Component.isLegacy) {
controller.props.breadcrumbs = controller.config.breadcrumbs;
}
class ControllerComponent extends Component {
setup() {
this.Component = controller.Component;
this.componentRef = useRef("component");
useDebugCategory("action", { action });
useSubEnv({ config: controller.config });
if (action.target !== "new") {
this.__beforeLeave__ = new CallbackRecorder();
this.__getGlobalState__ = new CallbackRecorder();
@@ -679,13 +705,6 @@ function makeActionManager(env) {
controller.props.globalState = controller.action.globalState;
}
const index = _computeStackIndex(options);
const controllerArray = [controller];
if (options.lazyController) {
controllerArray.unshift(options.lazyController);
}
const nextStack = controllerStack.slice(0, index).concat(controllerArray);
controller.props.breadcrumbs = _getBreadcrumbs(nextStack.slice(0, nextStack.length - 1));
const closingProm = _executeCloseAction();
controller.__info__ = {
@@ -772,7 +791,7 @@ function makeActionManager(env) {
action,
view,
views,
props: _getViewProps(view, action, views, options.props),
..._getViewInfo(view, action, views, options.props),
};
action.controllers[view.type] = controller;
@@ -789,7 +808,7 @@ function makeActionManager(env) {
action,
view: lazyView,
views,
props: _getViewProps(lazyView, action, views),
..._getViewInfo(lazyView, action, views),
};
}
@@ -829,7 +848,7 @@ function makeActionManager(env) {
jsId: `controller_${++id}`,
Component: clientAction,
action,
props: _getActionProps(action, options.props),
..._getActionInfo(action, options.props),
};
return _updateUI(controller, {
clearBreadcrumbs: options.clearBreadcrumbs,
@@ -1197,7 +1216,10 @@ function makeActionManager(env) {
}
// END LEGACY CODE COMPATIBILITY
newController.props = _getViewProps(view, controller.action, controller.views, props);
Object.assign(
newController,
_getViewInfo(view, controller.action, controller.views, props)
);
controller.action.controllers[viewType] = newController;
let index;
if (view.multiRecord) {
@@ -1235,7 +1257,10 @@ function makeActionManager(env) {
}
const controller = controllerStack[index];
if (controller.action.type === "ir.actions.act_window") {
controller.props = _getViewProps(controller.view, controller.action, controller.views);
Object.assign(
controller,
_getViewInfo(controller.view, controller.action, controller.views)
);
}
await clearUncommittedChanges(env);
return _updateUI(controller, { index });
@@ -110,6 +110,7 @@ export async function makeTestEnv(config = {}) {
});
const env = makeEnv();
env.config = config.config || {};
await startServices(env);
env.qweb.addTemplates(window.__ODOO_TEMPLATES__);
return env;
@@ -26,6 +26,7 @@ import legacyViewRegistry from "web.view_registry";
const serviceRegistry = registry.category("services");
const viewRegistry = registry.category("views");
const searchModelRegistry = registry.category("search_models");
let serverData;
QUnit.module("Views", (hooks) => {
@@ -81,7 +82,7 @@ QUnit.module("Views", (hooks) => {
ToyView.searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"];
ToyView.template = owl.tags.xml`
<div class="o_toy_view">
<ControlPanel t-props="props.info"/>
<ControlPanel />
</div>
`;
ToyView.type = "toy";
@@ -57,7 +57,7 @@ QUnit.module("Search", (hooks) => {
serverData,
resModel: "foo",
Component: ControlPanel,
componentProps: {
config: {
breadcrumbs: [{ jsId: "controller_7", name: "Previous" }],
displayName: "Current",
},
@@ -83,7 +83,7 @@ QUnit.module("Search", (hooks) => {
serverData,
resModel: "foo",
Component: ControlPanel,
componentProps: {
config: {
viewSwitcherEntries: [
{ type: "list", active: true, icon: "fa-list-ul", name: "List" },
{ type: "kanban", icon: "fa-th-large", name: "Kanban" },
@@ -75,7 +75,9 @@ QUnit.module("Search", (hooks) => {
Component: ControlPanel,
searchMenuTypes: ["favorite"],
searchViewId: false,
displayName: "Action Name",
config: {
displayName: "Action Name",
},
});
await toggleFavoriteMenu(controlPanel);
@@ -288,7 +290,7 @@ QUnit.module("Search", (hooks) => {
if (args.model === "ir.filters" && args.method === "create_or_replace") {
const irFilter = args.args[0];
assert.deepEqual(irFilter, {
action_id: false,
action_id: undefined,
context: { group_by: [] },
domain: "[]",
is_default: false,
@@ -67,7 +67,9 @@ QUnit.module("Search", (hooks) => {
Component: ControlPanel,
searchMenuTypes: ["favorite"],
searchViewId: false,
displayName: "Action Name",
config: {
displayName: "Action Name",
},
});
assert.containsOnce(controlPanel, "div.o_favorite_menu > button i.fa.fa-star");
@@ -98,7 +100,9 @@ QUnit.module("Search", (hooks) => {
Component: ControlPanel,
searchMenuTypes: ["favorite"],
searchViewId: false,
displayName: "Action Name",
config: {
displayName: "Action Name",
},
});
assert.containsOnce(controlPanel, "div.o_favorite_menu > button i.fa.fa-star");
+13 -4
View File
@@ -4,10 +4,10 @@ import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { notificationService } from "@web/core/notifications/notification_service";
import { ormService } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { CustomFavoriteItem } from "@web/search/favorite_menu/custom_favorite_item";
import { WithSearch } from "@web/search/with_search/with_search";
import { viewService } from "@web/views/view_service";
import { actionService } from "@web/webclient/actions/action_service";
import { CustomFavoriteItem } from "@web/search/favorite_menu/custom_favorite_item";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, triggerEvent } from "../helpers/utils";
@@ -34,16 +34,25 @@ export const setupControlPanelFavoriteMenuRegistry = () => {
};
export const makeWithSearch = async (params) => {
const serverData = params.serverData || undefined;
const mockRPC = params.mockRPC || undefined;
const props = { ...params };
const serverData = props.serverData || undefined;
const mockRPC = props.mockRPC || undefined;
const config = props.config || {};
delete props.serverData;
delete props.mockRPC;
const env = await makeTestEnv({ serverData, mockRPC });
delete props.config;
const env = await makeTestEnv({ serverData, mockRPC, config });
const target = getFixture();
const withSearch = await mount(WithSearch, { env, props, target });
registerCleanup(() => withSearch.destroy());
const component = Object.values(withSearch.__owl__.children)[0];
return component;
};
@@ -401,7 +401,7 @@ QUnit.module("Search", (hooks) => {
<searchpanel/>
</search>
`,
view: { type: "kanban" },
config: { viewType: "kanban" },
});
assert.deepEqual(model.getSections(), []);
});
@@ -418,7 +418,7 @@ QUnit.module("Search", (hooks) => {
</search>
`,
resModel: "partner",
view: { type: "kanban" },
config: { viewType: "kanban" },
});
const sections = model.getSections();
for (const section of sections) {
@@ -488,7 +488,7 @@ QUnit.module("Search", (hooks) => {
</search>
`,
resModel: "partner",
view: { type: "kanban" },
config: { viewType: "kanban" },
});
const sections = model.getSections();
for (const section of sections) {
@@ -101,7 +101,7 @@ const makeTestComponent = ({ onWillStart, onWillUpdateProps } = {}) => {
TestComponent.components = { FilterMenu, GroupByMenu, SearchPanel };
TestComponent.template = xml`
<div class="o_test_component">
<SearchPanel />
<SearchPanel t-if="env.searchModel.display.searchPanel" />
<FilterMenu />
<GroupByMenu />
</div>`;
@@ -282,7 +282,29 @@ QUnit.module("Search", (hooks) => {
searchViewId: false,
display: { searchPanel: false },
});
assert.doesNotHaveClass(comp.el, "o_component_with_search_panel");
assert.containsNone(comp, ".o_search_panel");
assert.deepEqual(getDomain(), []); // initial domain
});
QUnit.test("basic rendering of a component with empty search panel", async (assert) => {
assert.expect(2);
serverData.views["partner,false,search"] = `<search><searchpanel /></search>`;
const { TestComponent, getDomain } = makeTestComponent();
const comp = await makeWithSearch({
serverData,
async mockRPC(route, { method, model }) {
if (/search_panel_/.test(method || route)) {
assert.step(`${method || route} on ${model}`);
}
},
Component: TestComponent,
resModel: "partner",
searchViewId: false,
});
assert.containsNone(comp, ".o_search_panel");
assert.deepEqual(getDomain(), []); // initial domain
});
@@ -299,7 +321,6 @@ QUnit.module("Search", (hooks) => {
Component: TestComponent,
resModel: "partner",
searchViewId: false,
view: { type: "kanban" },
});
assert.containsOnce(comp, ".o_search_panel");
@@ -361,7 +382,7 @@ QUnit.module("Search", (hooks) => {
Component: TestComponent,
resModel: "partner",
searchViewId: false,
view: { type: "toy" },
config: { viewType: "toy" },
});
const sectionHeaderIcons = comp.el.querySelectorAll(".o_search_panel_section_header i");
@@ -397,7 +418,7 @@ QUnit.module("Search", (hooks) => {
Component: TestComponent,
resModel: "partner",
searchViewId: false,
view: { type: "kanban" },
config: { viewType: "kanban" },
});
assert.containsOnce(comp, ".o_search_panel_section");
@@ -422,7 +443,7 @@ QUnit.module("Search", (hooks) => {
Component: TestComponent,
resModel: "partner",
searchViewId: false,
view: { type: "kanban" },
config: { viewType: "kanban" },
});
const headers = comp.el.getElementsByClassName("o_search_panel_section_header");
@@ -1,13 +1,7 @@
/** @odoo-module **/
import { BORDER_WHITE, DEFAULT_BG } from "@web/views/graph/colors";
import { dialogService } from "@web/core/dialog/dialog_service";
import { GraphArchParser } from "@web/views/graph/graph_arch_parser";
import { registry } from "@web/core/registry";
import { makeView } from "./helpers";
import { click, makeDeferred, nextTick, triggerEvent } from "@web/../tests/helpers/utils";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { click, makeDeferred, nextTick, triggerEvent } from "@web/../tests/helpers/utils";
import {
editFavoriteName,
saveFavorite,
@@ -22,6 +16,12 @@ import {
toggleMenuItemOption,
toggleSaveFavorite,
} from "@web/../tests/search/helpers";
import { makeView } from "@web/../tests/views/helpers";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { dialogService } from "@web/core/dialog/dialog_service";
import { registry } from "@web/core/registry";
import { BORDER_WHITE, DEFAULT_BG } from "@web/views/graph/colors";
import { GraphArchParser } from "@web/views/graph/graph_arch_parser";
const serviceRegistry = registry.category("services");
@@ -30,7 +30,8 @@ function getGraphModelMetaData(graph) {
}
export function getGraphRenderer(graph) {
return Object.values(graph.__owl__.children).find((c) => c.chart);
const layout = Object.values(graph.__owl__.children)[0];
return Object.values(layout.__owl__.children).find((c) => c.chart);
}
function getChart(graph) {
@@ -2031,26 +2032,9 @@ QUnit.module("Views", (hooks) => {
type: "graph",
resModel: "foo",
noContentHelp: '<p class="abc">This helper should not be displayed in graph views</p>',
views: [[false, "search"]],
});
assert.containsOnce(graph, "div.o_graph_canvas_container canvas");
assert.containsNone(graph, "div.o_view_nocontent");
assert.containsNone(graph, ".abc");
await toggleFilterMenu(graph);
await toggleMenuItem(graph, "False Domain");
assert.containsOnce(graph, "div.o_graph_canvas_container canvas");
assert.containsNone(graph, "div.o_view_nocontent");
assert.containsNone(graph, ".abc");
});
QUnit.test("no content helper after update", async function (assert) {
assert.expect(6);
const graph = await makeView({
serverData,
type: "graph",
resModel: "foo",
noContentHelp: '<p class="abc">This helper should not be displayed in graph views</p>',
views: [[false, "search"]],
config: {
views: [[false, "search"]],
},
});
assert.containsOnce(graph, "div.o_graph_canvas_container canvas");
assert.containsNone(graph, "div.o_view_nocontent");
@@ -2719,7 +2703,9 @@ QUnit.module("Views", (hooks) => {
resModel: "foo",
groupBy: ["date:year", "product_id", "date", "date:quarter"],
arch: `<graph type="line"/>`,
views: [[false, "search"]],
config: {
views: [[false, "search"]],
},
});
checkLabels(assert, graph, ["January 2016", "March 2016", "May 2016", "April 2016"]);
// mockReadGroup does not always sort groups -> May 2016 is before April 2016 for that reason.
@@ -2748,7 +2734,9 @@ QUnit.module("Views", (hooks) => {
serverData,
type: "graph",
resModel: "foo",
displayName: "Glou glou",
config: {
displayName: "Glou glou",
},
});
assert.strictEqual(
graph.el.querySelector(".o_control_panel .breadcrumb-item.active").innerText,
@@ -2851,10 +2839,12 @@ QUnit.module("Views", (hooks) => {
<field name="bar"/>
</graph>
`,
views: [
[364, "list"],
[29, "form"],
],
config: {
views: [
[364, "list"],
[29, "form"],
],
},
});
checkModeIs(assert, graph, "pie");
checkDatasets(assert, graph, ["domains"], {
@@ -3093,7 +3083,7 @@ QUnit.module("Views", (hooks) => {
type: "graph",
resModel: "foo",
arch: `
<graph>
<graph>
<field name="date"/>
<field name="product_id"/>
</graph>
+18 -12
View File
@@ -1,9 +1,9 @@
/** @odoo-module **/
import { View } from "@web/views/view";
import { getFixture } from "@web/../tests/helpers/utils";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { getFixture } from "@web/../tests/helpers/utils";
import { View } from "@web/views/view";
import { _fieldsViewGet } from "../helpers/mock_server";
import { addLegacyMockEnvironment } from "../webclient/helpers";
@@ -21,20 +21,26 @@ const { mount } = owl;
/**
* @param {MakeViewParams} params
* @param {Object} [options={}]
* @param {boolean} [options.noFields] Do not add default fields
* @returns {owl.Component}
*/
export async function makeView(params) {
const serverData = params.serverData;
const mockRPC = params.mockRPC;
const legacyParams = params.legacyParams || {};
const props = Object.assign({}, params);
export const makeView = async (params, options = {}) => {
const props = { ...params };
const serverData = props.serverData;
const mockRPC = props.mockRPC;
const config = props.config || {};
const legacyParams = props.legacyParams || {};
delete props.serverData;
delete props.mockRPC;
delete props.legacyParams;
delete props.config;
const env = await makeTestEnv({ serverData, mockRPC });
const defaultFields = serverData.models[props.resModel].fields;
if (props.arch) {
const env = await makeTestEnv({ serverData, mockRPC, config });
if (!options.noFields && props.arch) {
const defaultFields = serverData.models[props.resModel].fields;
if (!props.fields) {
props.fields = Object.assign({}, defaultFields);
// write the field name inside the field description (as done by fields_get)
@@ -81,4 +87,4 @@ export async function makeView(params) {
const concreteView = Object.values(withSearch.__owl__.children)[0];
return concreteView;
}
};
@@ -0,0 +1,273 @@
/** @odoo-module **/
import { getFixture } 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 { makeTestEnv } from "@web/../tests/helpers/mock_env";
const { Component, hooks, mount, tags } = owl;
const { xml } = tags;
const { useSubEnv } = hooks;
const serviceRegistry = registry.category("services");
let serverData;
QUnit.module("Views", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
foo: {
fields: {
aaa: {
type: "selection",
selection: [
["a", "A"],
["b", "B"],
],
},
},
records: [],
},
},
views: {
"foo,false,search": /* xml */ `
<search>
<searchpanel>
<field name="aaa" />
</searchpanel>
</search>`,
},
};
setupControlPanelServiceRegistry();
serviceRegistry.add("dialog", dialogService);
});
QUnit.module("Layout");
QUnit.test("Simple rendering", async (assert) => {
assert.expect(5);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout viewType="'toy'" useSampleModel="true">
<div class="toy_content" />
</Layout>`;
ToyComponent.components = { Layout };
const env = await makeTestEnv({ config: {} });
const comp = await mount(ToyComponent, { target: getFixture(), env });
assert.hasClass(comp.el, "o_toy_view o_view_sample_data");
assert.containsNone(comp, ".o_control_panel");
assert.containsNone(comp, ".o_component_with_search_panel");
assert.containsNone(comp, ".o_search_panel");
assert.containsOnce(comp, ".o_content > .toy_content");
});
QUnit.test("Simple rendering: with search", async (assert) => {
assert.expect(6);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout viewType="'toy'">
<t t-set-slot="control-panel-top-right">
<div class="toy_search_bar" />
</t>
<div class="toy_content" />
</Layout>`;
ToyComponent.components = { Layout };
const root = await makeWithSearch({
serverData,
Component: ToyComponent,
resModel: "foo",
searchViewId: false,
});
assert.hasClass(root.el, "o_toy_view");
assert.doesNotHaveClass(root.el, "o_view_sample_data");
assert.containsOnce(root, ".o_control_panel .o_cp_top_right .toy_search_bar");
assert.containsOnce(root, ".o_component_with_search_panel .o_search_panel");
assert.containsNone(root, ".o_cp_searchview");
assert.containsOnce(root, ".o_content > .toy_content");
});
QUnit.test("Nested layouts", async (assert) => {
assert.expect(10);
// Component C: bottom (no control panel)
class ToyC extends Component {
setup() {
useSubEnv({
searchModel: {
display: {
controlPanel: false,
searchPanel: true,
},
},
});
}
}
ToyC.template = xml`
<Layout viewType="'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" />`;
class ToyB extends Component {
setup() {
useSubEnv({ config: { SearchPanel } });
}
}
ToyB.template = xml`
<Layout viewType="'toy_b'">
<t t-set-slot="control-panel-top-right">
<div class="toy_b_breadcrumbs" />
</t>
<ToyC />
</Layout>`;
ToyB.components = { Layout, ToyC };
// Component A: top
class ToyA extends Component {}
ToyA.template = xml`
<Layout viewType="'toy_a'">
<t t-set-slot="control-panel-top-right">
<div class="toy_a_search" />
</t>
<ToyB />
</Layout>`;
ToyA.components = { Layout, ToyB };
const root = await makeWithSearch({
serverData,
Component: ToyA,
resModel: "foo",
searchViewId: false,
});
assert.hasClass(root.el, "o_toy_a_view");
assert.doesNotHaveClass(root.el, "o_view_sample_data");
assert.containsOnce(root, ".o_content .o_toy_b_view .o_content .o_toy_c_view .o_content"); // Full chain of contents
assert.containsN(root, ".o_control_panel", 2); // Component C has hidden its control panel
assert.containsN(root, ".o_content.o_component_with_search_panel", 3);
assert.containsOnce(root, ".o_search_panel"); // Standard search panel
assert.containsN(root, ".o_toy_search_panel", 2); // Custom search panels
assert.containsOnce(root, ".toy_a_search");
assert.containsOnce(root, ".toy_b_breadcrumbs");
assert.containsOnce(root, ".toy_c_content");
});
QUnit.test("Custom control panel", async (assert) => {
assert.expect(3);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
<div class="o_toy_content" />
</Layout>`;
ToyComponent.components = { Layout };
class ControlPanel extends Component {}
ControlPanel.template = xml`<div class="o_toy_search_panel" />`;
const root = await makeWithSearch({
serverData,
Component: ToyComponent,
resModel: "foo",
searchViewId: false,
config: { ControlPanel },
});
assert.containsOnce(root, ".o_toy_content");
assert.containsOnce(root, ".o_toy_search_panel");
assert.containsNone(root, ".o_control_panel");
});
QUnit.test("Custom search panel", async (assert) => {
assert.expect(3);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
<div class="o_toy_content" />
</Layout>`;
ToyComponent.components = { Layout };
class SearchPanel extends Component {}
SearchPanel.template = xml`<div class="o_toy_search_panel" />`;
const root = await makeWithSearch({
serverData,
Component: ToyComponent,
resModel: "foo",
searchViewId: false,
config: { SearchPanel },
});
assert.containsOnce(root, ".o_toy_content");
assert.containsOnce(root, ".o_toy_search_panel");
assert.containsNone(root, ".o_search_panel");
});
QUnit.test("Custom banner: no bannerRoute in env", async (assert) => {
assert.expect(2);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
<div class="o_toy_content" />
</Layout>`;
ToyComponent.components = { Layout };
class Banner extends Component {}
Banner.template = xml`<div class="o_toy_banner" />`;
const root = await makeWithSearch({
serverData,
Component: ToyComponent,
resModel: "foo",
searchViewId: false,
config: { Banner },
});
assert.containsOnce(root, ".o_toy_content");
assert.containsNone(root, ".o_toy_banner");
});
QUnit.test("Custom banner: with bannerRoute in env", async (assert) => {
assert.expect(2);
class ToyComponent extends Component {}
ToyComponent.template = xml`
<Layout>
<div class="o_toy_content" />
</Layout>`;
ToyComponent.components = { Layout };
class Banner extends Component {}
Banner.template = xml`<div class="o_toy_banner" />`;
const root = await makeWithSearch({
serverData,
Component: ToyComponent,
resModel: "foo",
searchViewId: false,
config: { Banner, bannerRoute: "toy/banner/route" },
});
assert.containsOnce(root, ".o_toy_content");
assert.containsOnce(root, ".o_toy_banner");
});
});
@@ -6,19 +6,21 @@ import { session } from "@web/session";
import { makeFakeLocalizationService, makeFakeUserService } from "../helpers/mock_services";
import {
click,
nextTick,
legacyExtraNextTick,
makeDeferred,
mockDownload,
nextTick,
patchDate,
patchWithCleanup,
triggerEvent,
triggerEvents,
} from "../helpers/utils";
import { makeView } from "./helpers";
import {
applyGroup,
editFavoriteName,
removeFacet,
saveFavorite,
selectGroup,
setupControlPanelFavoriteMenuRegistry,
setupControlPanelServiceRegistry,
toggleAddCustomGroup,
@@ -26,15 +28,13 @@ import {
toggleFavoriteMenu,
toggleFilterMenu,
toggleGroupByMenu,
toggleMenu,
toggleMenuItem,
toggleMenuItemOption,
toggleSaveFavorite,
saveFavorite,
selectGroup,
removeFacet,
toggleMenu,
} from "../search/helpers";
import { createWebClient, doAction } from "../webclient/helpers";
import { makeView } from "./helpers";
const serviceRegistry = registry.category("services");
@@ -438,12 +438,14 @@ QUnit.module("Views", (hooks) => {
<field name="foo" type="measure"/>
</pivot>`,
context: { someKey: true, search_default_test: 3 },
views: [
[2, "form"],
[5, "kanban"],
[false, "list"],
[false, "pivot"],
],
config: {
views: [
[2, "form"],
[5, "kanban"],
[false, "list"],
[false, "pivot"],
],
},
});
assert.hasClass(pivot.el.querySelector("table"), "o_enable_linking");
@@ -4689,7 +4691,9 @@ QUnit.module("Views", (hooks) => {
serverData,
context: { search_default_small_than_0: true },
noContentHelp: '<p class="abc">click to add a foo</p>',
views: [[false, "search"]],
config: {
views: [[false, "search"]],
},
});
assert.containsOnce(pivot, ".o_view_nocontent .abc");
@@ -4720,7 +4724,9 @@ QUnit.module("Views", (hooks) => {
serverData,
context: { search_default_small_than_0: true },
noContentHelp: '<p class="abc">click to add a foo</p>',
views: [[false, "search"]],
config: {
views: [[false, "search"]],
},
});
assert.hasClass(pivot.el, "o_view_sample_data");
@@ -4753,7 +4759,9 @@ QUnit.module("Views", (hooks) => {
resModel: "partner",
serverData,
noContentHelp: '<p class="abc">click to add a foo</p>',
views: [[false, "search"]],
config: {
views: [[false, "search"]],
},
});
assert.doesNotHaveClass(pivot.el, "o_view_sample_data");
+72 -62
View File
@@ -1,14 +1,18 @@
/** @odoo-module **/
import { getFixture, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { ormService } from "@web/core/orm_service";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import {
click,
getFixture,
makeDeferred,
nextTick,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
import { makeView } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
import { View } from "@web/views/view";
import { OnboardingBanner } from "@web/views/onboarding_banner";
import { viewService } from "@web/views/view_service";
import { click, makeDeferred } from "../helpers/utils";
import { View } from "@web/views/view";
import { actionService } from "@web/webclient/actions/action_service";
const { Component, mount, hooks, tags } = owl;
@@ -18,28 +22,6 @@ const { xml } = tags;
const serviceRegistry = registry.category("services");
const viewRegistry = registry.category("views");
async function makeView(params) {
const serverData = params.serverData;
const mockRPC = params.mockRPC;
const env = await makeTestEnv({ serverData, mockRPC });
// we don't want "fields" to be added here !!!
const props = Object.assign({}, params);
delete props.serverData;
delete props.mockRPC;
const target = getFixture();
const view = await mount(View, { env, props, target });
registerCleanup(() => view.destroy());
const withSearch = Object.values(view.__owl__.children)[0];
const concreteView = Object.values(withSearch.__owl__.children)[0];
return concreteView;
}
let serverData;
QUnit.module("Views", (hooks) => {
@@ -55,6 +37,7 @@ QUnit.module("Views", (hooks) => {
selection: [
["omnivorous", "Omnivorous"],
["herbivorous", "Herbivorous"],
["carnivorous", "Carnivorous"],
],
store: true,
@@ -109,8 +92,7 @@ QUnit.module("Views", (hooks) => {
viewRegistry.add("toy", ToyView);
viewRegistry.add("toy_imp", ToyViewImp);
serviceRegistry.add("orm", ormService);
serviceRegistry.add("view", viewService);
setupControlPanelServiceRegistry();
const fakeActionService = {
name: "action",
@@ -140,7 +122,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, serverData.views["animal,false,toy"]);
assert.deepEqual(fields, serverData.models.animal.fields);
assert.strictEqual(info.actionMenus, undefined);
assert.strictEqual(info.viewId, false);
assert.strictEqual(this.env.config.viewId, false);
},
});
@@ -174,7 +156,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, serverData.views["animal,1,toy"]);
assert.deepEqual(fields, serverData.models.animal.fields);
assert.strictEqual(info.actionMenus, undefined);
assert.strictEqual(info.viewId, 1);
assert.strictEqual(this.env.config.viewId, 1);
},
});
@@ -207,13 +189,14 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, serverData.views["animal,1,toy"]);
assert.deepEqual(fields, serverData.models.animal.fields);
assert.strictEqual(info.actionMenus, undefined);
assert.strictEqual(info.viewId, 1);
assert.strictEqual(this.env.config.viewId, 1);
},
});
const view = await makeView({
serverData,
mockRPC: (_, args) => {
console.log(_);
assert.deepEqual(args.kwargs.views, [[1, "toy"]]);
assert.deepEqual(args.kwargs.options, {
action_id: false,
@@ -223,7 +206,9 @@ QUnit.module("Views", (hooks) => {
},
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.hasClass(view.el, "o_toy_view");
assert.strictEqual(view.el.innerHTML, serverData.views["animal,1,toy"]);
@@ -242,7 +227,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, serverData.views["animal,false,toy"]);
assert.deepEqual(fields, serverData.models.animal.fields);
assert.strictEqual(info.actionMenus, undefined);
assert.strictEqual(info.viewId, false);
assert.strictEqual(this.env.config.viewId, false);
},
});
@@ -261,7 +246,9 @@ QUnit.module("Views", (hooks) => {
},
resModel: "animal",
type: "toy",
views: [[false, "other"]],
config: {
views: [[false, "other"]],
},
});
assert.hasClass(view.el, "o_toy_view");
assert.strictEqual(view.el.innerHTML, serverData.views["animal,false,toy"]);
@@ -279,7 +266,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, serverData.views["animal,1,toy"]);
assert.deepEqual(fields, serverData.models.animal.fields);
assert.strictEqual(info.actionMenus, undefined);
assert.strictEqual(info.viewId, 1);
assert.strictEqual(this.env.config.viewId, 1);
},
});
@@ -299,10 +286,12 @@ QUnit.module("Views", (hooks) => {
resModel: "animal",
type: "toy",
viewId: 1,
views: [
[3, "toy"],
[false, "other"],
],
config: {
views: [
[3, "toy"],
[false, "other"],
],
},
});
assert.hasClass(view.el, "o_toy_view");
assert.strictEqual(view.el.innerHTML, serverData.views["animal,1,toy"]);
@@ -319,7 +308,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, `<toy>Specific arch content</toy>`);
assert.deepEqual(fields, {});
assert.strictEqual(info.actionMenus, undefined);
assert.strictEqual(info.viewId, undefined);
assert.strictEqual(this.env.config.viewId, undefined);
},
});
@@ -348,7 +337,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, serverData.views["animal,false,toy"]);
assert.deepEqual(fields, serverData.models.animal.fields);
assert.deepEqual(info.actionMenus, {});
assert.strictEqual(info.viewId, false);
assert.strictEqual(this.env.config.viewId, false);
},
});
@@ -384,7 +373,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, `<toy>Specific arch content</toy>`);
assert.deepEqual(fields, {});
assert.deepEqual(info.actionMenus, {});
assert.strictEqual(info.viewId, false);
assert.strictEqual(this.env.config.viewId, false);
},
});
@@ -423,7 +412,7 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(arch, `<toy>Specific arch content</toy>`);
assert.deepEqual(fields, {});
assert.deepEqual(info.actionMenus, {});
assert.strictEqual(info.viewId, undefined);
assert.strictEqual(this.env.config.viewId, undefined);
},
});
@@ -714,7 +703,9 @@ QUnit.module("Views", (hooks) => {
mockRPC,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.containsOnce(toy, "a");
@@ -757,7 +748,9 @@ QUnit.module("Views", (hooks) => {
serverData,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.containsOnce(toy, "a");
@@ -807,7 +800,9 @@ QUnit.module("Views", (hooks) => {
serverData,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.containsOnce(toy, "a");
@@ -818,7 +813,7 @@ QUnit.module("Views", (hooks) => {
assert.expect(3);
serverData.views["animal,1,toy"] = `
<toy banner_route="/mybody/isacage">
<Banner t-if="props.bannerRoute" bannerRoute="props.bannerRoute"/>
<Banner t-if="env.config.bannerRoute" />
</toy>`;
const mockRPC = (route) => {
@@ -833,7 +828,9 @@ QUnit.module("Views", (hooks) => {
mockRPC,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.verifySteps(["/mybody/isacage"]);
@@ -844,7 +841,7 @@ QUnit.module("Views", (hooks) => {
assert.expect(7);
serverData.views["animal,1,toy"] = `
<toy banner_route="/mybody/isacage">
<Banner t-if="props.bannerRoute" bannerRoute="props.bannerRoute"/>
<Banner t-if="env.config.bannerRoute" />
</toy>`;
const bannerArch = `
@@ -893,7 +890,9 @@ QUnit.module("Views", (hooks) => {
mockRPC,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.verifySteps(["/mybody/isacage", "js loaded", "css loaded"]);
@@ -910,7 +909,7 @@ QUnit.module("Views", (hooks) => {
serverData.views["animal,1,toy"] = `
<toy banner_route="/mybody/isacage">
<Banner t-if="props.bannerRoute" bannerRoute="props.bannerRoute"/>
<Banner t-if="env.config.bannerRoute" />
</toy>`;
const banners = [
@@ -938,7 +937,9 @@ QUnit.module("Views", (hooks) => {
mockRPC,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.verifySteps(["/mybody/isacage"]);
@@ -954,7 +955,7 @@ QUnit.module("Views", (hooks) => {
assert.expect(5);
serverData.views["animal,1,toy"] = `
<toy banner_route="/mybody/isacage">
<Banner t-if="props.bannerRoute" bannerRoute="props.bannerRoute"/>
<Banner t-if="env.config.bannerRoute" />
</toy>`;
const bannerArch = `
@@ -974,7 +975,9 @@ QUnit.module("Views", (hooks) => {
mockRPC,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
assert.verifySteps(["/mybody/isacage"]);
@@ -1010,7 +1013,7 @@ QUnit.module("Views", (hooks) => {
serverData.views["animal,1,toy"] = `
<toy banner_route="/banner_route">
<Banner t-if="props.bannerRoute" bannerRoute="props.bannerRoute"/>
<Banner t-if="env.config.bannerRoute" />
<a type="action" data-method="setTheControl" data-model="animal" />
</toy>`;
@@ -1040,7 +1043,9 @@ QUnit.module("Views", (hooks) => {
serverData,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
await click(toy.el.querySelector("a[data-method='setTheControl']"));
@@ -1054,7 +1059,7 @@ QUnit.module("Views", (hooks) => {
serverData.views["animal,1,toy"] = `
<toy banner_route="/mybody/isacage">
<Banner t-if="props.bannerRoute" bannerRoute="props.bannerRoute"/>
<Banner t-if="env.config.bannerRoute" />
</toy>`;
const bannerArch = `
@@ -1104,7 +1109,9 @@ QUnit.module("Views", (hooks) => {
mockRPC,
resModel: "animal",
type: "toy",
views: [[1, "toy"]],
config: {
views: [[1, "toy"]],
},
});
const prom = new Promise((resolve) => {
@@ -1252,7 +1259,7 @@ QUnit.module("Views", (hooks) => {
QUnit.test("'resModel' must be passed as prop", async function (assert) {
assert.expect(2);
try {
await makeView({ serverData });
await makeView({ serverData }, { noFields: true });
} catch (error) {
assert.step(error.message);
}
@@ -1272,7 +1279,10 @@ QUnit.module("Views", (hooks) => {
QUnit.test("'arch' cannot be passed as prop alone", async function (assert) {
assert.expect(2);
try {
await makeView({ serverData, resModel: "animal", type: "toy", arch: "<toy/>" });
await makeView(
{ serverData, resModel: "animal", type: "toy", arch: "<toy/>" },
{ noFields: true }
);
} catch (error) {
assert.step(error.message);
}
@@ -313,9 +313,9 @@ QUnit.module("ActionManager", (hooks) => {
class ClientAction extends Component {
setup() {
this.breadcrumbTitle = "myOwlAction";
const breadCrumbs = this.props.breadcrumbs;
assert.strictEqual(breadCrumbs.length, 1);
assert.strictEqual(breadCrumbs[0].name, "Favorite Ponies");
const { breadcrumbs } = this.env.config;
assert.strictEqual(breadcrumbs.length, 1);
assert.strictEqual(breadcrumbs[0].name, "Favorite Ponies");
}
mounted() {
this.trigger("controller-title-updated", this.breadcrumbTitle);
@@ -422,10 +422,12 @@ QUnit.module("ActionManager", (hooks) => {
title: "title",
message: "message %s <R&D>",
sticky: true,
links: [{
label: "test <R&D>",
url: '#action={action.id}&id={order.id}&model=purchase.order',
}],
links: [
{
label: "test <R&D>",
url: "#action={action.id}&id={order.id}&model=purchase.order",
},
],
},
});
const notificationSelector = ".o_notification_manager .o_notification";
@@ -460,10 +462,12 @@ QUnit.module("ActionManager", (hooks) => {
params: {
message: "message %s <R&D>",
sticky: true,
links: [{
label: "test <R&D>",
url: '#action={action.id}&id={order.id}&model=purchase.order',
}],
links: [
{
label: "test <R&D>",
url: "#action={action.id}&id={order.id}&model=purchase.order",
},
],
},
});
assert.containsOnce(
@@ -473,7 +477,8 @@ QUnit.module("ActionManager", (hooks) => {
);
notificationElement = document.body.querySelector(notificationSelector);
assert.containsNone(
notificationElement, ".o_notification_title",
notificationElement,
".o_notification_title",
"the notification should not have title"
);
});
@@ -652,7 +652,7 @@ QUnit.module("ActionManager", (hooks) => {
ToyView.searchMenuTypes = ["filter"];
ToyView.template = owl.tags.xml`
<div class="o_toy_view">
<ControlPanel t-props="props.info"/>
<ControlPanel />
</div>
`;
ToyView.type = "toy";