[REF] web: adapt ControlPanel to support new owl views

This commit contains several adaptations of the Layout, WithSearch,
ControlPanel and its children components with respect to the
conversion of kanban, list and form views in owl.

In a nutshell:
 - ActionMenus: we didn't have an implementation yet, as already
   converted views do not need it.
 - ControlPanel: allow to move the focus from the search bar to
   the view and vice-versa + slight rework of the template to
   properly handle hidden areas.
 - CustomFilterItems: adapt to formatter/parser changes.
 - Layout: hide breadcrumbs in dialogs and allow to move control
   panel buttons in the footer (in dialogs as well)
 - WithSearch: only pass search props to the underlying view +
   take its content in a default slot

Part-of: odoo/odoo#92475
Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Géry Debongnie <ged@odoo.com>
Co-authored-by: Samuel Degueldre <sad@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Simon Genin (ges) <ges@odoo.com>
Co-authored-by: Francois (fge) <fge@odoo.com>
Co-authored-by: Michael Mattiello (mcm) <mcm@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Lucas Perais (lpe) <lpe@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
Co-authored-by: luvi <luvi@odoo.com>
This commit is contained in:
Aaron Bohy
2022-06-30 09:29:44 +02:00
co-authored by Bruno Boi Géry Debongnie Samuel Degueldre Mathieu Duckerts-Antoine Simon Genin Francois Michael Mattiello Julien Mougenot Lucas Perais Jorge Pinna Puissant luvi
parent af0f323bac
commit b0f20bcd09
16 changed files with 288 additions and 93 deletions
+6 -6
View File
@@ -50,7 +50,7 @@ export class Pager extends Component {
return parts.join("-");
}
/**
* @returns {boolean} true iff there is only one page
* @returns {boolean} true if there is only one page
*/
get isSinglePage() {
return this.minimum === 1 && this.maximum === this.props.total;
@@ -68,15 +68,14 @@ export class Pager extends Component {
} else if (minimum < 0 && this.props.limit > 1) {
minimum = this.props.total - (this.props.total % this.props.limit || this.props.limit);
}
this.update(minimum, this.props.limit);
this.update(minimum, this.props.limit, true);
}
/**
* @param {string} value
* @returns {{ minimum: number, maximum: number }}
*/
parse(value) {
let [minimum, maximum] = value.trim().split(/\s*[\-\s,;]\s*/);
let [minimum, maximum] = value.trim().split(/\s*[-\s,;]\s*/);
const clamp = (value) => Math.min(Math.max(value, 1), this.props.total);
return {
minimum: clamp(parseInt(minimum, 10)) - 1,
@@ -96,10 +95,11 @@ export class Pager extends Component {
/**
* @param {number} offset
* @param {number} limit
* @param {Boolean} hasNavigated
*/
async update(offset, limit) {
async update(offset, limit, hasNavigated) {
this.state.isDisabled = true;
await this.props.onUpdate({ offset, limit });
await this.props.onUpdate({ offset, limit }, hasNavigated);
this.state.isDisabled = false;
this.state.isEditing = false;
}
@@ -0,0 +1,145 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { makeContext } from "@web/core/context";
import { session } from "@web/session";
import { registry } from "@web/core/registry";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillStart, onWillUpdateProps } = owl;
let registryActionId = 0;
/**
* Action menus (or Action/Print bar, previously called 'Sidebar')
*
* The side bar is the group of dropdown menus located on the left side of the
* control panel. Its role is to display a list of items depending on the view
* type and selected records and to execute a set of actions on active records.
* It is made out of 2 dropdown: Print and Action.
*
* @extends Component
*/
export class ActionMenus extends Component {
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
onWillStart(async () => {
this.actionItems = await this.setActionItems(this.props);
});
onWillUpdateProps(async (nextProps) => {
this.actionItems = await this.setActionItems(nextProps);
});
}
get printItems() {
const printActions = this.props.items.print || [];
return printActions.map((action) => ({
action,
description: action.name,
key: action.id,
}));
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
async setActionItems(props) {
// Callback based actions
const callbackActions = (props.items.other || []).map((action) =>
Object.assign({ key: `action-${action.description}` }, action)
);
// Action based actions
const actionActions = props.items.action || [];
const formattedActions = actionActions.map((action) => ({
action,
description: action.name,
key: action.id,
}));
// ActionMenus action registry components
const registryActions = [];
for (const { Component, getProps } of registry.category("action_menus").getAll()) {
const itemProps = await getProps(props, this.env);
if (itemProps) {
registryActions.push({
Component,
key: `registry-action-${registryActionId++}`,
props: itemProps,
});
}
}
return [...callbackActions, ...formattedActions, ...registryActions];
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
async executeAction(action) {
let activeIds = this.props.activeIds;
if (this.props.isDomainSelected) {
activeIds = await this.orm.search(this.props.resModel, this.props.domain, {
limit: session.active_ids_limit,
context: this.props.context,
});
}
const activeIdsContext = {
active_id: activeIds[0],
active_ids: activeIds,
active_model: this.props.resModel,
};
if (this.props.domain) {
// keep active_domain in context for backward compatibility
// reasons, and to allow actions to bypass the active_ids_limit
activeIdsContext.active_domain = this.props.domain;
}
const context = makeContext([this.props.context, activeIdsContext]);
return this.actionService.doAction(action.id, {
additionalContext: context,
on_close: () => this.trigger("reload"),
});
}
/**
* Handler used to determine which way must be used to execute a selected
* action: it will be either:
* - a callback (function given by the view controller);
* - an action ID (string);
* - an URL (string).
* @private
* @param {Object} item
*/
onItemSelected(item) {
if (item.callback) {
item.callback([item]);
} else if (item.action) {
this.executeAction(item.action);
} else if (item.url) {
// Event has been prevented at its source: we need to redirect manually.
browser.location = item.url;
}
}
}
ActionMenus.components = {
Dropdown,
DropdownItem,
};
ActionMenus.props = {
activeIds: { type: Array, element: [Number, String] }, // virtual IDs are strings.
context: Object,
resModel: String,
domain: { type: Array, optional: true },
isDomainSelected: { type: Boolean, optional: true },
items: {
type: Object,
shape: {
action: { type: Array, optional: true },
print: { type: Array, optional: true },
other: { type: Array, optional: true },
},
},
};
ActionMenus.template = "web.ActionMenus";
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ActionMenus" owl="1">
<div class="o_cp_action_menus">
<Dropdown t-if="printItems.length" togglerClass="'btn btn-secondary'" tooltip="'Printing options'" hotkey="'shift+u'">
<t t-set-slot="toggler">
<i class="small mr-1 fa fa-print"/>
<span class="o_dropdown_title">Print</span>
</t>
<t t-foreach="printItems" t-as="item" t-key="item.key">
<DropdownItem class="'o_menu_item'" onSelected="() => this.onItemSelected(item)">
<t t-esc="item.description"/>
</DropdownItem>
</t>
</Dropdown>
<Dropdown t-if="actionItems.length" togglerClass="'btn btn-secondary'" tooltip="'Additional actions'" hotkey="'u'">
<t t-set-slot="toggler">
<i class="small mr-1 fa fa-cog"/>
<span class="o_dropdown_title">Action</span>
</t>
<t t-foreach="actionItems" t-as="item" t-key="item.key">
<t t-if="item.Component" t-component="item.Component" t-props="item.props" />
<DropdownItem t-else="" class="'o_menu_item'" onSelected="() => this.onItemSelected(item)">
<t t-esc="item.description"/>
</DropdownItem>
</t>
</Dropdown>
</div>
</t>
</templates>
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { Pager } from "@web/core/pager/pager";
import { useService } from "@web/core/utils/hooks";
import { ComparisonMenu } from "../comparison_menu/comparison_menu";
@@ -25,6 +26,18 @@ export class ControlPanel extends Component {
: undefined;
}
/**
* @param {KeyboardEvent} ev
*/
onBottomLeftKeydown(ev) {
const hotkey = getActiveHotkey(ev);
if (hotkey === "arrowdown") {
this.env.searchModel.trigger("focus-view");
ev.preventDefault();
ev.stopPropagation();
}
}
/**
* @returns {Object}
*/
@@ -34,6 +47,7 @@ export class ControlPanel extends Component {
"top-left": true,
"top-right": true,
"bottom-left": true,
"bottom-left-buttons": true,
"bottom-right": true,
},
this.props.display || this.env.searchModel.display.controlPanel
@@ -2,46 +2,49 @@
<templates xml:space="preserve">
<t t-name="web.ControlPanel" owl="1">
<div class="o_control_panel">
<div class="o_control_panel" t-ref="root">
<div t-if="display['top']" class="o_cp_top">
<div t-if="display['top-left']" class="o_cp_top_left">
<t t-slot="control-panel-top-left">
<t t-call="web.Breadcrumbs" />
<div class="o_cp_top_left">
<t t-slot="control-panel-top-left" t-if="display['top-left']">
<t t-call="web.Breadcrumbs" t-if="!env.config.noBreadcrumbs"/>
</t>
</div>
<div t-if="display['top-right']" class="o_cp_top_right">
<t t-slot="control-panel-top-right">
<div class="o_cp_top_right">
<t t-slot="control-panel-top-right" t-if="display['top-right']">
<SearchBar/>
</t>
</div>
</div>
<div t-if="display['bottom']" class="o_cp_bottom">
<div t-if="display['bottom-left']" class="o_cp_bottom_left">
<t t-slot="control-panel-bottom-left"/>
<div class="o_cp_bottom_left" t-on-keydown="onBottomLeftKeydown">
<t t-slot="control-panel-bottom-left-buttons" t-if="display['bottom-left'] and display['bottom-left-buttons']" />
<t t-slot="control-panel-bottom-left" t-if="display['bottom-left']"/>
</div>
<div t-if="display['bottom-right']" class="o_cp_bottom_right">
<t t-slot="control-panel-bottom-right">
<div class="btn-group o_search_options position-static" role="search">
<t t-foreach="searchMenus" t-as="menu" t-key="menu.key">
<t t-component="menu.Component"/>
</t>
<div class="o_cp_bottom_right">
<t t-if="display['bottom-right']">
<t t-slot="control-panel-bottom-right">
<div class="btn-group o_search_options position-static" role="search">
<t t-foreach="searchMenus" t-as="menu" t-key="menu.key">
<t t-component="menu.Component"/>
</t>
</div>
</t>
<div class="o_cp_pager" role="search">
<Pager t-if="pagerProps and pagerProps.total > 0" t-props="pagerProps"/>
</div>
</t>
<div class="o_cp_pager" role="search">
<Pager t-if="pagerProps and pagerProps.total > 0" t-props="pagerProps"/>
</div>
<t t-if="env.config.viewSwitcherEntries.length">
<nav class="btn-group o_cp_switch_buttons">
<t t-foreach="env.config.viewSwitcherEntries" t-as="view" t-key="view.type">
<button class="btn btn-light o_switch_view "
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
t-att-data-tooltip="view.name"
t-on-click="() => this.onViewClicked(view.type)"
/>
</t>
</nav>
<t t-if="env.config.viewSwitcherEntries.length > 1">
<nav class="btn-group o_cp_switch_buttons">
<t t-foreach="env.config.viewSwitcherEntries" t-as="view" t-key="view.type">
<button class="btn btn-light o_switch_view "
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
t-att-data-tooltip="view.name"
t-on-click="() => this.onViewClicked(view.type)"
/>
</t>
</nav>
</t>
</t>
</div>
</div>
@@ -52,11 +55,7 @@
<ol class="breadcrumb">
<t t-foreach="env.config.breadcrumbs" t-as="breadcrumb" t-key="breadcrumb.jsId">
<t t-set="isPenultimate" t-value="breadcrumb_index === env.config.breadcrumbs.length - 2"/>
<li t-if="!breadcrumb_last"
class="breadcrumb-item"
t-att-class="{ o_back_button: isPenultimate}"
t-on-click.prevent="() => this.onBreadcrumbClicked(breadcrumb.jsId)"
>
<li t-if="!breadcrumb_last" class="breadcrumb-item" t-att-data-hotkey="isPenultimate and 'b'" t-att-class="{ o_back_button: isPenultimate}" t-on-click.prevent="() => this.onBreadcrumbClicked(breadcrumb.jsId)">
<a href="#">
<t t-if="breadcrumb.name" t-esc="breadcrumb.name"/>
<em t-else="" class="text-warning">Unnamed</em>
@@ -1,8 +1,8 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import { Domain } from "@web/core/domain";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
@@ -86,23 +86,23 @@ const FIELD_OPERATORS = {
],
};
const parseField = (field, value, opts = {}) => {
function parseField(field, value, opts = {}) {
if (FIELD_TYPES[field.type] === "char") {
return value;
}
const type = field.type === "id" ? "integer" : field.type;
const parse = parsers.contains(type) ? parsers.get(type) : (v) => v;
return parse(value, { field, ...opts });
};
}
const formatField = (field, value, opts = {}) => {
function formatField(field, value, opts = {}) {
if (FIELD_TYPES[field.type] === "char") {
return value;
}
const type = field.type === "id" ? "integer" : field.type;
const format = formatters.contains(type) ? formatters.get(type) : (v) => v;
return format(value, { field, ...opts });
};
return format(value, { digits: field.digits, ...opts });
}
export class CustomFilterItem extends Component {
setup() {
@@ -176,7 +176,11 @@ export class CustomFilterItem extends Component {
if (genericType === "datetime") {
condition.value[0] = condition.value[0].set({ hour: 0, minute: 0, second: 0 });
if (operator.symbol === "between") {
condition.value[1] = condition.value[1].set({ hour: 23, minute: 59, second: 59 });
condition.value[1] = condition.value[1].set({
hour: 23,
minute: 59,
second: 59,
});
}
}
break;
+8 -2
View File
@@ -9,22 +9,28 @@ const { Component } = owl;
* @param {Object} params
* @returns {Object}
*/
export const extractLayoutComponents = (params) => {
export function 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);
if (display.controlPanel && this.env.inDialog) {
display.controlPanel["top-left"] = false;
display.controlPanel["bottom-left-buttons"] = false;
}
this.display = display;
}
get controlPanelSlots() {
const slots = { ...this.props.slots };
slots["control-panel-bottom-left-buttons"] = slots["layout-buttons"];
delete slots["layout-buttons"];
delete slots.default;
return slots;
}
+3
View File
@@ -2,6 +2,9 @@
<templates xml:space="preserve">
<t t-name="web.Layout" owl="1">
<t t-if="env.inDialog" t-portal="'#' + env.dialogId + ' .modal-footer'">
<t t-slot="layout-buttons"/>
</t>
<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"/>
+2 -2
View File
@@ -23,7 +23,7 @@ const { useEnv, useChildSubEnv, useState, onWillRender } = owl;
*/
export function usePager(getProps) {
const env = useEnv();
const pagerState = useState(getProps());
const pagerState = useState({});
useChildSubEnv({
config: {
@@ -32,6 +32,6 @@ export function usePager(getProps) {
},
});
onWillRender(() => {
Object.assign(pagerState, getProps());
Object.assign(pagerState, getProps() || { total: 0 });
});
}
@@ -17,13 +17,13 @@ const DEFAULT_VIEWS_WITH_SEARCH_PANEL = ["kanban", "list"];
* @param {string} context
* @returns {string[]}
*/
const getContextGroubBy = (context) => {
function getContextGroubBy(context) {
try {
return makeContext([context]).group_by.split(":");
} catch (_err) {
return [];
}
};
}
function reduceType(type) {
if (type === "dateFilter") {
@@ -37,7 +37,7 @@ export class SearchBar extends Component {
this.inputRef = useAutofocus();
useBus(this.env.bus, "FOCUS-SEARCH-BAR", () => {
useBus(this.env.searchModel, "focus-search", () => {
this.inputRef.el.focus();
});
@@ -345,7 +345,7 @@ export class SearchBar extends Component {
focusedIndex = this.state.focusedIndex + 1;
}
} else {
this.env.bus.trigger("FOCUS-VIEW");
this.env.searchModel.trigger("focus-view");
}
break;
case "ArrowUp":
+10 -7
View File
@@ -266,8 +266,10 @@ export class SearchModel extends EventBus {
this.__legacyParseSearchPanelArchAnyway(searchViewDescription, searchViewFields);
this.domainParts = {};
this.display = this._getDisplay(config.display);
return this._reloadSections();
if (!this.searchPanelInfo.loaded) {
return this._reloadSections();
}
return;
}
this.blockNotification = true;
@@ -310,7 +312,7 @@ export class SearchModel extends EventBus {
);
const { labels, preSearchItems, searchPanelInfo, sections } = parser.parse();
this.searchPanelInfo = { ...searchPanelInfo, shouldReload: false };
this.searchPanelInfo = { ...searchPanelInfo, loaded: false, shouldReload: false };
await Promise.all(labels.map((cb) => cb(this.orm)));
@@ -1284,6 +1286,7 @@ export class SearchModel extends EventBus {
async _fetchSections(categoriesToLoad, filtersToLoad) {
await this._fetchCategories(categoriesToLoad);
await this._fetchFilters(filtersToLoad);
this.searchPanelInfo.loaded = true;
}
_getActiveComparison() {
@@ -1423,9 +1426,9 @@ export class SearchModel extends EventBus {
let domain;
try {
domain = Domain.and(domains);
return params.raw ? domain : domain.toList(
Object.assign({}, this.globalContext, this.userService.context)
);
return params.raw
? domain
: domain.toList(Object.assign({}, this.globalContext, this.userService.context));
} catch (error) {
throw new Error(
`${this.env._t("Failed to evaluate the domain")} ${domain.toString()}.\n${
@@ -2094,6 +2097,6 @@ export class SearchModel extends EventBus {
const parser = new SearchArchParser(searchViewDescription, searchViewFields);
const { searchPanelInfo } = parser.parse();
this.searchPanelInfo = { ...searchPanelInfo, shouldReload: false };
this.searchPanelInfo = { ...searchPanelInfo, loaded: false, shouldReload: false };
}
}
@@ -14,5 +14,6 @@ export const GROUPABLE_TYPES = [
"datetime",
"integer",
"many2one",
"many2many",
"selection",
];
@@ -7,12 +7,9 @@ import { CallbackRecorder, useSetupAction } from "@web/webclient/actions/action_
const { Component, onWillStart, onWillUpdateProps, toRaw, useSubEnv } = owl;
export const SEARCH_KEYS = ["comparison", "context", "domain", "groupBy", "orderBy"];
const OTHER_SEARCH_KEYS = ["irFilters", "searchViewArch", "searchViewFields", "searchViewId"];
export class WithSearch extends Component {
setup() {
this.Component = this.props.Component;
if (!this.env.__getContext__) {
useSubEnv({ __getContext__: new CallbackRecorder() });
}
@@ -54,31 +51,12 @@ export class WithSearch extends Component {
await this.searchModel.reload(config);
});
}
//-------------------------------------------------------------------------
// Getters
//-------------------------------------------------------------------------
get componentProps() {
const componentProps = { ...this.props.componentProps };
for (const key of SEARCH_KEYS) {
componentProps[key] = this.searchModel[key];
}
componentProps.info = componentProps.info || {};
for (const key of OTHER_SEARCH_KEYS) {
componentProps.info[key] = this.searchModel[key];
}
return componentProps;
}
}
WithSearch.defaultProps = {
componentProps: {},
};
WithSearch.template = "web.WithSearch";
WithSearch.props = {
Component: Function,
slots: Object,
SearchModel: { type: Function, optional: true },
componentProps: { type: Object, optional: true },
resModel: String,
@@ -107,4 +85,3 @@ WithSearch.props = {
dynamicFilters: { type: Array, element: Object, optional: true },
searchMenuTypes: { type: Array, element: String, optional: true },
};
WithSearch.template = "web.WithSearch";
@@ -2,7 +2,12 @@
<templates xml:space="preserve">
<t t-name="web.WithSearch" owl="1">
<t t-component="Component" t-props="componentProps" />
<t t-slot="default"
context="searchModel.context"
domain="searchModel.domain"
groupBy="searchModel.groupBy"
orderBy="searchModel.orderBy"
comparison="searchModel.comparison"/>
</t>
</templates>
@@ -322,18 +322,23 @@ QUnit.module("Search", (hooks) => {
class Parent extends Component {
setup() {
this.state = useState({
this.searchState = useState({
resModel: "animal",
Component: TestComponent,
domain: [["type", "=", "carnivorous"]],
});
}
}
Parent.template = xml`<WithSearch t-props="state"/>`;
Parent.components = { WithSearch };
Parent.template = xml`
<WithSearch t-props="searchState" t-slot-scope="search">
<TestComponent
domain="search.domain"
/>
</WithSearch>
`;
Parent.components = { WithSearch, TestComponent };
const parent = await mount(Parent, target, { env });
parent.state.domain = [["type", "=", "herbivorous"]];
parent.searchState.domain = [["type", "=", "herbivorous"]];
await nextTick();
});