[MERGE] web, *: kanban, list and form views in owl

This PR introduces the new kanban, list and form views written in owl.
Even though it contains the implementation of those 3 views, only the
kanban view is activated for now (the list and form views aren't 100%
ready yet, so they aren't added to the view registry).

Alongside the views, the fields (`<field name="..."/>` in archs) and
widgets (`<widget name="..."` in archs) of web/ have been implemented
in owl as well. Legacy ones remaining in other addons are supported
in our new views, thanks to a compatibility layer. The goal is to convert
them asap though. A few fields and widgets from other addons have
already been converted in this PR.

Legacy views, fields and widgets are kept for now, which explains the
number of added lines in this PR (around half of them concern tests).
They are still extended by custom code in other addons, that still need
to be converted (a lot of them are already on the way). Moreover,
they are still used in Studio as well. The plan is to lazy load them in
the Studio bundle when all custom code extending them will be
converted. Studio will be converted for v17.

closes odoo/odoo#92475

Related: odoo/enterprise#27879
Signed-off-by: Géry Debongnie <ged@odoo.com>
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:
Odoo's Mergebot
2022-06-30 09:29:51 +02:00
committed by GitHub
co-authored by Aaron Bohy Bruno Boi Géry Debongnie Samuel Degueldre Mathieu Duckerts-Antoine Simon Genin Francois Michael Mattiello Julien Mougenot Lucas Perais Jorge Pinna Puissant luvi
603 changed files with 109761 additions and 5104 deletions
@@ -1,4 +1,4 @@
.o_kanban_view.o_kanban_dashboard.o_account_kanban {
.o_legacy_kanban_view.o_kanban_dashboard.o_account_kanban {
&.o_kanban_ungrouped .o_account_dashboard_header {
margin: (0 - $o-kanban-record-margin) ($o-kanban-record-margin - $o-horizontal-padding) $o-kanban-record-margin;
@@ -1,4 +1,4 @@
.o_kanban_view.o_kanban_dashboard.o_account_kanban {
.o_kanban_renderer.o_kanban_dashboard.o_account_kanban {
.o_kanban_record {
@include media-breakpoint-up(sm) {

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 306 B

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 313 B

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 313 B

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 304 B

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -36,11 +36,18 @@ export class AddToBoard extends Component {
async addToBoard() {
const { domain, globalContext } = this.env.searchModel;
const { context } = this.env.searchModel.getIrFilterValues();
const {
comparison,
context,
groupBys,
orderBy,
} = this.env.searchModel.getPreFavoriteValues();
const contextToSave = {
...globalContext,
...context,
orderedBy: this.env.searchModel.orderBy,
comparison,
orderedBy: orderBy,
group_by: groupBys,
dashboard_merge_domains_contexts: false,
};
@@ -1,11 +1,11 @@
odoo.define('board.AddToBoardMenu', function (require) {
odoo.define("board.AddToBoardMenu", function (require) {
"use strict";
const Context = require('web.Context');
const Domain = require('web.Domain');
const { Dropdown } = require('@web/core/dropdown/dropdown');
const FavoriteMenu = require('web.FavoriteMenu');
const { sprintf } = require('web.utils');
const Context = require("web.Context");
const Domain = require("web.Domain");
const { Dropdown } = require("@web/core/dropdown/dropdown");
const FavoriteMenu = require("web.FavoriteMenu");
const { sprintf } = require("web.utils");
const { useAutofocus } = require("@web/core/utils/hooks");
const { Component, useState } = owl;
@@ -44,20 +44,23 @@ odoo.define('board.AddToBoardMenu', function (require) {
* @private
*/
async addToBoard() {
const searchQuery = this.env.searchModel.get('query');
const searchQuery = this.env.searchModel.get("query");
const context = new Context(this.env.action.context);
context.add(searchQuery.context);
context.add({
group_by: searchQuery.groupBy,
orderedBy: searchQuery.orderedBy,
});
if (searchQuery.timeRanges && searchQuery.timeRanges.hasOwnProperty('fieldName')) {
if (
searchQuery.timeRanges &&
Object.prototype.hasOwnProperty.call(searchQuery.timeRanges, "fieldName")
) {
context.add({
comparison: searchQuery.timeRanges,
});
}
let controllerQueryParams;
this.env.searchModel.trigger('get-controller-query-params', params => {
this.env.searchModel.trigger("get-controller-query-params", (params) => {
controllerQueryParams = params || {};
});
controllerQueryParams.context = controllerQueryParams.context || {};
@@ -66,11 +69,16 @@ odoo.define('board.AddToBoardMenu', function (require) {
context.add(Object.assign(controllerQueryParams, queryContext));
const domainArray = new Domain(this.env.action.domain || []);
const domain = Domain.prototype.normalizeArray(domainArray.toArray().concat(searchQuery.domain));
const domain = Domain.prototype.normalizeArray(
domainArray.toArray().concat(searchQuery.domain)
);
const evalutatedContext = context.eval();
for (const key in evalutatedContext) {
if (evalutatedContext.hasOwnProperty(key) && /^search_default_/.test(key)) {
if (
Object.prototype.hasOwnProperty.call(evalutatedContext, key) &&
/^search_default_/.test(key)
) {
delete evalutatedContext[key];
}
}
@@ -82,7 +90,7 @@ odoo.define('board.AddToBoardMenu', function (require) {
});
const result = await this.env.services.rpc({
route: '/board/add_to_dashboard',
route: "/board/add_to_dashboard",
params: {
action_id: this.env.action.id || false,
context_to_save: evalutatedContext,
@@ -94,13 +102,15 @@ odoo.define('board.AddToBoardMenu', function (require) {
if (result) {
this.env.services.notification.notify({
title: sprintf(this.env._t("'%s' added to dashboard"), this.state.name),
message: this.env._t("Please refresh your browser for the changes to take effect."),
type: 'warning',
message: this.env._t(
"Please refresh your browser for the changes to take effect."
),
type: "warning",
});
} else {
this.env.services.notification.notify({
message: this.env._t("Could not add filter to dashboard"),
type: 'danger',
type: "danger",
});
}
}
@@ -115,11 +125,11 @@ odoo.define('board.AddToBoardMenu', function (require) {
*/
onInputKeydown(ev) {
switch (ev.key) {
case 'Enter':
case "Enter":
ev.preventDefault();
this.addToBoard();
break;
case 'Escape':
case "Escape":
// Gives the focus back to the component.
ev.preventDefault();
ev.target.blur();
@@ -136,15 +146,15 @@ odoo.define('board.AddToBoardMenu', function (require) {
* @returns {boolean}
*/
static shouldBeDisplayed(env) {
return env.action.type === 'ir.actions.act_window';
return env.action.type === "ir.actions.act_window";
}
}
AddToBoardMenu.props = {};
AddToBoardMenu.template = 'board.AddToBoard';
AddToBoardMenu.template = "board.AddToBoard";
AddToBoardMenu.components = { Dropdown };
FavoriteMenu.registry.add('add-to-board-menu', AddToBoardMenu, 10);
FavoriteMenu.registry.add("add-to-board-menu", AddToBoardMenu, 10);
return AddToBoardMenu;
});
+68
View File
@@ -0,0 +1,68 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { View } from "@web/views/view";
import { makeContext } from "@web/core/context";
const { Component, onWillStart } = owl;
export class BoardAction extends Component {
setup() {
const rpc = useService("rpc");
const userService = useService("user");
this.actionService = useService("action");
const action = this.props.action;
this.formViewId = false;
this.isValid = true;
onWillStart(async () => {
let result = BoardAction.cache[action.actionId];
if (!result) {
result = await rpc("/web/action/load", { action_id: action.actionId });
BoardAction.cache[action.actionId] = result;
}
if (!result) {
// action does not exist
this.isValid = false;
return;
}
const viewMode = action.viewMode || result.views[0][1];
const formView = result.views.find((v) => v[1] === "form");
if (formView) {
this.formViewId = formView[0];
}
this.viewProps = {
resModel: result.res_model,
type: viewMode,
display: { controlPanel: false },
selectRecord: (resId) => this.selectRecord(result.res_model, resId),
};
if (action.context) {
this.viewProps.context = makeContext([
action.context,
{ lang: userService.context.lang },
]);
if ("group_by" in this.viewProps.context) {
const groupBy = this.viewProps.context.group_by;
this.viewProps.groupBy = typeof groupBy === "string" ? [groupBy] : groupBy;
}
}
if (action.domain) {
this.viewProps.domain = action.domain;
}
if (viewMode === "list") {
this.viewProps.hasSelectors = false;
}
});
}
selectRecord(resModel, resId) {
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: resModel,
views: [[this.formViewId, "form"]],
res_id: resId,
});
}
}
BoardAction.template = "board.BoardAction";
BoardAction.components = { View };
BoardAction.cache = {};
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="board.BoardAction" owl="1">
<t t-if="isValid">
<View t-props="viewProps"/>
</t>
<t t-else="">
<div class="text-center text-warning m-1">Invalid action</div>
</t>
</t>
</templates>
+118
View File
@@ -0,0 +1,118 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useService } from "@web/core/utils/hooks";
import { renderToString } from "@web/core/utils/render";
import { useSortable } from "@web/core/utils/sortable";
import { standardViewProps } from "@web/views/standard_view_props";
import { BoardAction } from "./board_action";
const { Component, useState, useRef } = owl;
export class BoardController extends Component {
setup() {
this.board = useState(this.props.board);
this.rpc = useService("rpc");
this.dialogService = useService("dialog");
const mainRef = useRef("main");
useSortable({
ref: mainRef,
elements: ".o-dashboard-action",
handle: ".o-dashboard-action-header",
cursor: "move",
groups: ".o-dashboard-column",
connectGroups: true,
onDrop: ({ element, previous, parent }) => {
const fromColIdx = parseInt(element.parentElement.dataset.idx, 10);
const fromActionIdx = parseInt(element.dataset.idx, 10);
const toColIdx = parseInt(parent.dataset.idx, 10);
const toActionIdx = previous ? parseInt(previous.dataset.idx, 10) + 1 : 0;
if (fromColIdx !== toColIdx) {
// to reduce visual flickering
element.classList.add("d-none");
}
this.moveAction(fromColIdx, fromActionIdx, toColIdx, toActionIdx);
},
});
}
moveAction(fromColIdx, fromActionIdx, toColIdx, toActionIdx) {
const action = this.board.columns[fromColIdx].actions[fromActionIdx];
if (fromColIdx !== toColIdx) {
// action moving from a column to another
this.board.columns[fromColIdx].actions.splice(fromActionIdx, 1);
this.board.columns[toColIdx].actions.splice(toActionIdx, 0, action);
} else {
// move inside a column
if (fromActionIdx === toActionIdx) {
return;
}
const actions = this.board.columns[fromColIdx].actions;
if (fromActionIdx < toActionIdx) {
actions.splice(toActionIdx + 1, 0, action);
actions.splice(fromActionIdx, 1);
} else {
actions.splice(fromActionIdx, 1);
actions.splice(toActionIdx, 0, action);
}
}
this.saveBoard();
}
selectLayout(layout) {
const currentColNbr = this.board.colNumber;
const nextColNbr = layout.split("-").length;
if (nextColNbr < currentColNbr) {
// need to move all actions in last cols in the last visible col
const cols = this.board.columns;
const lastVisibleCol = cols[nextColNbr - 1];
for (let i = nextColNbr; i < currentColNbr; i++) {
lastVisibleCol.actions.push(...cols[i].actions);
cols[i].actions = [];
}
}
this.board.layout = layout;
this.board.colNumber = nextColNbr;
this.saveBoard();
if (document.querySelector("canvas")) {
// horrible hack to force charts to be recreated so they pick up the
// proper size. also, no idea why raf is needed :(
browser.requestAnimationFrame(() => this.render(true));
}
}
closeAction(column, action) {
this.dialogService.add(ConfirmationDialog, {
body: this.env._t("Are you sure that you want to remove this item?"),
confirm: () => {
const index = column.actions.indexOf(action);
column.actions.splice(index, 1);
this.saveBoard();
},
cancel: () => {},
});
}
toggleAction(action) {
action.isFolded = !action.isFolded;
this.saveBoard();
}
saveBoard() {
this.rpc("/web/view/edit_custom", {
custom_id: this.board.customViewId,
arch: renderToString("board.arch", this.board),
});
this.env.bus.trigger("CLEAR-CACHES");
}
}
BoardController.template = "board.BoardView";
BoardController.components = { BoardAction, Dropdown, DropdownItem };
BoardController.props = {
...standardViewProps,
board: Object,
};
@@ -0,0 +1,46 @@
.o_dashboard {
height: 100%;
overflow: auto;
}
.o-dashboard-layout {
display: grid;
}
.o-dashboard-layout-1 {
grid-template-columns: 1fr;
}
.o-dashboard-layout-1-1 {
grid-template-columns: 1fr 1fr;
}
.o-dashboard-layout-1-1-1 {
grid-template-columns: 1fr 1fr 1fr;
}
.o-dashboard-layout-2-1 {
grid-template-columns: 2fr 1fr;
}
.o-dashboard-layout-1-2 {
grid-template-columns: 1fr 2fr;
}
.o-dashboard-column {
overflow-x: scroll;
}
.o-dashboard-action {
background-color: white;
align-self: start;
border: 1px solid;
border-color: #e5e5e5 #dbdbdb #d2d2d2;
> .o_view_controller {
padding: 0 12px 12px 12px;
}
.o_column_quick_create {
display: none;
}
}
.o-dashboard-action > h3 > span > i {
font-size: 12px;
}
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="board.BoardView" owl="1">
<div class="o_dashboard d-flex flex-column">
<t t-if="board.isEmpty">
<t t-call="board.NoContent"/>
</t>
<t t-else="">
<t t-call="board.Content"/>
</t>
</div>
</t>
<t t-name="board.Content" owl="1">
<!-- Note: need to add mobile support that i just removed for now -->
<div class="o-dashboard-header d-flex justify-content-end">
<Dropdown togglerClass="'btn btn-secondary m-2 p-2'">
<t t-set-slot="toggler">
<img t-attf-src="/board/static/img/layout_{{board.layout}}.png" width="16" height="16" alt=""/>
Change Layout
</t>
<t t-foreach="'1 1-1 1-1-1 1-2 2-1'.split(' ')" t-as="layout" t-key="layout">
<DropdownItem onSelected="() => this.selectLayout(layout)">
<img t-attf-src="/board/static/img/layout_{{layout}}.png" alt=""/>
<i t-if="layout == board.layout" class="fa fa-check fa-lg text-success" aria-label='Layout' role="img" title="Layout"/>
</DropdownItem>
</t>
</Dropdown>
</div>
<div class="o-dashboard-layout flex-grow-1" t-attf-class="o-dashboard-layout-{{board.layout}}" t-ref="main">
<t t-foreach="board.columns" t-as="column" t-key="column_index">
<div t-if="column_index lt board.colNumber" class="o-dashboard-column h-100" t-att-data-idx="column_index">
<t t-foreach="column.actions" t-as="action" t-key="action.id">
<div class="o-dashboard-action mx-3 my-1" t-att-data-idx="action_index">
<h3 t-attf-class="o-dashboard-action-header {{action.title ? '' : 'oe_header_empty'}} p-2 m-2">
<span> <t t-esc="action.title"/> </span>
<span class="btn float-right p-1 text-muted" t-on-click="() => this.closeAction(column, action)"><i class="fa fa-close"/></span>
<span class="btn float-right p-1 text-muted" t-if="!action.isFolded" t-on-click="() => this.toggleAction(action)"><i class="fa fa-window-minimize"/></span>
<span class="btn float-right p-1 text-muted" t-if="action.isFolded" t-on-click="() => this.toggleAction(action)"><i class="fa fa-window-maximize"/></span>
</h3>
<BoardAction t-if="!action.isFolded" action="action" />
</div>
</t>
</div>
</t>
</div>
</t>
<t t-name="board.NoContent" owl="1">
<div class="o_view_nocontent">
<div class="o_nocontent_help">
<p class="o_view_nocontent_neutral_face">
Your personal dashboard is empty
</p>
<p>
To add your first report into this dashboard, go to any
menu, switch to list or graph view, and click <i>"Add to
Dashboard"</i> in the extended search options.
</p>
<p>
You can filter and group data before inserting into the
dashboard using the search options.
</p>
</div>
</div>
</t>
<t t-name="board.arch" owl="1">
<form t-att-string="title">
<board t-att-style="layout">
<column t-foreach="columns" t-as="column" t-key="column_index">
<t t-foreach="column.actions" t-as="action" t-key="action_index">
<action
t-att-name="action.actionId"
t-att-string="action.title"
t-att-view_mode="action.viewMode"
t-att-context="action.context"
t-att-domain="action.domain"
t-att-fold="action.isFolded ? 1 : 0"/>
</t>
</column>
</board>
</form>
</t>
</templates>
+77
View File
@@ -0,0 +1,77 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { BoardController } from "./board_controller";
import { XMLParser } from "@web/core/utils/xml";
import { Domain } from "@web/core/domain";
export class BoardArchParser extends XMLParser {
parse(arch, customViewId) {
let nextId = 1;
const archInfo = {
title: null,
layout: null,
colNumber: 0,
isEmpty: true,
columns: [{ actions: [] }, { actions: [] }, { actions: [] }],
customViewId,
};
let currentIndex = -1;
this.visitXML(arch, (node) => {
switch (node.tagName) {
case "form":
archInfo.title = node.getAttribute("string");
break;
case "board":
archInfo.layout = node.getAttribute("style");
archInfo.colNumber = archInfo.layout.split("-").length;
break;
case "column":
currentIndex++;
break;
case "action": {
archInfo.isEmpty = false;
const isFolded = Boolean(
node.hasAttribute("fold") ? parseInt(node.getAttribute("fold"), 10) : 0
);
let action = {
id: nextId++,
title: node.getAttribute("string"),
actionId: parseInt(node.getAttribute("name"), 10),
viewMode: node.getAttribute("view_mode"),
context: node.getAttribute("context"),
isFolded,
};
if (node.hasAttribute("domain")) {
action.domain = new Domain(node.getAttribute("domain")).toList();
// so it can be serialized when reexporting board xml
action.domain.toString = () => node.getAttribute("domain");
}
archInfo.columns[currentIndex].actions.push(action);
break;
}
}
});
return archInfo;
}
}
export const boardView = {
type: "form",
display_name: _lt("Board"),
Controller: BoardController,
props: (genericProps, view) => {
const { arch, info } = genericProps;
const board = new BoardArchParser().parse(arch, info.customViewId);
return {
...genericProps,
className: "o_dashboard",
board,
};
},
};
registry.category("views").add("board", boardView);
@@ -1,482 +0,0 @@
odoo.define('board.BoardView', function (require) {
"use strict";
var Context = require('web.Context');
var config = require('web.config');
var core = require('web.core');
var dataManager = require('web.data_manager');
var Dialog = require('web.Dialog');
var Domain = require('web.Domain');
var FormController = require('web.FormController');
var FormRenderer = require('web.FormRenderer');
var FormView = require('web.FormView');
var pyUtils = require('web.py_utils');
var session = require('web.session');
var viewRegistry = require('web.view_registry');
const { loadLegacyViews } = require("@web/legacy/legacy_views");
var _t = core._t;
var _lt = core._lt;
var QWeb = core.qweb;
var BoardController = FormController.extend({
custom_events: _.extend({}, FormController.prototype.custom_events, {
change_layout: '_onChangeLayout',
enable_dashboard: '_onEnableDashboard',
save_dashboard: '_saveDashboard',
switch_view: '_onSwitchView',
}),
/**
* @override
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.customViewID = params.customViewID;
},
async willStart() {
const _super = this._super.bind(this, ...arguments);
await loadLegacyViews({ rpc: this._rpc.bind(this) });
return _super();
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
getTitle: function () {
return _t("My Dashboard");
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Actually save a dashboard
*
* @returns {Promise}
*/
_saveDashboard: function () {
var board = this.renderer.getBoard();
var arch = QWeb.render('DashBoard.xml', _.extend({}, board));
return this._rpc({
route: '/web/view/edit_custom',
params: {
custom_id: this.customViewID,
arch: arch,
}
}).then(dataManager.invalidate.bind(dataManager));
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {OdooEvent} event
*/
_onChangeLayout: function (event) {
var self = this;
var dialog = new Dialog(this, {
title: _t("Edit Layout"),
$content: QWeb.render('DashBoard.layouts', _.clone(event.data))
});
dialog.opened().then(function () {
dialog.$('li').click(function () {
var layout = $(this).attr('data-layout');
self.renderer.changeLayout(layout);
self._saveDashboard();
dialog.close();
});
});
dialog.open();
},
/**
* We need to intercept switch_view event coming from sub views, because we
* don't actually want to switch view in dashboard, we want to do a
* do_action (which will open the record in a different breadcrumb).
*
* @private
* @param {OdooEvent} event
*/
_onSwitchView: function (event) {
event.stopPropagation();
this.do_action({
type: 'ir.actions.act_window',
res_model: event.data.model,
views: [[event.data.formViewID || false, 'form']],
res_id: event.data.res_id,
});
},
});
var BoardRenderer = FormRenderer.extend({
custom_events: _.extend({}, FormRenderer.prototype.custom_events, {
update_filters: '_onUpdateFilters',
switch_view: '_onSwitchView',
}),
events: _.extend({}, FormRenderer.prototype.events, {
'click .oe_dashboard_column .oe_fold': '_onFoldClick',
'click .oe_dashboard_link_change_layout': '_onChangeLayout',
'click .oe_dashboard_column .oe_close': '_onCloseAction',
}),
/**
* @override
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
this.noContentHelp = params.noContentHelp;
this.actionsDescr = {};
this._boardSubcontrollers = []; // for board: controllers of subviews
this._boardFormViewIDs = {}; // for board: mapping subview controller to form view id
},
/**
* Call `on_attach_callback` for each subview
*
* @override
*/
on_attach_callback: function () {
_.each(this._boardSubcontrollers, function (controller) {
if ('on_attach_callback' in controller) {
controller.on_attach_callback();
}
});
},
/**
* Call `on_detach_callback` for each subview
*
* @override
*/
on_detach_callback: function () {
_.each(this._boardSubcontrollers, function (controller) {
if ('on_detach_callback' in controller) {
controller.on_detach_callback();
}
});
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {string} layout
*/
changeLayout: function (layout) {
var $dashboard = this.$('.oe_dashboard');
var current_layout = $dashboard.attr('data-layout');
if (current_layout !== layout) {
var clayout = current_layout.split('-').length,
nlayout = layout.split('-').length,
column_diff = clayout - nlayout;
if (column_diff > 0) {
var $last_column = $();
$dashboard.find('.oe_dashboard_column').each(function (k, v) {
if (k >= nlayout) {
$(v).find('.oe_action').appendTo($last_column);
} else {
$last_column = $(v);
}
});
}
$dashboard.toggleClass('oe_dashboard_layout_' + current_layout + ' oe_dashboard_layout_' + layout);
$dashboard.attr('data-layout', layout);
}
},
/**
* Returns a representation of the current dashboard
*
* @returns {Object}
*/
getBoard: function () {
var self = this;
var board = {
form_title : this.arch.attrs.string,
style : this.$('.oe_dashboard').attr('data-layout'),
columns : [],
};
this.$('.oe_dashboard_column').each(function () {
var actions = [];
$(this).find('.oe_action').each(function () {
var actionID = $(this).attr('data-id');
var newAttrs = _.clone(self.actionsDescr[actionID]);
/* prepare attributes as they should be saved */
if (newAttrs.modifiers) {
newAttrs.modifiers = JSON.stringify(newAttrs.modifiers);
}
actions.push(newAttrs);
});
board.columns.push(actions);
});
return board;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {Object} params
* @param {jQueryElement} params.$node
* @param {integer} params.actionID
* @param {Object} params.context
* @param {any[]} params.domain
* @param {string} params.viewType
* @returns {Promise}
*/
_createController: function (params) {
var self = this;
return this._rpc({
route: '/web/action/load',
params: {action_id: params.actionID}
})
.then(function (action) {
if (!action) {
// the action does not exist anymore
return Promise.resolve();
}
var evalContext = new Context(params.context).eval();
if (evalContext.group_by && evalContext.group_by.length === 0) {
delete evalContext.group_by;
}
// tz and lang are saved in the custom view
// override the language to take the current one
var rawContext = new Context(action.context, evalContext, {lang: session.user_context.lang});
var context = pyUtils.eval('context', rawContext, evalContext);
var domain = params.domain || pyUtils.eval('domain', action.domain || '[]', action.context);
action.context = context;
action.domain = domain;
// When creating a view, `action.views` is expected to be an array of dicts, while
// '/web/action/load' returns an array of arrays.
action._views = action.views;
action.views = $.map(action.views, function (view) { return {viewID: view[0], type: view[1]}});
var viewType = params.viewType || action._views[0][1];
var view = _.find(action._views, function (descr) {
return descr[1] === viewType;
}) || [false, viewType];
return self.loadViews(action.res_model, context, [view])
.then(function (viewsInfo) {
var viewInfo = viewsInfo[viewType];
var xml = new DOMParser().parseFromString(viewInfo.arch, "text/xml")
var key = xml.documentElement.getAttribute("js_class");
var View = viewRegistry.get(key || viewType);
const searchQuery = {
context: context,
domain: domain,
groupBy: typeof context.group_by === 'string' && context.group_by ?
[context.group_by] :
context.group_by || [],
orderedBy: context.orderedBy || [],
};
if (View.prototype.searchMenuTypes.includes('comparison')) {
searchQuery.timeRanges = context.comparison || {};
}
var view = new View(viewInfo, {
action: action,
hasSelectors: false,
modelName: action.res_model,
searchQuery,
withControlPanel: false,
withSearchPanel: false,
});
return view.getController(self).then(function (controller) {
self._boardFormViewIDs[controller.handle] = _.first(
_.find(action._views, function (descr) {
return descr[1] === 'form';
})
);
self._boardSubcontrollers.push(controller);
return controller.appendTo(params.$node);
});
});
});
},
/**
* @private
* @param {Object} node
* @returns {jQueryElement}
*/
_renderTagBoard: function (node) {
var self = this;
// we add the o_dashboard class to the renderer's $el. This means that
// this function has a side effect. This is ok because we assume that
// once we have a '<board>' tag, we are in a special dashboard mode.
this.$el.addClass('o_dashboard');
var hasAction = _.detect(node.children, function (column) {
return _.detect(column.children,function (element){
return element.tag === "action"? element: false;
});
});
if (!hasAction) {
return $(QWeb.render('DashBoard.NoContent'));
}
// We should start with three columns available
node = $.extend(true, {}, node);
// no idea why master works without this, but whatever
if (!('layout' in node.attrs)) {
node.attrs.layout = node.attrs.style;
}
for (var i = node.children.length; i < 3; i++) {
node.children.push({
tag: 'column',
attrs: {},
children: []
});
}
// register actions, alongside a generated unique ID
_.each(node.children, function (column, column_index) {
_.each(column.children, function (action, action_index) {
action.attrs.id = 'action_' + column_index + '_' + action_index;
self.actionsDescr[action.attrs.id] = action.attrs;
});
});
var $html = $('<div>').append($(QWeb.render('DashBoard', {node: node, isMobile: config.device.isMobile})));
this._boardSubcontrollers = []; // dashboard controllers are reset on re-render
// render each view
_.each(this.actionsDescr, function (action) {
self.defs.push(self._createController({
$node: $html.find('.oe_action[data-id=' + action.id + '] .oe_content'),
actionID: _.str.toNumber(action.name),
context: action.context,
domain: Domain.prototype.stringToArray(action.domain, {}),
viewType: action.view_mode,
}));
});
$html.find('.oe_dashboard_column').sortable({
connectWith: '.oe_dashboard_column',
handle: '.oe_header',
scroll: false
}).bind('sortstop', function () {
self.trigger_up('save_dashboard');
});
return $html;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onChangeLayout: function () {
var currentLayout = this.$('.oe_dashboard').attr('data-layout');
this.trigger_up('change_layout', {currentLayout: currentLayout});
},
/**
* @private
* @param {MouseEvent} event
*/
_onCloseAction: function (event) {
var self = this;
var $container = $(event.currentTarget).parents('.oe_action:first');
Dialog.confirm(this, (_t("Are you sure you want to remove this item?")), {
confirm_callback: function () {
$container.remove();
self.trigger_up('save_dashboard');
},
});
},
/**
* @private
* @param {MouseEvent} event
*/
_onFoldClick: function (event) {
var $e = $(event.currentTarget);
var $action = $e.closest('.oe_action');
var id = $action.data('id');
var actionAttrs = this.actionsDescr[id];
if ($e.is('.oe_minimize')) {
actionAttrs.fold = '1';
} else {
delete(actionAttrs.fold);
}
$e.toggleClass('oe_minimize oe_maximize');
$action.find('.oe_content').toggle();
this.trigger_up('save_dashboard');
},
/**
* Let FormController know which form view it should display based on the
* window action of the sub controller that is switching view
*
* @private
* @param {OdooEvent} event
*/
_onSwitchView: function (event) {
event.data.formViewID = this._boardFormViewIDs[event.target.handle];
},
/**
* Stops the propagation of 'update_filters' events triggered by the
* controllers instantiated by the dashboard to prevent them from
* interfering with the ActionManager.
*
* @private
* @param {OdooEvent} event
*/
_onUpdateFilters: function (event) {
event.stopPropagation();
},
});
var BoardView = FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller: BoardController,
Renderer: BoardRenderer,
}),
display_name: _lt('Board'),
/**
* @override
*/
init: function (viewInfo) {
this._super.apply(this, arguments);
this.controllerParams.customViewID = viewInfo.custom_view_id;
},
/**
* @override
*/
_extractParamsFromAction(action) {
action.target = "inline";
action.flags = action.flags || {};
return this._super.apply(this, arguments);
},
});
return BoardView;
});
odoo.define('board.viewRegistry', function (require) {
"use strict";
var BoardView = require('board.BoardView');
var viewRegistry = require('web.view_registry');
viewRegistry.add('board', BoardView);
});
@@ -1,183 +0,0 @@
.o_dashboard {
// Dashboard layout
.oe_dashboard_layout_1 .oe_dashboard_column {
&.index_0 {
width: 100%;
}
&.index_1, &.index_2 {
display: none;
}
}
.oe_dashboard_layout_1-1 .oe_dashboard_column {
width: 50%;
&.index_2 {
display: none;
}
}
.oe_dashboard_layout_1-1-1 .oe_dashboard_column {
width: 33%;
}
.oe_dashboard_layout_2-1 .oe_dashboard_column {
&.index_0 {
width: 70%;
}
&.index_1 {
width: 30%;
}
&.index_2 {
display: none;
}
}
.oe_dashboard_layout_1-2 .oe_dashboard_column {
&.index_0 {
width: 30%;
}
&.index_1 {
width: 70%;
}
&.index_2 {
display: none;
}
}
.oe_dashboard_column {
vertical-align: top;
}
// Layout selector
.oe_dashboard_links {
text-align: right;
margin: 0 4px 6px 0;
}
// Dashboard content
.oe_dashboard {
width: 100%;
.oe_action {
margin: 0 8px 8px 0;
background-color: white;
border: 1px solid;
border-color: #e5e5e5 #dbdbdb #d2d2d2;
margin: 0 8px 8px 0;
.oe_header {
font-size: 16px;
vertical-align: middle;
margin: 0;
padding: 12px;
&:hover {
cursor: move;
}
.oe_icon {
float: right;
cursor: pointer;
color: #b3b3b3;
&:hover {
color: #666;
text-decoration: none;
}
}
.oe_close:after {
content: "×";
margin-left: 4px;
}
.oe_minimize:after {
content: "-";
margin-left: 4px;
}
.oe_maximize:after {
content: "+";
margin-left: 4px;
}
.oe_header_text {
width: auto;
visibility: hidden;
display: inline-block;
cursor: text;
}
span {
cursor: pointer;
}
}
.oe_header_empty {
padding-top: 0;
padding-bottom: 2px;
}
.oe_button_create {
margin-left: 4px;
padding: 0 4px 0 4px;
height: 16px !important;
}
.oe_content {
padding: 0 12px 12px 12px;
&.oe_folded {
display: none;
}
.o_view_nocontent {
display: none; // we don't have a create button on the dashboard, so no need to display that
}
}
// Override border of many2manytags defined for form_views
.o_kanban_view .o_kanban_record .oe_kanban_list_many2many .o_field_many2manytags {
border: none;
}
// Override height of graph. min-height doesn't do the trick
.o_legacy_graph_renderer canvas {
height: 300px;
}
// Override height for calendar view to be displayed properly
.o_calendar_view {
height: 100vh;
}
// Override height for map view to be displayed properly
.o_map_view {
.o_map_container, .o_pin_list_container {
height: calc(100vh - #{$o-navbar-height});
}
}
//Add padding to the old dashboard view (To remove when board is converted to OWL)
.o_dashboard_view {
padding: $o-sheet-vpadding 0;
}
}
}
}
// Layout selector modal
.oe_dashboard_layout_selector {
ul {
white-space: nowrap;
}
li {
margin: 0;
padding: 0;
list-style-type: none;
float: left;
}
.oe_dashboard_selected_layout {
margin-left: -30px;
vertical-align: bottom;
margin-bottom: 10px;
}
}
// Favorites menu in control panel
.o_add_to_dashboard {
display: none; // hidden by default
max-width: 250px;
width: auto;
}
@include media-breakpoint-down(sm) {
.o_dashboard .oe_dashboard {
table-layout: fixed;
.oe_action .oe_header .oe_header_text {
display: none;
}
}
}
@@ -1,92 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<template>
<t t-name="DashBoard">
<t t-if="isMobile">
<t t-set="node.attrs.layout" t-value="1"/>
</t>
<t t-if="!isMobile">
<div class="oe_dashboard_links">
<button type="button" class="button oe_dashboard_link_change_layout btn btn-secondary"
title="Change Layout..">
<img src="/board/static/src/legacy/img/layout_1-1-1.png" width="16" height="16" alt=""/>
<span> Change Layout </span>
</button>
</div>
</t>
<table t-att-data-layout="node.attrs.layout" t-attf-class="oe_dashboard oe_dashboard_layout_#{node.attrs.layout}" cellspacing="0" cellpadding="0" border="0">
<tr>
<td t-foreach="node.children" t-as="column" t-if="column.tag == 'column'"
t-att-id="'column_' + column_index" t-attf-class="oe_dashboard_column index_#{column_index}">
<t t-foreach="column.children" t-as="action" t-if="action.tag == 'action'" t-call="DashBoard.action"/>
</td>
</tr>
</table>
</t>
<t t-name="DashBoard.action">
<div t-att-data-id="action.attrs.id" class="oe_action">
<h2 t-attf-class="oe_header #{action.attrs.string ? '' : 'oe_header_empty'}">
<span class="oe_header_txt"> <t t-esc="action.attrs.string"/> </span>
<input class = "oe_header_text" type="text"/>
<t t-if="!action.attrs.string">&amp;nbsp;</t>
<span class='oe_icon oe_close'></span>
<span class='oe_icon oe_minimize oe_fold' t-if="!action.attrs.fold"></span>
<span class='oe_icon oe_maximize oe_fold' t-if="action.attrs.fold"></span>
</h2>
<div t-att-class="'oe_content' + (action.attrs.fold ? ' oe_folded' : '')"/>
</div>
</t>
<t t-name="DashBoard.layouts">
<div class="oe_dashboard_layout_selector">
<p>
<strong>Choose dashboard layout</strong>
</p>
<ul>
<li t-foreach="'1 1-1 1-1-1 1-2 2-1'.split(' ')" t-as="layout" t-att-data-layout="layout">
<img t-attf-src="/board/static/src/legacy/img/layout_#{layout}.png" alt=""/>
<i t-if="layout == currentLayout" class="oe_dashboard_selected_layout fa fa-check fa-lg text-success" aria-label='Layout' role="img" title="Layout"/>
</li>
</ul>
</div>
</t>
<t t-name="DashBoard.NoContent">
<div class="o_view_nocontent">
<div class="o_nocontent_help">
<p class="o_view_nocontent_neutral_face">
Your personal dashboard is empty
</p><p>
To add your first report into this dashboard, go to any
menu, switch to list or graph view, and click <i>"Add to
Dashboard"</i> in the extended search options.
</p><p>
You can filter and group data before inserting into the
dashboard using the search options.
</p>
</div>
</div>
</t>
<t t-name="DashBoard.xml">
<form t-att-string="form_title">
<board t-att-style="style">
<column t-foreach="columns" t-as="column">
<action t-foreach="column" t-as="action" t-att="action"/>
</column>
</board>
</form>
</t>
<div t-name="HomeWidget" class="oe_dashboard_home_widget"/>
<t t-name="HomeWidget.content">
<h3><t t-esc="widget.title"/></h3>
<iframe width="100%" frameborder="0" t-att-src="url"/>
</t>
<t t-name="SearchView.addtodashboard">
<a href="#" class="dropdown-item o_add_to_dashboard_link o_closed_menu">Add to my Dashboard</a>
<div class="dropdown-item-text o_add_to_dashboard">
<input class="o_input o_add_to_dashboard_input" type="text"/>
</div>
<div class="dropdown-item-text o_add_to_dashboard">
<button type="button" class="btn btn-primary o_add_to_dashboard_button">Add</button>
</div>
</t>
</template>
@@ -0,0 +1,429 @@
/** @odoo-module **/
import { AddToBoard } from "@board/add_to_board/add_to_board";
import {
click,
getFixture,
patchWithCleanup,
mouseEnter,
triggerEvent,
} from "@web/../tests/helpers/utils";
import {
applyFilter,
applyGroup,
editConditionValue,
toggleAddCustomFilter,
toggleAddCustomGroup,
toggleComparisonMenu,
toggleFavoriteMenu,
toggleFilterMenu,
toggleGroupByMenu,
toggleMenuItem,
toggleMenuItemOption,
} from "@web/../tests/search/helpers";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import LegacyAddToBoard from "board.AddToBoardMenu";
import LegacyFavoriteMenu from "web.FavoriteMenu";
import testUtils from "web.test_utils";
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
const patchDate = testUtils.mock.patchDate;
const favoriteMenuRegistry = registry.category("favoriteMenu");
let serverData;
let target;
QUnit.module("Board", (hooks) => {
hooks.beforeEach(() => {
const models = {
board: {
fields: {},
records: [],
},
partner: {
fields: {
display_name: { string: "Displayed name", type: "char", searchable: true },
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
},
bar: { string: "Bar", type: "boolean" },
int_field: {
string: "Integer field",
type: "integer",
group_operator: "sum",
},
},
records: [
{
id: 1,
display_name: "first record",
foo: "yop",
int_field: 3,
},
{
id: 2,
display_name: "second record",
foo: "lalala",
int_field: 5,
},
{
id: 4,
display_name: "aaa",
foo: "abc",
int_field: 2,
},
],
},
};
LegacyFavoriteMenu.registry.add("add-to-board-menu", LegacyAddToBoard, 10);
favoriteMenuRegistry.add(
"add-to-board",
{
Component: AddToBoard,
groupNumber: 4,
isDisplayed: ({ config }) => config.actionType === "ir.actions.act_window",
},
{ sequence: 10 }
);
serverData = { models };
target = getFixture();
});
QUnit.module("Add to dashboard");
QUnit.test("save actions to dashboard", async function (assert) {
assert.expect(6);
serverData.models.partner.fields.foo.sortable = true;
serverData.views = {
"partner,false,list": '<list><field name="foo"/></list>',
"partner,false,search": "<search></search>",
};
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
const mockRPC = (route, args) => {
if (route === "/board/add_to_dashboard") {
assert.deepEqual(
args.context_to_save.group_by,
["foo"],
"The group_by should have been saved"
);
assert.deepEqual(
args.context_to_save.orderedBy,
[
{
name: "foo",
asc: true,
},
],
"The orderedBy should have been saved"
);
assert.strictEqual(
args.context_to_save.fire,
"on the bayou",
"The context of a controller should be passed and flattened"
);
assert.strictEqual(args.action_id, 1, "should save the correct action");
assert.strictEqual(args.view_mode, "list", "should save the correct view type");
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
id: 1,
res_model: "partner",
type: "ir.actions.act_window",
context: { fire: "on the bayou" },
views: [[false, "list"]],
});
assert.containsOnce(target, ".o_list_view", "should display the list view");
// Sort the list
await click(document.querySelector(".o_column_sortable"));
// Group It
await toggleGroupByMenu(target);
await toggleAddCustomGroup(target);
await applyGroup(target);
// add this action to dashboard
await toggleFavoriteMenu(target);
await testUtils.dom.triggerEvent($(".o_add_to_board button.dropdown-toggle"), "mouseenter");
await testUtils.fields.editInput($(".o_add_to_board input"), "a name");
await testUtils.dom.click($(".o_add_to_board .dropdown-menu button"));
});
QUnit.test("save two searches to dashboard", async function (assert) {
// the second search saved should not be influenced by the first
assert.expect(2);
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
serverData.views = {
"partner,false,list": '<list><field name="foo"/></list>',
"partner,false,search": "<search></search>",
};
const mockRPC = (route, args) => {
if (route === "/board/add_to_dashboard") {
if (filter_count === 0) {
assert.deepEqual(
args.domain,
[["display_name", "ilike", "a"]],
"the correct domain should be sent"
);
}
if (filter_count === 1) {
assert.deepEqual(
args.domain,
[["display_name", "ilike", "b"]],
"the correct domain should be sent"
);
}
filter_count += 1;
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
});
var filter_count = 0;
// Add a first filter
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionValue(target, 0, "a");
await applyFilter(target);
// Add it to dashboard
await toggleFavoriteMenu(target);
await testUtils.dom.triggerEvent($(".o_add_to_board button.dropdown-toggle"), "mouseenter");
await testUtils.dom.click($(".o_add_to_board .dropdown-menu button"));
// Remove it
await testUtils.dom.click(target.querySelector(".o_facet_remove"));
// Add the second filter
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionValue(target, 0, "b");
await applyFilter(target);
// Add it to dashboard
await toggleFavoriteMenu(target);
await testUtils.dom.triggerEvent(
target.querySelector(".o_add_to_board button.dropdown-toggle"),
"mouseenter"
);
await testUtils.dom.click(target.querySelector(".o_add_to_board .dropdown-menu button"));
});
QUnit.test("save a action domain to dashboard", async function (assert) {
// View domains are to be added to the dashboard domain
assert.expect(1);
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
var view_domain = ["display_name", "ilike", "a"];
var filter_domain = ["display_name", "ilike", "b"];
var expected_domain = ["&", view_domain, filter_domain];
serverData.views = {
"partner,false,list": '<list><field name="foo"/></list>',
"partner,false,search": "<search></search>",
};
const mockRPC = (route, args) => {
if (route === "/board/add_to_dashboard") {
assert.deepEqual(args.domain, expected_domain, "the correct domain should be sent");
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
domain: [view_domain],
});
// Add a filter
await toggleFilterMenu(target);
await toggleAddCustomFilter(target);
await editConditionValue(target, 0, "b");
await applyFilter(target);
// Add it to dashboard
await toggleFavoriteMenu(target);
await testUtils.dom.triggerEvent(
target.querySelector(".o_add_to_board button.dropdown-toggle"),
"mouseenter"
);
// add
await testUtils.dom.click(target.querySelector(".o_add_to_board .dropdown-menu button"));
});
QUnit.test(
"correctly save the time ranges of a reporting view in comparison mode",
async function (assert) {
assert.expect(1);
const unpatchDate = patchDate(2020, 6, 1, 11, 0, 0);
serverData.models.partner.fields.date = {
string: "Date",
type: "date",
sortable: true,
};
serverData.views = {
"partner,false,pivot": '<pivot><field name="foo"/></pivot>',
"partner,false,search": '<search><filter name="Date" date="date"/></search>',
};
const mockRPC = (route, args) => {
if (route === "/board/add_to_dashboard") {
assert.deepEqual(args.context_to_save.comparison, {
comparisonId: "previous_period",
fieldName: "date",
fieldDescription: "Date",
rangeDescription: "July 2020",
range: ["&", ["date", ">=", "2020-07-01"], ["date", "<=", "2020-07-31"]],
comparisonRange: [
"&",
["date", ">=", "2020-06-01"],
["date", "<=", "2020-06-30"],
],
comparisonRangeDescription: "June 2020",
});
return Promise.resolve(true);
}
};
registry.category("services").add("user", makeFakeUserService());
patchWithCleanup(browser, { setTimeout: (fn) => fn() }); // makes mouseEnter work
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "pivot"]],
});
// filter on July 2020
await toggleFilterMenu(target);
await toggleMenuItem(target, "Date");
await toggleMenuItemOption(target, "Date", "July");
// compare July 2020 to June 2020
await toggleComparisonMenu(target);
await toggleMenuItem(target, 0);
// add the view to the dashboard
await toggleFavoriteMenu(target);
await mouseEnter(target.querySelector(".o_add_to_board .dropdown-toggle"));
const input = target.querySelector(".o_add_to_board .dropdown-menu input");
await testUtils.fields.editInput(input, "Pipeline");
await testUtils.dom.click($(".o_add_to_board div button"));
unpatchDate();
}
);
QUnit.test("Add a view to dashboard (keynav)", async function (assert) {
serverData.views = {
"partner,false,pivot": '<pivot><field name="foo"/></pivot>',
"partner,false,search": "<search/>",
};
registry.category("services").add("user", makeFakeUserService());
patchWithCleanup(browser, { setTimeout: (fn) => fn() }); // makes mouseEnter work
const mockRPC = (route) => {
if (route === "/board/add_to_dashboard") {
assert.step("add to board");
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "pivot"]],
});
await toggleFavoriteMenu(target);
await mouseEnter(target.querySelector(".o_add_to_board .dropdown-toggle"));
const input = target.querySelector(".o_add_to_board .dropdown-menu input");
await testUtils.fields.editInput(input, "Pipeline");
await triggerEvent(input, null, "keydown", { key: "Enter" });
assert.verifySteps(["add to board"]);
});
QUnit.test("Add a view with dynamic domain", async function (assert) {
assert.expect(1);
serverData.views = {
"partner,false,pivot": '<pivot><field name="foo"/></pivot>',
"partner,false,search": `
<search>
<filter name="filter" domain="[('user_id','=',uid)]"/>
</search>`,
};
registry.category("services").add("user", makeFakeUserService());
patchWithCleanup(browser, { setTimeout: (fn) => fn() }); // makes mouseEnter work
const mockRPC = (route, args) => {
if (route === "/board/add_to_dashboard") {
assert.deepEqual(args.domain, ["&", ["int_field", "<=", 3], ["user_id", "=", 7]]);
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "pivot"]],
domain: [["int_field", "<=", 3]],
context: { search_default_filter: 1 },
});
await toggleFavoriteMenu(target);
await mouseEnter(target.querySelector(".o_add_to_board .dropdown-toggle"));
const input = target.querySelector(".o_add_to_board .dropdown-menu input");
await testUtils.fields.editInput(input, "Pipeline");
await triggerEvent(input, null, "keydown", { key: "Enter" });
});
});
+723
View File
@@ -0,0 +1,723 @@
/** @odoo-module **/
import { BoardAction } from "@board/board_action";
import { click, dragAndDrop, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
const serviceRegistry = registry.category("services");
let serverData;
let target;
QUnit.module("Board", (hooks) => {
hooks.beforeEach(async () => {
target = getFixture();
BoardAction.cache = {};
serverData = {
models: {
board: {
fields: {},
records: [],
},
partner: {
fields: {
display_name: { string: "Displayed name", type: "char", searchable: true },
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
},
bar: { string: "Bar", type: "boolean" },
int_field: {
string: "Integer field",
type: "integer",
group_operator: "sum",
},
},
records: [
{
id: 1,
display_name: "first record",
foo: "yop",
int_field: 3,
},
{
id: 2,
display_name: "second record",
foo: "lalala",
int_field: 5,
},
{
id: 4,
display_name: "aaa",
foo: "abc",
int_field: 2,
},
],
},
},
views: {
"partner,100000001,form": "<form/>",
"partner,100000002,search": "<search/>",
},
};
setupViewRegistries();
});
QUnit.module("BoardView");
QUnit.test("display the no content helper", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column></column>
</board>
</form>`,
});
assert.containsOnce(target, ".o_view_nocontent");
});
QUnit.test("basic functionality, with one sub action", async function (assert) {
assert.expect(21);
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" view_mode="list" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
assert.step("load action");
return Promise.resolve({
res_model: "partner",
views: [[4, "list"]],
});
}
if (route === "/web/dataset/call_kw/partner/web_search_read") {
assert.deepEqual(
args.kwargs.domain,
[["foo", "!=", "False"]],
"the domain should be passed"
);
assert.deepEqual(
args.kwargs.context.orderedBy,
[
{
name: "foo",
asc: true,
},
],
"orderedBy is present in the search read when specified on the custom action"
);
}
if (route === "/web/view/edit_custom") {
assert.step("edit custom");
return Promise.resolve(true);
}
},
});
assert.containsOnce(target, ".o-dashboard-header", "should have rendered a header");
assert.containsOnce(
target,
"div.o-dashboard-layout-2-1",
"should have rendered a div with layout"
);
assert.containsNone(
target,
"td.o_list_record_selector",
"td should not have a list selector"
);
assert.containsOnce(
target,
"h3 span:contains(ABC)",
"should have rendered a header with action string"
);
assert.containsN(target, "tr.o_data_row", 3, "should have rendered 3 data rows");
assert.containsOnce(target, ".o-dashboard-action .o_list_view");
await click(target, "h3 i.fa-window-minimize");
assert.containsNone(target, ".o-dashboard-action .o_list_view");
await click(target, "h3 i.fa-window-maximize");
// content is visible again
assert.containsOnce(target, ".o-dashboard-action .o_list_view");
assert.verifySteps(["load action", "edit custom", "edit custom"]);
// header should have dropdown with correct image
assert.containsOnce(
target,
".o-dashboard-header .dropdown img[data-src='/board/static/img/layout_2-1.png']"
);
// change layout to 1-1
await click(target, ".o-dashboard-header .dropdown img");
await click(target, ".o-dashboard-header .dropdown-item:nth-child(2)");
assert.containsOnce(
target,
".o-dashboard-header .dropdown img[data-src='/board/static/img/layout_1-1.png']"
);
assert.containsOnce(
target,
"div.o-dashboard-layout-1-1",
"should have rendered a div with layout"
);
assert.verifySteps(["edit custom"]);
});
QUnit.test("views in the dashboard do not have a control panel", async function (assert) {
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" view_mode="list" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [
[4, "list"],
[5, "form"],
],
});
}
},
});
assert.containsOnce(target, ".o-dashboard-action .o_list_view");
assert.containsNone(target, ".o-dashboard-action .o_control_panel");
});
QUnit.test("can render an action without view_mode attribute", async function (assert) {
// The view_mode attribute is automatically set to the 'action' nodes when
// the action is added to the dashboard using the 'Add to dashboard' button
// in the searchview. However, other dashboard views can be written by hand
// (see openacademy tutorial), and in this case, we don't want hardcode
// action's params (like context or domain), as the dashboard can directly
// retrieve them from the action. Same applies for the view_type, as the
// first view of the action can be used, by default.
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [
[4, "list"],
[false, "form"],
],
});
}
},
});
assert.containsOnce(target, ".o-dashboard-action .o_list_view");
});
QUnit.test("can sort a sub list", async function (assert) {
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
serverData.models.partner.fields.foo.sortable = true;
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [[4, "list"]],
});
}
},
});
assert.strictEqual(
$("tr.o_data_row").text(),
"yoplalalaabc",
"should have correct initial data"
);
await click($(target).find("th.o_column_sortable:contains(Foo)")[0]);
assert.strictEqual(
$("tr.o_data_row").text(),
"abclalalayop",
"data should have been sorted"
);
});
QUnit.test("can open a record", async function (assert) {
assert.expect(1);
const fakeActionService = {
start() {
return {
doAction(action) {
assert.deepEqual(action, {
res_id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "form"]],
});
return Promise.resolve(true);
},
};
},
};
serviceRegistry.add("action", fakeActionService, { force: true });
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
serverData.models.partner.fields.foo.sortable = true;
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [[4, "list"]],
});
}
},
});
await click($(target).find("tr.o_data_row td:contains(yop)")[0]);
});
QUnit.test("can open record using action form view", async function (assert) {
assert.expect(1);
const fakeActionService = {
start() {
return {
doAction(action) {
assert.deepEqual(action, {
res_id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [[5, "form"]],
});
return Promise.resolve(true);
},
};
},
};
serviceRegistry.add("action", fakeActionService, { force: true });
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
serverData.views["partner,5,form"] =
'<form string="Partner"><field name="display_name"/></form>';
serverData.models.partner.fields.foo.sortable = true;
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [
[4, "list"],
[5, "form"],
],
});
}
},
});
await click($(target).find("tr.o_data_row td:contains(yop)")[0]);
});
QUnit.skip("can drag and drop a view", async function (assert) {
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" view_mode="list" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [[4, "list"]],
});
}
if (route === "/web/view/edit_custom") {
assert.step("edit custom");
return Promise.resolve(true);
}
},
});
assert.strictEqual(
target.querySelectorAll('.o-dashboard-column[data-idx="0"] .o-dashboard-action').length,
1
);
assert.strictEqual(
target.querySelectorAll('.o-dashboard-column[data-idx="1"] .o-dashboard-action').length,
0
);
await dragAndDrop(
'.o-dashboard-column[data-idx="0"] .o-dashboard-action-header',
'.o-dashboard-column[data-idx="1"]'
);
assert.strictEqual(
target.querySelectorAll('.o-dashboard-column[data-idx="0"] .o-dashboard-action').length,
0
);
assert.strictEqual(
target.querySelectorAll('.o-dashboard-column[data-idx="1"] .o-dashboard-action').length,
1
);
assert.verifySteps(["edit custom"]);
});
QUnit.test("twice the same action in a dashboard", async function (assert) {
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
serverData.views["partner,5,kanban"] = `
<kanban>
<templates>
<t t-name="kanban-box">
<div><field name="foo"/></div>
</t>
</templates>
</kanban>`;
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>
<action context="{}" view_mode="kanban" string="DEF" name="51" domain="[]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [
[4, "list"],
[5, "kanban"],
],
});
}
if (route === "/web/view/edit_custom") {
assert.step("edit custom");
return Promise.resolve(true);
}
},
});
var $firstAction = $(".o-dashboard-action:eq(0)");
assert.strictEqual(
$firstAction.find(".o_list_view").length,
1,
"list view should be displayed in 'ABC' block"
);
var $secondAction = $(".o-dashboard-action:eq(1)");
assert.strictEqual(
$secondAction.find(".o_kanban_view").length,
1,
"kanban view should be displayed in 'DEF' block"
);
});
QUnit.test("clicking on a kanban's button should trigger the action", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{&quot;orderedBy&quot;: [{&quot;name&quot;: &quot;foo&quot;, &quot;asc&quot;: True}]}" view_mode="list" string="ABC" name="51" domain="[['foo', '!=', 'False']]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
// server answer if the action doesn't exist anymore
return Promise.resolve(false);
}
},
});
assert.containsOnce(target, "h3 span:contains(ABC)");
assert.containsOnce(target, "div.text-center:contains(Invalid action)");
});
QUnit.test("twice the same action in a dashboard", async function (assert) {
assert.expect(4);
serverData.views["partner,false,kanban"] = `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<div><field name="foo"/></div>
<button name="sitting_on_a_park_bench" type="object">Eying little girls with bad intent</button>
</div>
</t>
</templates>
</kanban>`;
const view = await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action name="149" string="Partner" view_mode="kanban" id="action_0_1"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
view_mode: "kanban",
views: [[false, "kanban"]],
});
}
if (route === "/web/dataset/search_read") {
return Promise.resolve({ records: [{ foo: "aqualung" }] });
}
},
});
patchWithCleanup(view.env.services.action, {
doActionButton(params) {
assert.strictEqual(params.resModel, "partner");
assert.strictEqual(params.resId, 1);
assert.strictEqual(params.name, "sitting_on_a_park_bench");
assert.strictEqual(params.type, "object");
},
});
await click(document.querySelector(".btn.oe_kanban_action"));
});
QUnit.test("Views should be loaded in the user's language", async function (assert) {
assert.expect(2);
patchWithCleanup(session.user_context, { lang: "fr_FR" });
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{'lang': 'en_US'}" view_mode="list" string="ABC" name="51" domain="[]"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [[4, "list"]],
});
}
if (args.method === "get_views") {
assert.strictEqual(args.kwargs.context.lang, "fr_FR");
}
},
});
});
QUnit.test("Dashboard should use correct groupby", async function (assert) {
assert.expect(1);
serverData.views["partner,4,list"] = '<tree string="Partner"><field name="foo"/></tree>';
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{'group_by': ['bar']}" string="ABC" name="51"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [[4, "list"]],
});
}
if (args.method === "web_read_group") {
assert.deepEqual(args.kwargs.groupby, ["bar"]);
}
},
});
});
QUnit.test(
"Dashboard should use correct groupby when defined as a string of one field",
async function (assert) {
assert.expect(1);
serverData.views["partner,4,list"] =
'<tree string="Partner"><field name="foo"/></tree>';
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action context="{'group_by': 'bar'}" string="ABC" name="51"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [[4, "list"]],
});
}
if (args.method === "web_read_group") {
assert.deepEqual(args.kwargs.groupby, ["bar"]);
}
},
});
}
);
QUnit.test("click on a cell of pivot view inside dashboard", async function (assert) {
serverData.views["partner,4,pivot"] =
'<pivot><field name="int_field" type="measure"/></pivot>';
const fakeActionService = {
start() {
return {
doAction(action) {
assert.step("do action");
},
};
},
};
serviceRegistry.add("action", fakeActionService, { force: true });
await makeView({
serverData,
type: "form",
resModel: "board",
arch: `
<form string="My Dashboard" js_class="board">
<board style="2-1">
<column>
<action view_mode="pivot" string="ABC" name="51"></action>
</column>
</board>
</form>`,
mockRPC(route, args) {
if (route === "/web/action/load") {
return Promise.resolve({
res_model: "partner",
views: [[4, "pivot"]],
});
}
if (args.method === "web_read_group") {
assert.deepEqual(args.kwargs.groupby, ["bar"]);
}
},
});
assert.verifySteps([]);
await click(document.querySelector(".o_pivot_view .o_pivot_cell_value"));
assert.verifySteps(["do action"]);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
.o_kanban_view.o_crm_team_member_kanban {
.o_kanban_view .o_crm_team_member_kanban {
.o_member_assignment div.oe_gauge {
width: 100px;
height: 80px;
+2 -2
View File
@@ -1,4 +1,4 @@
.o_kanban_view.o_event_kanban_view {
.o_kanban_view .o_event_kanban_view {
.o_kanban_record {
min-height: 140px;
}
@@ -17,7 +17,7 @@
}
}
.o_kanban_view.o_event_attendee_kanban_view {
.o_kanban_view .o_event_attendee_kanban_view {
@media (max-width: 768px) {
.o_event_registration_kanban {
min-height: 80px;
+10 -11
View File
@@ -185,34 +185,33 @@
<div t-attf-class="d-flex flex-column p-0 oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_content p-0 m-0 position-relative row d-flex flex-fill">
<div class="col-3 bg-primary p-2 text-center d-flex flex-column justify-content-center">
<div t-out="record.date_begin.raw_value.getDate()" class="o_event_fontsize_20"/>
<div t-esc="record.date_begin.raw_value.getDate()" class="o_event_fontsize_20"/>
<div>
<t t-out="moment(record.date_begin.raw_value).format('MMM')"/>
<t t-out="record.date_begin.raw_value.getFullYear()"/>
<t t-esc="luxon.DateTime.fromJSDate(record.date_begin.raw_value).toFormat('MMM yyyy')"/>
</div>
<div><t t-out="moment(record.date_begin.raw_value).format('LT')"/></div>
<div t-if="moment(record.date_begin.raw_value).dayOfYear() !== moment(record.date_end.raw_value).dayOfYear()">
<i class="fa fa-arrow-right o_event_fontsize_09" title="End date"/>
<t t-out="moment(record.date_end.raw_value).format('D MMM')"/>
</div>
<div><t t-esc="luxon.DateTime.fromJSDate(record.date_begin.raw_value).toFormat('t')"/></div>
<div t-if="luxon.DateTime.fromJSDate(record.date_begin.raw_value).toFormat('DDMMYYYY') !== luxon.DateTime.fromJSDate(record.date_end.raw_value).toFormat('DDMMYYYY')">
<i class="fa fa-arrow-right mr-2 o_event_fontsize_09" title="End date"/>
<t t-esc="luxon.DateTime.fromJSDate(record.date_end.raw_value).toFormat('d MMM')"/>
</div>
</div>
<div class="col-9 py-2 px-3 d-flex flex-column justify-content-between pt-3">
<div>
<div class="o_kanban_record_title o_text_overflow" t-att-title="record.name.value">
<field name="name"/>
</div>
<div t-if="record.address_id.value"><i class="fa fa-map-marker" title="Location"/> <span class="o_text_overflow o_event_kanban_location" t-out="record.address_id.value"/></div>
<div t-if="record.address_id.value"><i class="fa fa-map-marker" title="Location"/> <span class="o_text_overflow o_event_kanban_location" t-esc="record.address_id.value"/></div>
</div>
<h5 class="o_event_fontsize_11 p-0">
<a name="%(event_registration_action_stats_from_event)d"
type="action"
context="{'search_default_expected': True}">
<t t-out="record.seats_expected.raw_value"/> Expected attendees
<t t-esc="record.seats_expected.raw_value"/> Expected attendees
</a>
<t t-set="total_seats" t-value="record.seats_reserved.raw_value + record.seats_used.raw_value"/>
<div class="pt-2 pt-md-0" t-if="total_seats > 0 and ! record.auto_confirm.raw_value"><br/>
<a class="pl-2" name="%(event_registration_action_stats_from_event)d" type="action" context="{'search_default_confirmed': True}">
<i class="fa fa-level-up fa-rotate-90" title="Confirmed"/><span class="pl-2"><t t-out="total_seats"/> Confirmed</span>
<i class="fa fa-level-up fa-rotate-90" title="Confirmed"/><span class="pl-2"><t t-esc="total_seats"/> Confirmed</span>
</a>
</div>
</h5>
@@ -148,7 +148,7 @@
<div t-if="!widget.isHtmlEmpty(record.description.value)" class="o_kanban_badge_description">
<em t-out="record.description.value"/>
<div>
<t t-foreach="record.unique_owner_ids.raw_value.slice(0,11)" t-as="owner">
<t t-foreach="record.unique_owner_ids.raw_value.slice(0,11)" t-as="owner" t-key="owner">
<img class="oe_kanban_avatar o_image_24_cover" t-att-src="kanban_image('res.users', 'avatar_128', owner)" t-att-data-member_id="owner" alt="Owner"/>
</t>
</div>
@@ -166,7 +166,7 @@
</ul>
</t>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click o_kanban_gamification #{record.end_date.raw_value &lt; record.last_update.raw_value &amp; record.state.raw_value == 'failed' ? 'oe_kanban_color_2' : ''} #{record.end_date.raw_value &lt; record.last_update.raw_value &amp; record.state.raw_value == 'reached' ? 'oe_kanban_color_5' : ''}">
<div t-attf-class="oe_kanban_card oe_kanban_global_click o_kanban_gamification #{record.end_date.raw_value &lt; record.last_update.raw_value and record.state.raw_value == 'failed' ? 'oe_kanban_color_2' : ''} #{record.end_date.raw_value &lt; record.last_update.raw_value and record.state.raw_value == 'reached' ? 'oe_kanban_color_5' : ''}">
<div class="o_kanban_content">
<p><strong><h4 class="oe_goal_name text-center" tooltip="kanban-tooltip"><field name="definition_id" /></h4></strong></p>
<div class="float-left">
@@ -3,7 +3,7 @@
<t t-name="hr_contract.CalendarMismatch" owl="1">
<div class="o-tooltip px-1 py-2">
<p style="font-size: 12px;" class="text-danger"
t-esc="info.text"/>
t-esc="text"/>
</div>
</t>
</templates>
@@ -1,6 +1,6 @@
.hr_expense {
@include media-breakpoint-up(md) {
&.o_list_view, &.o_kanban_view {
&.o_list_view, &.o_legacy_kanban_view {
min-height: auto;
}
}
@@ -30,7 +30,7 @@ $o-hr-holidays-border-color: gray('300');
border-radius: 25px;
}
.o_kanban_view.o_kanban_ungrouped .o_kanban_record {
.o_kanban_renderer.o_kanban_ungrouped .o_kanban_record {
counter-increment: o-hr-holidays-accrual-plan-level-counter;
display: flex;
flex: 0 0 100%;
@@ -1,4 +1,4 @@
.o_kanban_view.o_kanban_dashboard {
.o_kanban_renderer.o_kanban_dashboard {
&.o_hr_recruitment_kanban {
&.o_kanban_ungrouped .o_kanban_record {
width: 450px;
@@ -54,10 +54,10 @@
.o_kanban_card_header_title {
min-height: 3rem;
}
.o_recruitment_job_container {
max-width: None !important;
.o_field_boolean_favorite {
display: inline;
}
}
}
@@ -116,7 +116,7 @@
}
}
.o_kanban_view {
.o_kanban_renderer {
&.o_kanban_applicant {
.o_sample_data_disabled {
.ribbon {
+2 -7
View File
@@ -48,13 +48,8 @@ up a management by affair.
'assets': {
'web.assets_backend': [
'hr_timesheet/static/src/scss/timesheets_task_form.scss',
'hr_timesheet/static/src/js/task_with_hours.js',
'hr_timesheet/static/src/js/timesheet_uom.js',
'hr_timesheet/static/src/js/timesheet_factor.js',
'hr_timesheet/static/src/js/timesheet_config_form_view.js',
'hr_timesheet/static/src/js/qr_code_action.js',
'hr_timesheet/static/src/js/timesheet_graph_view.js',
'hr_timesheet/static/src/js/timesheet_graph_model.js',
'hr_timesheet/static/src/**/*.js',
('remove', 'hr_timesheet/static/src/js/*_legacy.js')
],
"web.assets_backend_legacy_lazy": [
'hr_timesheet/static/src/js/*_legacy.js',
+1
View File
@@ -142,6 +142,7 @@ Help your customers with this chat, and analyse their feedback.
'web/static/src/core/utils/components.js',
'web/static/src/core/utils/functions.js',
'web/static/src/core/utils/hooks.js',
'web/static/src/core/utils/numbers.js',
'web/static/src/core/utils/strings.js',
'web/static/src/core/utils/timing.js',
'web/static/src/core/utils/ui.js',
@@ -1,5 +1,5 @@
.o_lunch_content {
.o_kanban_view {
.o_legacy_kanban_view {
flex: 1 1 100%;
&.o_kanban_grouped {
min-height: auto; // override min-height: 100%
@@ -72,15 +72,15 @@ QUnit.module('LunchKanbanView Mobile', {
}),
});
assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)',
assert.containsOnce(kanban, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)',
"should have 1 records in the renderer");
// check view layout
assert.containsOnce(kanban, '.o_content > .o_lunch_content',
"should have a 'kanban lunch wrapper' column");
assert.containsOnce(kanban, '.o_lunch_content > .o_kanban_view',
assert.containsOnce(kanban, '.o_lunch_content > .o_legacy_kanban_view',
"should have a 'classical kanban view' column");
assert.hasClass(kanban.$('.o_kanban_view'), 'o_lunch_kanban_view',
assert.hasClass(kanban.$('.o_legacy_kanban_view'), 'o_lunch_kanban_view',
"should have classname 'o_lunch_kanban_view'");
assert.containsOnce($('.o_lunch_content'), '> details',
"should have a 'lunch kanban' details/summary discolure panel");
@@ -112,7 +112,7 @@ QUnit.module('LunchKanbanView', {
}),
});
assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)',
assert.containsOnce(kanban, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)',
"should have 1 records in the renderer");
// check view layout
@@ -122,9 +122,9 @@ QUnit.module('LunchKanbanView', {
"should have a 'lunch filters' column");
assert.containsOnce(kanban, '.o_content > .o_lunch_content',
"should have a 'lunch wrapper' column");
assert.containsOnce(kanban, '.o_lunch_content > .o_kanban_view',
assert.containsOnce(kanban, '.o_lunch_content > .o_legacy_kanban_view',
"should have a 'classical kanban view' column");
assert.hasClass(kanban.$('.o_kanban_view'), 'o_lunch_kanban_view',
assert.hasClass(kanban.$('.o_legacy_kanban_view'), 'o_lunch_kanban_view',
"should have classname 'o_lunch_kanban_view'");
assert.containsOnce(kanban, '.o_lunch_content > span > .o_lunch_banner',
"should have a 'lunch' banner");
@@ -57,6 +57,9 @@ export class FollowerListMenu extends Component {
* @param {MouseEvent} ev
*/
_onClickCaptureGlobal(ev) {
if (!this.followerListMenuView.exists()) {
return;
}
// since dropdown is conditionally shown based on state, dropdownRef can be null
if (this._dropdownRef.el && !this._dropdownRef.el.contains(ev.target)) {
this.followerListMenuView.hide();
@@ -2,7 +2,42 @@
import { registry } from "@web/core/registry";
export function manageMessages({ action, component, env }) {
export function manageMessages({ component, env }) {
const resId = component.model.root.resId;
if (!resId) {
return null; // No record
}
const description = env._t("Manage Messages");
return {
type: "item",
description,
callback: () => {
env.services.action.doAction({
res_model: "mail.message",
name: description,
views: [
[false, "list"],
[false, "form"],
],
type: "ir.actions.act_window",
domain: [
["res_id", "=", resId],
["model", "=", component.props.resModel],
],
context: {
default_res_model: component.props.resModel,
default_res_id: resId,
},
});
},
sequence: 325,
};
}
registry.category("debug").category("form").add("mail.manageMessages", manageMessages);
// Legacy form views compatibility: remove as soon as we no longer support legacy views
function legacyManageMessages({ action, component, env }) {
const selectedIds = component.widget.getSelectedIds();
if (!selectedIds.length) {
return null; // No record
@@ -33,5 +68,4 @@ export function manageMessages({ action, component, env }) {
sequence: 325,
};
}
registry.category("debug").category("form").add("mail.manageMessages", manageMessages);
registry.category("debug").category("form_legacy").add("mail.manageMessages", legacyManageMessages);
+1 -1
View File
@@ -1,6 +1,6 @@
$o-kanban-attachement-image-size: 80px;
.o_kanban_view {
.o_legacy_kanban_view, .o_kanban_renderer {
.o_kanban_record.o_kanban_attachment {
padding: map-get($spacers, 0);
@@ -12,6 +12,7 @@ import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { MockServer } from "@web/../tests/helpers/mock_server";
import { getFixture, makeDeferred, patchWithCleanup } from "@web/../tests/helpers/utils";
import { doAction, getActionManagerServerData } from "@web/../tests/webclient/helpers";
import { useLegacyViews } from "@web/../tests/legacy/legacy_setup";
import core from 'web.core';
@@ -507,6 +508,7 @@ function getOpenFormView(afterEvent, openView) {
* @returns {Object}
*/
async function start(param0 = {}) {
useLegacyViews();
// patch _.debounce and _.throttle to be fast and synchronous.
patchWithCleanup(_, {
debounce: func => func,
@@ -5,6 +5,7 @@ import { WEBCLIENT_LOAD_ROUTES } from '@mail/../tests/helpers/webclient_setup';
import testUtils from 'web.test_utils';
import { patchWithCleanup } from '@web/../tests/helpers/utils';
import { ViewAdapter } from "@web/legacy/action_adapters";
QUnit.module('mail', {}, function () {
QUnit.module('Chatter');
@@ -167,7 +168,7 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
const views = {
'res.users,false,list': '<list><field name="activity_ids" widget="list_activity"/></list>',
};
const { env, openView } = await start({
const { openView } = await start({
mockRPC: function (route, args) {
if (
args.method !== 'get_views' &&
@@ -188,11 +189,18 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
},
serverData: { views },
});
patchWithCleanup(env.services.action, {
switchView() {
assert.step('switch_view');
},
patchWithCleanup(ViewAdapter.prototype, {
setup() {
this._super();
const selectRecord = this.props.selectRecord;
this.props.selectRecord = (...args) => {
assert.step(`select_record ${JSON.stringify(args)}`);
return selectRecord(...args);
}
}
});
await openView({
res_model: 'res.users',
views: [[false, 'list']],
@@ -216,7 +224,7 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
assert.verifySteps([
'/web/dataset/search_read',
'switch_view',
'select_record [2,{\"mode\":\"readonly\"}]',
'open dropdown',
'activity_format',
'action_feedback',
@@ -26,14 +26,15 @@ QUnit.test("Manage Messages", async function (assert) {
registry.category("debug").category("form").add("manageMessages", manageMessages);
async function mockRPC(route, args) {
if (args.method === "check_access_rights") {
async function mockRPC(route, { method, model, kwargs }) {
if (method === "check_access_rights") {
return true;
}
if (route === "/web/dataset/search_read" && args.model === "mail.message") {
assert.strictEqual(args.context.default_res_id, 5);
assert.strictEqual(args.context.default_res_model, "partner");
assert.deepEqual(args.domain, ["&", ["res_id", "=", 5], ["model", "=", "partner"]]);
if (method === "web_search_read" && model === "mail.message") {
const { context, domain } = kwargs;
assert.strictEqual(context.default_res_id, 5);
assert.strictEqual(context.default_res_model, "partner");
assert.deepEqual(domain, ["&", ["res_id", "=", 5], ["model", "=", "partner"]]);
}
}
@@ -1,4 +1,4 @@
.o_kanban_view.o_kanban_ungrouped.o_kanban_dashboard.o_maintenance_team_kanban {
.o_kanban_renderer.o_kanban_ungrouped.o_kanban_dashboard.o_maintenance_team_kanban {
.o_kanban_record {
width: 420px;
}
@@ -462,7 +462,7 @@
<t t-out="record.maintenance_open_count.raw_value"/> Request
</div>
<div class="badge badge-secondary" t-if="!selection_mode and record.next_action_date.raw_value" >
<t t-out="moment(record.next_action_date.raw_value).format('MMMM Do')"/>
<t t-esc="luxon.DateTime.fromJSDate(record.next_action_date.raw_value).toFormat('d MMMM')"/>
</div>
</div>
<div class="oe_kanban_bottom_right">
@@ -1,4 +1,4 @@
.o_kanban_dashboard.o_kanban_view {
.o_kanban_dashboard.o_kanban_renderer {
&.o_mrp_workorder_kanban,&.o_workcenter_kanban {
.o_kanban_group:not(.o_column_folded) {
width: 400px + $o-kanban-group-padding;
@@ -58,10 +58,10 @@ QUnit.module('MrpDocumentsKanbanView', {
assert.ok(kanban, "kanban is created");
assert.ok(kanban.$buttons.find('.o_mrp_documents_kanban_upload'),
"should have upload button in kanban buttons");
assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3,
assert.containsN(kanban, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3,
"should have 3 records in the renderer");
// check view layout
assert.hasClass(kanban.$('.o_kanban_view'), 'o_mrp_documents_kanban_view',
assert.hasClass(kanban.$('.o_legacy_kanban_view'), 'o_mrp_documents_kanban_view',
"should have classname 'o_mrp_documents_kanban_view'");
// check control panel buttons
assert.containsN(kanban, '.o_cp_buttons .btn-primary', 1,
+1 -1
View File
@@ -124,7 +124,7 @@
</div>
<div t-attf-class="d-flex #{noteHasFollowers ? 'w-100 align-items-center justify-content-end' : 'align-items-end'}">
<div t-if="noteHasFollowers" class="d-flex align-items-center mr-2 mt-2">
<t t-foreach="record.message_partner_ids.raw_value" t-as="follower">
<t t-foreach="record.message_partner_ids.raw_value" t-as="follower" t-key="follower">
<img
t-if="follower_index &lt; 5"
t-att-src="kanban_image('res.partner', 'avatar_128', follower)"
@@ -1,4 +1,4 @@
.o_kanban_view.o_kanban_payment_acquirer {
.o_kanban_renderer.o_kanban_payment_acquirer {
&.o_kanban_ungrouped {
.o_kanban_record {
width: 500px;
@@ -1,4 +1,4 @@
.o_kanban_view.o_kanban_dashboard.o_pos_kanban.o_kanban_ungrouped {
.o_kanban_renderer.o_kanban_dashboard.o_pos_kanban.o_kanban_ungrouped {
.o_kanban_record {
width: 500px;
}
@@ -1,111 +1,104 @@
/** @odoo-module **/
import { _lt } from 'web.core';
import {Markup} from 'web.utils';
import kanbanExamplesRegistry from 'web.kanban_examples_registry';
import { registry } from "@web/core/registry";
import { renderToMarkup } from '@web/core/utils/render';
const greenBullet = Markup`<span class="o_status d-inline-block o_status_green"></span>`;
const redBullet = Markup`<span class="o_status d-inline-block o_status_red"></span>`;
const star = Markup`<a style="color: gold;" class="fa fa-star"/>`;
const clock = Markup`<a class="fa fa-clock-o" />`;
const { markup } = owl;
const greenBullet = markup(`<span class="o_status d-inline-block o_status_green"></span>`);
const redBullet = markup(`<span class="o_status d-inline-block o_status_red"></span>`);
const star = markup(`<a style="color: gold;" class="fa fa-star"></a>`);
const clock = markup(`<a class="fa fa-clock-o"></a>`);
const descriptionActivities = escFormat(_lt('%s Use the %s icon to organize your daily activities.'), '<br/>', clock);
const description = escFormat(_lt('Prioritize Tasks by using the %s icon.' +
'%s Use the %s button to signalize to your colleagues that a task is ready for the next stage.' +
'%s Use the %s to signalize a problem or a need for discussion on a task.' +
'%s'), star, '<br/>', greenBullet, '<br/>', redBullet, descriptionActivities);
/**
* Helper function to escape the format string, but not the values formatted in,
* rougly equivalent to `_.str.sprintf(_.escape(fmt), ...)`.
*
* Performs the escaping lazily (at point of use) as it's designed to be used
* with `_lt` as the source of the format string.
*
* @param fmt format string, to escape
* @param args objects to format into `fmt`, unescaped
* @returns {Object} a stringifiable object returning the escaped then formatted string
*/
function escFormat(fmt, ...args) {
return {
[_.escapeMethod]() {
return this;
},
toString() {
return _.str.sprintf(_.escape(fmt), ...args);
},
};
}
kanbanExamplesRegistry.add('project', {
const exampleData = {
ghostColumns: [_lt('New'), _lt('Assigned'), _lt('In Progress'), _lt('Done')],
applyExamplesText: _lt("Use This For My Project"),
examples:[{
name: _lt('Software Development'),
columns: [_lt('Backlog'), _lt('Specifications'), _lt('Development'), _lt('Tests'), _lt('Delivered')],
description: escFormat(_lt('Prioritize Tasks by using the %s icon.' +
'%s Use the %s button to inform your colleagues that a task is ready for the next stage.' +
'%s Use the %s to indicate a problem or a need for discussion on a task.' +
'%s'), star, '<br/>', greenBullet, '<br/>', redBullet, descriptionActivities),
get description() {
return renderToMarkup("project.example.generic");
},
bullets: [greenBullet, redBullet, star],
}, {
name: _lt('Agile Scrum'),
columns: [_lt('Backlog'), _lt('Sprint Backlog'), _lt('Sprint in Progress'), _lt('Sprint Complete'), _lt('Old Completed Sprint')],
description: escFormat(_lt('Use %s and %s bullets to indicate the status of a task. %s'), greenBullet, redBullet, descriptionActivities),
get description() {
return renderToMarkup("project.example.agilescrum");
},
bullets: [greenBullet, redBullet],
}, {
name: _lt('Digital Marketing'),
columns: [_lt('Ideas'), _lt('Researching'), _lt('Writing'), _lt('Editing'), _lt('Done')],
description: escFormat(_lt('Everyone can propose ideas, and the Editor marks the best ones ' +
'as %s. Attach all documents or links to the task directly, to have all research information centralized. %s'), greenBullet, descriptionActivities),
get description() {
return renderToMarkup("project.example.digitalmarketing");
},
bullets: [greenBullet, redBullet],
}, {
name: _lt('Customer Feedback'),
columns: [_lt('New'), _lt('In development'), _lt('Done'), _lt('Refused')],
description: escFormat(_lt('Customers propose feedbacks by email; Odoo creates tasks ' +
'automatically, and you can communicate on the task directly. Your managers decide which ' +
'feedback is accepted %s and which feedback is moved to the %s column. %s'), greenBullet, _lt('"Refused"'), descriptionActivities),
get description() {
return renderToMarkup("project.example.customerfeedback");
},
bullets: [greenBullet, redBullet],
}, {
name: _lt('Consulting'),
columns: [_lt('New Projects'), _lt('Resources Allocation'), _lt('In Progress'), _lt('Done')],
description: escFormat(_lt('Manage the lifecycle of your project using the kanban view. Add newly acquired projects, assign them and use the %s and %s to define if the project is ready for the next step. %s'), greenBullet, redBullet, descriptionActivities),
get description() {
return renderToMarkup("project.example.consulting");
},
bullets: [greenBullet, redBullet],
}, {
name: _lt('Research Project'),
columns: [_lt('Brainstorm'), _lt('Research'), _lt('Draft'), _lt('Final Document')],
description: escFormat(_lt('Handle your idea gathering within Tasks of your new Project and discuss them in the chatter of the tasks. Use the %s and %s to signalize what is the current status of your Idea. %s'), greenBullet, redBullet, descriptionActivities),
get description() {
return renderToMarkup("project.example.researchproject");
},
bullets: [greenBullet, redBullet],
}, {
name: _lt('Website Redesign'),
columns: [_lt('Page Ideas'), _lt('Copywriting'), _lt('Design'), _lt('Live')],
description: escFormat(_lt('Handle your idea gathering within Tasks of your new Project and discuss them in the chatter of the tasks. Use the %s and %s to signalize what is the current status of your Idea. %s'), greenBullet, redBullet, descriptionActivities),
get description() {
return renderToMarkup("project.example.researchproject");
},
}, {
name: _lt('T-shirt Printing'),
columns: [_lt('New Orders'), _lt('Logo Design'), _lt('To Print'), _lt('Done')],
description: escFormat(_lt('Communicate with customers on the task using the email gateway. ' +
'Attach logo designs to the task, so that information flows from designers to the workers ' +
'who print the t-shirt. Organize priorities amongst orders using the %s icon. %s'), star, descriptionActivities),
get description() {
return renderToMarkup("project.example.tshirtprinting");
},
bullets: [star],
}, {
name: _lt('Design'),
columns: [_lt('New Request'), _lt('Design'), _lt('Client Review'), _lt('Handoff')],
description: description,
get description() {
return renderToMarkup("project.example.generic");
},
bullets: [greenBullet, redBullet, star, clock],
}, {
name: _lt('Publishing'),
columns: [_lt('Ideas'), _lt('Writing'), _lt('Editing'), _lt('Published')],
description: description,
get description() {
return renderToMarkup("project.example.generic");
},
bullets: [greenBullet, redBullet, star, clock],
}, {
name: _lt('Manufacturing'),
columns: [_lt('New Orders'), _lt('Material Sourcing'), _lt('Manufacturing'), _lt('Assembling'), _lt('Delivered')],
description: description,
get description() {
return renderToMarkup("project.example.generic");
},
bullets: [greenBullet, redBullet, star, clock],
}, {
name: _lt('Podcast and Video Production'),
columns: [_lt('Research'), _lt('Script'), _lt('Recording'), _lt('Mixing'), _lt('Published')],
description: description,
get description() {
return renderToMarkup("project.example.generic");
},
bullets: [greenBullet, redBullet, star, clock],
}],
});
};
kanbanExamplesRegistry.add('project', exampleData);
registry.category("kanban_examples").add('project', exampleData);
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { AddMilestone, OpenMilestone } from '@project/js/right_panel/project_utils';
import { formatFloat, formatMonetary } from "@web/fields/formatters";
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
import { LegacyComponent } from "@web/legacy/legacy_component";
import { session } from "@web/session";
@@ -1,4 +1,4 @@
.o_kanban_view.o_kanban_dashboard.o_project_kanban {
.o_legacy_kanban_view.o_kanban_dashboard.o_project_kanban {
.o_project_kanban_main {
max-width: 300px;
@@ -37,7 +37,7 @@
cursor: pointer;
}
.o_kanban_view.o_pupdate_kanban {
.o_legacy_kanban_view.o_pupdate_kanban {
overflow: auto;
&.o_kanban_ungrouped {
padding:0px;
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project.example.generic" owl="1">
Prioritize Tasks by using the <a style="color: gold;" class="fa fa-star"></a> icon.
<br/>
Use the <span class="o_status d-inline-block o_status_green"></span> button to inform your colleagues that a task is ready for the next stage.
<br/>
Use the <span class="o_status d-inline-block o_status_red"></span> to indicate a problem or a need for discussion on a task.
<br/>
Use the <a class="fa fa-clock-o"></a> icon to organize your daily activities.
</t>
<t t-name="project.example.agilescrum" owl="1">
Use <span class="o_status d-inline-block o_status_green"></span> and <span class="o_status d-inline-block o_status_red"></span> bullets to indicate the status of a task.
<br/>
Use the <a class="fa fa-clock-o"></a> icon to organize your daily activities.
</t>
<t t-name="project.example.digitalmarketing" owl="1">
Everyone can propose ideas, and the Editor marks the best ones as <span class="o_status d-inline-block o_status_green"></span>.
Attach all documents or links to the task directly, to have all research information centralized.
<br/>
Use the <a class="fa fa-clock-o"></a> icon to organize your daily activities.
</t>
<t t-name="project.example.customerfeedback" owl="1">
Customers propose feedbacks by email; Odoo creates tasks automatically, and you can
communicate on the task directly. Your managers decide which feedback is accepted
<span class="o_status d-inline-block o_status_green"></span> and which feedback is
moved to the "Refused" column.
<br/>
Use the <a class="fa fa-clock-o"></a> icon to organize your daily activities.
</t>
<t t-name="project.example.consulting" owl="1">
Manage the lifecycle of your project using the kanban view. Add newly acquired projects,
assign them and use the <span class="o_status d-inline-block o_status_green"></span> and
<span class="o_status d-inline-block o_status_red"></span> to define if the project is
ready for the next step.
<br/>
Use the <a class="fa fa-clock-o"></a> icon to organize your daily activities.
</t>
<t t-name="project.example.researchproject" owl="1">
Handle your idea gathering within Tasks of your new Project and discuss them in the chatter of the tasks. Use the
<span class="o_status d-inline-block o_status_green"></span> and <span class="o_status d-inline-block o_status_red"></span>
to signalize what is the current status of your Idea.
<br/>
Use the <a class="fa fa-clock-o"></a> icon to organize your daily activities.
</t>
<t t-name="project.example.tshirtprinting" owl="1">
Communicate with customers on the task using the email gateway. Attach logo designs to the task, so that information flows from
designers to the workers who print the t-shirt. Organize priorities amongst orders using the
<a style="color: gold;" class="fa fa-star"></a> icon.
<br/>
Use the <a class="fa fa-clock-o"></a> icon to organize your daily activities.
</t>
</templates>
@@ -48,7 +48,7 @@ const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_p
content: 'Click "Create" button',
run: 'click',
}, {
trigger: '.o_kanban_quick_create .o_field_widget[name="name"]',
trigger: '.o_kanban_quick_create .o_field_widget[name="name"] input',
content: 'Create Task',
run: 'text Test Create Task',
}, {
@@ -1,4 +1,4 @@
.o_kanban_view.o_rating_rating_kanban {
.o_kanban_renderer .o_rating_rating_kanban {
.o_kanban_record {
width: 350px;
}
+2 -2
View File
@@ -152,7 +152,7 @@
<div class="d-flex flex-row">
<div class="o_rating_kanban_left mr-3">
<h1 class="o_rating_value text-center text-primary" t-esc="val_stars"/>
<i t-foreach="_.range(0, val_integer)" t-as="num"
<i t-foreach="[...Array(val_integer).keys()]" t-as="num" t-key="num"
class="fa fa-star"
aria-label="A star"
role="img"/>
@@ -160,7 +160,7 @@
class="fa fa-star-half-o"
aria-label="Half a star"
role="img"/>
<i t-foreach="_.range(0, empty_star)" t-as="num"
<i t-foreach="[...Array(empty_star).keys()]" t-as="num" t-key="num"
class="fa fa-star text-black-25"
aria-label="A star"
role="img"/>
@@ -55,7 +55,7 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs [REQUIRE FOCUS
},
});
assert.containsOnce(kanban, '.o_kanban_view .o_progressbar:contains(Click to define an invoicing target)')
assert.containsOnce(kanban, '.o_legacy_kanban_view .o_progressbar:contains(Click to define an invoicing target)')
assert.containsN(kanban, '.o_kanban_primary_bottom', 2,
"should have two divs with classname 'o_kanban_primary_bottom'");
@@ -1,7 +1,7 @@
/** @odoo-module */
import { patch } from 'web.utils';
import { formatFloatTime, formatFloat } from "@web/fields/formatters";
import { formatFloatTime, formatFloat } from "@web/views/fields/formatters";
import ProjectRightPanel from '@project/js/right_panel/project_right_panel';
patch(ProjectRightPanel.prototype, '@sale_project/right_panel/project_right_panel', {
@@ -206,7 +206,7 @@
<record id="view_task_form2_inherit_sale_timesheet" model="ir.ui.view">
<field name="name">view.task.form2.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2"/>
<field name="inherit_id" ref="sale_project.view_sale_project_inherit_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='sale_line_id']" position="attributes">
<attribute name="context">{'with_remaining_hours': True, 'with_price_unit': True}</attribute>
@@ -1,7 +1,7 @@
$o-kanban-large-record-width: 400px;
$o-kanban-medium-record-width: 350px;
.o_kanban_view.o_crm_team_kanban {
.o_kanban_renderer.o_crm_team_kanban {
.o_kanban_group:not(.o_column_folded) {
width: $o-kanban-large-record-width + 2*$o-kanban-group-padding;
}
@@ -10,7 +10,7 @@ $o-kanban-medium-record-width: 350px;
}
}
.o_kanban_view.o_crm_team_member_kanban {
.o_kanban_renderer.o_crm_team_member_kanban {
.o_kanban_group:not(.o_column_folded) {
width: $o-kanban-medium-record-width + 2*$o-kanban-group-padding;
min-height: 100px;
+2
View File
@@ -44,10 +44,12 @@ The service is provided by the In App Purchase Odoo platform.
],
'mail.assets_discuss_public': [
'sms/static/src/components/*/*',
('remove', 'sms/static/src/components/phone_field/*'),
],
'web.assets_backend': [
'sms/static/src/js/fields_phone_widget.js',
'sms/static/src/js/fields_sms_widget.js',
'sms/static/src/components/*/*',
],
'web.qunit_suite_tests': [
'sms/static/tests/sms_widget_test.js',
@@ -0,0 +1,12 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { PhoneField } from "@web/views/fields/phone/phone_field";
import { SendSMSButton } from '@sms/components/sms_button/sms_button';
patch(PhoneField, "sms.PhoneField", {
components: {
...PhoneField.components,
SendSMSButton
},
});
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.PhoneField" t-inherit-mode="extension">
<xpath expr="//*[hasclass('o_phone_link')]" position="after">
<t t-if="props.value.length > 0">
<SendSMSButton record="props.record" value="props.value"/>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,23 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
const { Component } = owl;
export class SendSMSButton extends Component {
setup() {
this.action = useService("action");
this.title = this.env._t("Send SMS Text Message");
}
onClick() {
this.action.doAction({
type: "ir.actions.act_window",
name: this.title,
res_id: this.props.record.resId,
res_model: "sms.composer",
target: "new",
views: [[false, "form"]],
});
}
};
SendSMSButton.template = "sms.SendSMSButton";
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sms.SendSMSButton" owl="1">
<a
t-att-title="title"
t-att-href="'sms:'+props.value"
t-on-click.prevent="onClick"
class="ml-3 d-inline-flex align-items-center o_field_phone_sms"
><i class="fa fa-mobile"></i><small class="font-weight-bold ml-1">SMS</small></a>
</t>
</templates>
@@ -1,7 +1,8 @@
odoo.define('stock.stock_traceability_report_backend_tests', function (require) {
"use strict";
const ControlPanel = require('web.ControlPanel');
const LegacyControlPanel = require('web.ControlPanel');
const { ControlPanel } = require("@web/search/control_panel/control_panel");
const dom = require('web.dom');
const StockReportGeneric = require('stock.stock_report_generic');
const testUtils = require('web.test_utils');
@@ -99,6 +100,20 @@ odoo.define('stock.stock_traceability_report_backend_tests', function (require)
});
},
});
patchWithCleanup(LegacyControlPanel.prototype, {
setup() {
this._super();
onMounted(() => {
mountCount = mountCount + 1;
this.__uniqueId = mountCount;
assert.step(`mounted ${this.__uniqueId} (legacy)`);
});
onWillUnmount(() => {
assert.step(`willUnmount ${this.__uniqueId} (legacy)`);
});
},
});
const serverData = {
models: {
partner: {
@@ -147,12 +162,12 @@ odoo.define('stock.stock_traceability_report_backend_tests', function (require)
destroy(webClient);
assert.verifySteps([
'mounted 1',
'willUnmount 1',
'mounted 1 (legacy)',
'willUnmount 1 (legacy)',
'mounted 2',
'willUnmount 2',
'mounted 3',
'willUnmount 3',
'mounted 3 (legacy)',
'willUnmount 3 (legacy)',
]);
});
});
@@ -117,7 +117,7 @@ QUnit.test('activity view: simple activity rendering', async function (assert) {
'should have a table');
var $th1 = $activity.find('table thead tr:first th:nth-child(2)');
assert.containsOnce($th1, 'span:first:contains(Email)', 'should contain "Email" in header of first column');
assert.containsOnce($th1, '.o_kanban_counter', 'should contain a progressbar in header of first column');
assert.containsOnce($th1, '.o_legacy_kanban_counter', 'should contain a progressbar in header of first column');
assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:first'), 'data-original-title', '1 Planned',
'the counter progressbars should be correctly displayed');
assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:nth-child(2)'), 'data-original-title', '1 Today',
@@ -3,10 +3,11 @@ odoo.define('utm.campaing_kanban_examples', function (require) {
var core = require('web.core');
var kanbanExamplesRegistry = require('web.kanban_examples_registry');
const { registry } = require("@web/core/registry");
var _lt = core._lt;
kanbanExamplesRegistry.add('utm_campaign', {
const exampleData = {
ghostColumns: [_lt('Ideas'), _lt('Design'), _lt('Review'), _lt('Send'), _lt('Done')],
applyExamplesText: _lt("Use This For My Campaigns"),
examples: [{
@@ -30,5 +31,8 @@ kanbanExamplesRegistry.add('utm_campaign', {
columns: [_lt('To be Approved'), _lt('Approved'), _lt('Deployed')],
description: _lt("Prepare Campaigns and get them approved before making them go live."),
}],
});
};
kanbanExamplesRegistry.add('utm_campaign', exampleData);
registry.category("kanban_examples").add("utm_campaign", exampleData);
});
+1 -1
View File
@@ -1,4 +1,4 @@
.o_kanban_view.o_utm_kanban {
.o_kanban_renderer.o_utm_kanban {
.o_kanban_group {
padding: 0 ($grid-gutter-width * .25) $grid-gutter-width;
+9 -3
View File
@@ -96,9 +96,10 @@ This module provides the core of the Odoo Web Client.
'web/static/src/core/utils/transitions.scss',
'web/static/src/core/**/*',
'web/static/src/fields/**/*',
'web/static/src/legacy/legacy_fields.scss',
'web/static/src/search/**/*',
'web/static/src/views/**/*',
('remove', 'web/static/src/views/form/button_box/*.scss'),
'web/static/src/webclient/**/*',
('remove', 'web/static/src/webclient/navbar/navbar.scss'), # already in _assets_common_styles
('remove', 'web/static/src/webclient/clickbot/clickbot.js'), # lazy loaded
@@ -141,6 +142,9 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/scss/profiling_qweb_view.scss',
'base/static/src/scss/res_partner.scss',
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/debug_manager.js',
'web/static/src/legacy/legacy_service_provider.js',
@@ -148,6 +152,8 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/legacy_dialog.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_views.js',
'web/static/src/legacy/legacy_fields.js',
'web/static/src/legacy/legacy_view_widgets.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
'web/static/src/legacy/legacy_rpc_error_handler.js',
'web/static/src/legacy/root_widget.js',
@@ -355,7 +361,6 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/scss/keyboard.scss',
'web/static/src/legacy/scss/name_and_signature.scss',
'web/static/src/legacy/scss/web.zoomodoo.scss',
'web/static/src/legacy/scss/color_picker.scss',
'web/static/src/legacy/scss/fontawesome_overridden.scss',
],
'web._assets_common_scripts': [
@@ -490,6 +495,7 @@ This module provides the core of the Odoo Web Client.
# 'web/static/tests/legacy/main_tests.js',
'web/static/tests/helpers/**/*.js',
'web/static/tests/views/helpers.js',
'web/static/tests/search/helpers.js',
'web/static/tests/webclient/**/helpers.js',
'web/static/tests/qunit.js',
@@ -511,10 +517,10 @@ This module provides the core of the Odoo Web Client.
'base/static/tests/base_settings_tests.js',
'web/static/tests/env_tests.js',
'web/static/tests/core/**/*.js',
'web/static/tests/fields/**/*.js',
'web/static/tests/search/**/*.js',
('remove', 'web/static/tests/search/helpers.js'),
'web/static/tests/views/**/*.js',
('remove', 'web/static/tests/views/helpers.js'),
'web/static/tests/webclient/**/*.js',
('remove', 'web/static/tests/webclient/**/helpers.js'),
'web/static/tests/legacy/**/*.js',
@@ -0,0 +1,318 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { useDebounced } from "@web/core/utils/timing";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { usePosition } from "@web/core/position_hook";
const { Component, useExternalListener, useRef, useState, useEffect } = owl;
export class AutoComplete extends Component {
setup() {
this.nextSourceId = 0;
this.nextOptionId = 0;
this.sources = [];
this.state = useState({
navigationRev: 0,
optionsRev: 0,
open: false,
activeSourceOption: null,
});
this.inputRef = useRef("input");
this.root = useRef("root");
this.debouncedOnInput = useDebounced(this.onInput, this.constructor.timeout);
useExternalListener(window, "scroll", this.onWindowScroll, true);
this.hotkey = useService("hotkey");
this.hotkeysToRemove = [];
owl.onWillUpdateProps((nextProps) => {
if (nextProps.forceClose) {
this.inputRef.el.value = nextProps.value;
this.close();
}
});
// position and size
const sourcesListRef = useRef("sourcesList");
useEffect(
() => {
if (sourcesListRef.el) {
sourcesListRef.el.style.width =
this.inputRef.el.getBoundingClientRect().width + "px";
}
},
() => [sourcesListRef.el]
);
usePosition(() => this.inputRef.el, {
popper: "sourcesList",
});
}
get isOpened() {
return this.state.open;
}
open(useInput = false) {
this.state.open = true;
this.loadSources(useInput);
}
close() {
this.state.open = false;
this.state.activeSourceOption = null;
}
loadSources(useInput) {
const sources = [];
const proms = [];
for (const pSource of this.props.sources) {
const source = this.makeSource(pSource);
sources.push(source);
const options = this.loadOptions(
pSource.options,
useInput ? this.inputRef.el.value.trim() : ""
);
if (options instanceof Promise) {
source.isLoading = true;
const prom = options.then((options) => {
source.options = options.map((option) => this.makeOption(option));
source.isLoading = false;
this.state.optionsRev++;
});
proms.push(prom);
} else {
source.options = options.map((option) => this.makeOption(option));
}
}
this.sources = sources;
Promise.all(proms).then(() => {
this.navigate(0);
});
}
loadOptions(options, request) {
if (typeof options === "function") {
return options(request);
} else {
return options;
}
}
makeOption(option) {
return Object.assign(Object.create(option), {
id: ++this.nextOptionId,
});
}
makeSource(source) {
return {
id: ++this.nextSourceId,
options: [],
isLoading: false,
placeholder: source.placeholder,
optionTemplate: source.optionTemplate,
};
}
isActiveSourceOption([sourceIndex, optionIndex]) {
return (
this.state.activeSourceOption &&
this.state.activeSourceOption[0] === sourceIndex &&
this.state.activeSourceOption[1] === optionIndex
);
}
selectOption(indices, params = {}) {
this.inputRef.el.value = "";
const option = this.sources[indices[0]].options[indices[1]];
if (option.unselectable) {
return;
}
this.props.onSelect(option, {
...params,
inputValue: this.inputRef.el.value.trim(),
input: this.inputRef.el,
});
this.close();
}
navigate(direction) {
let step = Math.sign(direction);
if (!step) {
this.state.activeSourceOption = null;
step = 1;
} else {
this.state.navigationRev++;
}
if (this.state.activeSourceOption) {
let [sourceIndex, optionIndex] = this.state.activeSourceOption;
let source = this.sources[sourceIndex];
optionIndex += step;
if (0 > optionIndex || optionIndex >= source.options.length) {
sourceIndex += step;
source = this.sources[sourceIndex];
while (source && source.isLoading) {
sourceIndex += step;
source = this.sources[sourceIndex];
}
if (source) {
optionIndex = step < 0 ? source.options.length - 1 : 0;
}
}
this.state.activeSourceOption = source ? [sourceIndex, optionIndex] : null;
} else {
let sourceIndex = step < 0 ? this.sources.length - 1 : 0;
let source = this.sources[sourceIndex];
while (source && source.isLoading) {
sourceIndex += step;
source = this.sources[sourceIndex];
}
if (source) {
const optionIndex = step < 0 ? source.options.length - 1 : 0;
this.state.activeSourceOption = [sourceIndex, optionIndex];
}
}
}
onInputBlur() {
const value = this.inputRef.el.value;
if (
this.props.autoSelect &&
this.state.activeSourceOption &&
value.length > 0 &&
value !== this.props.value
) {
this.selectOption(this.state.activeSourceOption, { triggeredOnBlur: true });
} else {
this.props.onBlur({
inputValue: value,
});
this.close();
}
}
onInputClick() {
if (!this.isOpened) {
this.open(this.inputRef.el.value.trim() !== this.props.value);
} else {
this.close();
}
}
onInputChange() {
this.props.onChange({
inputValue: this.inputRef.el.value,
});
}
onInput() {
this.props.onInput({
inputValue: this.inputRef.el.value,
});
this.open(true);
}
onInputKeydown(ev) {
const hotkey = getActiveHotkey(ev);
switch (hotkey) {
case "enter":
if (!this.isOpened || !this.state.activeSourceOption) {
return;
}
this.selectOption(this.state.activeSourceOption);
break;
case "escape":
if (!this.isOpened) {
return;
}
this.close();
break;
case "tab":
if (!this.isOpened) {
return;
}
if (
this.props.autoSelect &&
this.state.activeSourceOption &&
(this.state.navigationRev > 0 || this.inputRef.el.value.length > 0)
) {
this.selectOption(this.state.activeSourceOption);
}
this.close();
return;
case "arrowup":
this.navigate(-1);
if (!this.isOpened) {
this.open(true);
}
break;
case "arrowdown":
this.navigate(+1);
if (!this.isOpened) {
this.open(true);
}
break;
default:
return;
}
ev.stopPropagation();
ev.preventDefault();
}
onOptionMouseEnter(indices) {
this.state.activeSourceOption = indices;
}
onOptionMouseLeave() {
this.state.activeSourceOption = null;
}
onOptionClick(indices) {
this.selectOption(indices);
}
onWindowScroll(ev) {
if (this.isOpened && !this.root.el.contains(ev.target)) {
this.close();
}
}
}
Object.assign(AutoComplete, {
template: "web.AutoComplete",
props: {
value: { type: String },
id: { type: String, optional: true },
onSelect: { type: Function },
sources: {
type: Array,
element: {
type: Object,
shape: {
placeholder: { type: String, optional: true },
optionTemplate: { type: String, optional: true },
options: [Array, Function],
},
},
},
placeholder: { type: String, optional: true },
autoSelect: { type: Boolean, optional: true },
onInput: { type: Function, optional: true },
onChange: { type: Function, optional: true },
onBlur: { type: Function, optional: true },
forceClose: { type: Boolean, optional: true },
},
defaultProps: {
placeholder: "",
autoSelect: false,
onInput: () => {},
onChange: () => {},
onBlur: () => {},
forceClose: false,
},
timeout: 250,
});
@@ -0,0 +1,11 @@
.o-autocomplete {
.o-autocomplete--input {
width: 100%;
}
.o-autocomplete--dropdown-menu {
left: 0;
right: 0;
}
}
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.AutoComplete" owl="1">
<div class="o-autocomplete dropdown" t-ref="root">
<input
type="text"
t-att-id="props.id"
class="o-autocomplete--input o_input"
t-att-placeholder="props.placeholder"
t-att-value="props.value"
t-on-blur="onInputBlur"
t-on-click="onInputClick"
t-on-change="onInputChange"
t-on-input="debouncedOnInput"
t-on-keydown="onInputKeydown"
t-ref="input"
/>
<t t-if="isOpened">
<ul class="o-autocomplete--dropdown-menu dropdown-menu ui-widget ui-autocomplete show" t-on-mousedown.prevent="" t-ref="sourcesList">
<t t-foreach="sources" t-as="source" t-key="source.id">
<t t-if="source.isLoading">
<li class="o-autocomplete--dropdown-item ui-menu-item">
<a href="#" class="dropdown-item ui-menu-item-wrapper">
<i class="fa fa-spin fa-circle-o-notch" /> <t t-esc="source.placeholder" />
</a>
</li>
</t>
<t t-else="">
<t t-foreach="source.options" t-as="option" t-key="option.id">
<li
class="o-autocomplete--dropdown-item ui-menu-item"
t-att-class="option.classList"
t-on-mouseenter="() => this.onOptionMouseEnter([source_index, option_index])"
t-on-mouseleave="() => this.onOptionMouseLeave([source_index, option_index])"
t-on-click="() => this.onOptionClick([source_index, option_index])"
>
<a
href="#"
class="dropdown-item ui-menu-item-wrapper"
t-att-class="{ 'ui-state-active': isActiveSourceOption([source_index, option_index]) }"
>
<t t-if="source.optionTemplate">
<t t-call="{{ source.optionTemplate }}" />
</t>
<t t-else="">
<t t-esc="option.label" />
</t>
</a>
</li>
</t>
</t>
</t>
</ul>
</t>
</div>
</t>
</templates>
@@ -52,11 +52,11 @@ Object.defineProperty(browser, "location", {
configurable: true,
});
Object.defineProperty(browser, 'innerHeight', {
Object.defineProperty(browser, "innerHeight", {
get: () => window.innerHeight,
configurable: true,
});
Object.defineProperty(browser, 'innerWidth', {
Object.defineProperty(browser, "innerWidth", {
get: () => window.innerWidth,
configurable: true,
});
@@ -1,6 +1,8 @@
/** @odoo-module **/
const { Component } = owl;
import { useHotkey } from "../hotkeys/hotkey_hook";
const { Component, useRef } = owl;
/**
* Custom checkbox
@@ -9,9 +11,9 @@ const { Component } = owl;
* value="boolean"
* disabled="boolean"
* onChange="_onValueChange"
* >
* >
* Change the label text
* </CheckBox>
* </CheckBox>
*
* @extends Component
*/
@@ -19,6 +21,37 @@ const { Component } = owl;
export class CheckBox extends Component {
setup() {
this.id = `checkbox-comp-${CheckBox.nextId++}`;
this.rootRef = useRef("root");
// Make it toggleable through the Enter hotkey
// when the focus is inside the root element
useHotkey(
"Enter",
({ area }) => {
const oldValue = area.querySelector("input").checked;
this.props.onChange(!oldValue);
},
{ area: () => this.rootRef.el, bypassEditableProtection: true }
);
}
onClick(ev) {
if (ev.composedPath().find((el) => ["INPUT", "LABEL"].includes(el.tagName))) {
// The onChange will handle these cases.
return;
}
// Reproduce the click event behavior as if it comes from the input element.
const input = this.rootRef.el.querySelector("input");
input.focus();
if (!this.props.disabled) {
input.checked = !input.checked;
}
this.props.onChange(input.checked);
}
onChange(ev) {
this.props.onChange(ev.target.checked);
}
}
@@ -27,3 +60,33 @@ CheckBox.nextId = 1;
CheckBox.defaultProps = {
onChange: () => {},
};
CheckBox.props = {
id: {
type: true,
optional: true,
},
disabled: {
type: Boolean,
optional: true,
},
value: {
type: Boolean,
optional: true,
},
slots: {
type: Object,
optional: true,
},
onChange: {
type: Function,
optional: true,
},
className: {
type: String,
optional: true,
},
name: {
type: String,
optional: true,
},
};
@@ -2,15 +2,16 @@
<templates xml:space="preserve">
<t t-name="web.CheckBox" owl="1">
<div class="custom-control custom-checkbox">
<div class="custom-control custom-checkbox" t-att-class="props.className" t-on-click.stop="onClick" t-ref="root">
<input
t-att-id="props.id or id"
type="checkbox"
class="custom-control-input"
t-att-disabled="props.disabled"
t-att-checked="props.value"
t-on-change="(ev) => props.onChange(ev.target.checked)"
/>
t-att-name="props.name"
t-on-change="onChange"
/>
<label t-att-for="props.id or id" class="custom-control-label">
<t t-slot="default"/>
</label>
@@ -0,0 +1,54 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
const { Component, useRef, useState, useExternalListener } = owl;
export class ColorList extends Component {
setup() {
this.colorlistRef = useRef("colorlist");
this.state = useState({ isExpanded: false });
useExternalListener(window, "click", this.onOutsideClick);
}
get colors() {
return this.constructor.COLORS;
}
onColorSelected(id) {
this.props.onColorSelected(id);
this.state.isExpanded = false;
}
onOutsideClick(ev) {
if (this.colorlistRef.el.contains(ev.target)) {
return;
}
this.state.isExpanded = false;
}
onToggle(focus = true) {
this.state.isExpanded = !this.state.isExpanded;
if (focus) {
this.colorlistRef.el.firstElementChild.focus();
}
}
}
ColorList.COLORS = [
_lt("No color"),
_lt("Red"),
_lt("Orange"),
_lt("Yellow"),
_lt("Light blue"),
_lt("Dark purple"),
_lt("Salmon pink"),
_lt("Medium blue"),
_lt("Dark blue"),
_lt("Fuchsia"),
_lt("Green"),
_lt("Purple"),
];
ColorList.template = "web.ColorList";
ColorList.props = {
colors: Array,
isExpanded: { type: Boolean, optional: true },
onColorSelected: Function,
togglerColor: { type: Number, optional: true },
};
@@ -0,0 +1,39 @@
.o_colorlist {
display: flex;
flex-wrap: wrap;
align-items: center;
max-width: 100%;
gap: 10px;
button {
padding: 0;
border: 1px solid white;
box-shadow: 0 0 0 1px #c9ccd2;
width: 22px;
height: 17px;
}
}
// Set all the colors but the "no-color" one
@for $size from 2 through length($o-colors) {
.o_colorlist_item_color_#{$size - 1} {
background-color: nth($o-colors, $size);
}
}
// Set the "no-color", a red bar on white background
.o_colorlist_item_color_0 {
position: relative;
&::before {
content: "";
@include o-position-absolute(-2px, $left: 10px);
display: block;
width: 1px;
height: 19px;
transform: rotate(45deg);
background-color: red;
}
&::after {
background-color: white;
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColorList" owl="1">
<div class="o_colorlist" aria-atomic="true" t-ref="colorlist">
<t t-if="props.togglerColor !== undefined and !state.isExpanded">
<button t-on-click.prevent.stop="onToggle" role="menuitem" t-att-title="colors[props.togglerColor]" t-att-data-color="props.togglerColor" t-att-aria-label="colors[props.togglerColor]" t-attf-class="{{'btn o_colorlist_toggler o_colorlist_item_color_' + props.togglerColor }}"/>
</t>
<t t-else="" t-foreach="props.colors" t-as="colorId" t-key="colorId">
<button t-on-click.prevent.stop="() => this.onColorSelected(colorId)" role="menuitem" t-att-title="colors[colorId]" t-att-data-color="colorId" t-att-aria-label="colors[colorId]" t-attf-class="{{'btn o_colorlist_item_color_' + colorId }}"/>
</t>
</div>
</t>
</templates>
@@ -45,7 +45,7 @@ const FUZZY_NAMESPACES = ["default"];
/**
* @typedef {{
* configByNamespace?: {[namespace]: NamespaceConfig};
* configByNamespace?: {[namespace: string]: NamespaceConfig};
* FooterComponent?: Component;
* providers: Provider[];
* searchValue?: string;
@@ -18,11 +18,8 @@ const { Component, xml } = owl;
*/
/**
* @typedef {{
* activeElement?: HTMLElement;
* @typedef {import("../hotkeys/hotkey_service").HotkeyOptions & {
* category?: string;
* global?: boolean;
* hotkey?: string;
* }} CommandOptions
*/
@@ -1,7 +1,14 @@
/** @odoo-module **/
import {
areDateEquals,
formatDate,
formatDateTime,
parseDate,
parseDateTime,
} from "@web/core/l10n/dates";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
import { useAutofocus } from "@web/core/utils/hooks";
const {
@@ -15,38 +22,47 @@ const {
} = owl;
const { DateTime } = luxon;
const formatters = registry.category("formatters");
const parsers = registry.category("parsers");
let datePickerId = 0;
/**
* @param {DateTime} date
* @returns {moment}
*/
const luxonDateToMomentDate = (date) => {
function luxonDateToMomentDate(date) {
return window.moment(String(date));
};
}
/**
* @param {string} format
* @returns {string}
*/
const luxonFormatToMomentFormat = (format) => {
function luxonFormatToMomentFormat(format) {
return format.replace(/[dy]/g, (x) => x.toUpperCase());
};
}
/**
* @param {string} format
* @returns {boolean}
*/
const isValidStaticFormat = (format) => {
try {
return /^[\d\s/:-]+$/.test(DateTime.local().toFormat(format));
} catch (_err) {
return false;
}
};
function isValidStaticFormat(format) {
return /^[\d\s/:-]+$/.test(DateTime.local().toFormat(format));
}
/**
* @param {Function} fn
* @returns {[any, null] | [null, Error]}
*/
function wrapError(fn) {
return (...args) => {
const result = [null, null];
try {
result[0] = fn(...args);
} catch (_err) {
result[1] = _err;
}
return result;
};
}
/**
* Date picker
@@ -63,7 +79,7 @@ const isValidStaticFormat = (format) => {
*/
export class DatePicker extends Component {
setup() {
this.root = useRef("root");
this.rootRef = useRef("root");
this.inputRef = useRef("input");
this.state = useState({ warning: false });
@@ -73,8 +89,7 @@ export class DatePicker extends Component {
this.datePickerShown = false;
this.initFormat();
this.setDate(this.props);
this.setFormat(this.props);
this.setDateAndFormat(this.props);
useAutofocus();
useExternalListener(window, "scroll", this.onWindowScroll);
@@ -88,43 +103,33 @@ export class DatePicker extends Component {
this.bootstrapDateTimePicker(this.props);
this.updateInput();
window.$(this.root.el).on("show.datetimepicker", () => {
window.$(this.rootRef.el).on("show.datetimepicker", () => {
this.datePickerShown = true;
this.inputRef.el.select();
});
window.$(this.root.el).on("hide.datetimepicker", () => {
window.$(this.rootRef.el).on("hide.datetimepicker", () => {
this.datePickerShown = false;
this.onDateChange({ useStatic: true });
});
window.$(this.root.el).on("error.datetimepicker", () => false);
window.$(this.rootRef.el).on("error.datetimepicker", () => false);
}
onWillUpdateProps(nextProps) {
const pickerParams = {};
let shouldUpdateInput = false;
for (const prop in nextProps) {
const prev = this.props[prop];
const next = nextProps[prop];
if ((prev instanceof DateTime && !prev.equals(next)) || prev !== next) {
if (!areDateEquals(this.props[prop], nextProps[prop])) {
pickerParams[prop] = nextProps[prop];
if (prop === "date") {
this.setDate(nextProps);
shouldUpdateInput = true;
} else if (prop === "format") {
this.setFormat(nextProps);
shouldUpdateInput = true;
}
}
}
if (shouldUpdateInput) {
this.setDateAndFormat(nextProps);
if ("date" in pickerParams || "format" in pickerParams) {
this.updateInput();
}
this.bootstrapDateTimePicker(pickerParams);
}
onWillUnmount() {
window.$(this.root.el).off(); // Removes all jQuery events
window.$(this.rootRef.el).off(); // Removes all jQuery events
this.bootstrapDateTimePicker("destroy");
}
@@ -136,7 +141,7 @@ export class DatePicker extends Component {
return {
format:
!useStatic || isValidStaticFormat(this.format) ? this.format : this.staticFormat,
locale: this.date.locale,
locale: this.props.locale || (this.date && this.date.locale),
timezone: this.isLocal,
};
}
@@ -147,8 +152,8 @@ export class DatePicker extends Component {
initFormat() {
this.defaultFormat = localization.dateFormat;
this.staticFormat = "yyyy/MM/dd";
this.formatValue = formatters.get("date");
this.parseValue = parsers.get("date");
this.formatValue = wrapError(formatDate);
this.parseValue = wrapError(parseDate);
this.isLocal = false;
}
@@ -158,17 +163,10 @@ export class DatePicker extends Component {
* @param {Object} params
* @param {DateTime} params.date
* @param {string} [params.locale]
*/
setDate({ date, locale }) {
this.date = locale ? date.setLocale(locale) : date;
}
/**
* Sets the current format.
* @param {Object} params
* @param {string} [params.format]
*/
setFormat({ format }) {
setDateAndFormat({ date, locale, format }) {
this.date = date && locale ? date.setLocale(locale) : date;
// Fallback to default localization format in `@web/core/l10n/dates.js`.
this.format = format || this.defaultFormat;
}
@@ -179,10 +177,9 @@ export class DatePicker extends Component {
* @param {boolean} [params.useStatic]
*/
updateInput({ useStatic } = {}) {
try {
this.inputRef.el.value = this.formatValue(this.date, this.getOptions(useStatic));
} catch (_err) {
// Do nothing
const [formattedDate] = this.formatValue(this.date, this.getOptions(useStatic));
if (formattedDate) {
this.inputRef.el.value = formattedDate;
}
}
@@ -197,20 +194,20 @@ export class DatePicker extends Component {
bootstrapDateTimePicker(commandOrParams) {
if (typeof commandOrParams === "object") {
const format = isValidStaticFormat(this.format) ? this.format : this.staticFormat;
const params = { ...commandOrParams, format: luxonFormatToMomentFormat(format) };
if (!params.locale && commandOrParams.date) {
params.locale = commandOrParams.date.locale;
}
const params = {
...commandOrParams,
date: this.date || null,
format: luxonFormatToMomentFormat(format),
locale: commandOrParams.locale || (this.date && this.date.locale),
};
for (const prop in params) {
if (params[prop] instanceof DateTime) {
const luxonDate = params[prop];
const momentDate = luxonDateToMomentDate(luxonDate);
params[prop] = momentDate;
params[prop] = luxonDateToMomentDate(params[prop]);
}
}
commandOrParams = params;
}
return window.$(this.root.el).datetimepicker(commandOrParams);
return window.$(this.rootRef.el).datetimepicker(commandOrParams);
}
//---------------------------------------------------------------------
@@ -232,17 +229,26 @@ export class DatePicker extends Component {
* @param {boolean} [params.useStatic]
*/
onDateChange({ useStatic } = {}) {
let date;
try {
date = this.parseValue(this.inputRef.el.value, this.getOptions(useStatic));
} catch (_err) {
const { value } = this.inputRef.el;
const options = this.getOptions(useStatic);
const parsedDate = value && this.parseValue(value, options)[0];
this.state.warning = parsedDate && parsedDate > DateTime.local();
if (value && !parsedDate) {
// Reset to default (= given) date.
}
if (!date || date.equals(this.date)) {
this.updateInput();
} else {
this.state.warning = date > DateTime.local();
this.props.onDateTimeChanged(date);
}
if (!areDateEquals(this.date, parsedDate)) {
this.props.onDateTimeChanged(parsedDate);
}
}
onInputKeydown(ev) {
const hotkey = getActiveHotkey(ev);
if (this.datePickerShown && hotkey === "escape") {
this.bootstrapDateTimePicker("hide");
this.inputRef.el.select();
ev.preventDefault();
ev.stopPropagation();
}
}
@@ -277,7 +283,7 @@ DatePicker.defaultProps = {
DatePicker.props = {
// Components props
onDateTimeChanged: Function,
date: DateTime,
date: { type: [DateTime, { value: false }], optional: true },
warn_future: { type: Boolean, optional: true },
// Bootstrap datepicker options
buttons: {
@@ -313,6 +319,7 @@ DatePicker.props = {
readonly: { type: Boolean, optional: true },
useCurrent: { type: Boolean, optional: true },
widgetParent: { type: String, optional: true },
daysOfWeekDisabled: { type: Array, optional: true },
};
DatePicker.template = "web.DatePicker";
@@ -333,8 +340,8 @@ export class DateTimePicker extends DatePicker {
initFormat() {
this.defaultFormat = localization.dateTimeFormat;
this.staticFormat = "yyyy/MM/dd HH:mm:ss";
this.formatValue = formatters.get("datetime");
this.parseValue = parsers.get("datetime");
this.formatValue = wrapError(formatDateTime);
this.parseValue = wrapError(parseDateTime);
this.isLocal = true;
}
}
@@ -13,10 +13,13 @@
t-ref="input"
t-on-change="() => this.onDateChange()"
t-on-click="onInputClick"
t-on-keydown="onInputKeydown"
/>
<span
t-if="props.warn_future and state.warning"
class="fa fa-exclamation-triangle text-danger o_tz_warning o_datepicker_warning"
data-tooltip="This date is on the future. Make sure it is what you expected."
/>
<span t-if="props.warn_future and state.warning" class="fa fa-exclamation-triangle text-danger o_tz_warning o_datepicker_warning">
<t>This date is on the future. Make sure it is what you expected.</t>
</span>
<span class="o_datepicker_button" />
</div>
</t>
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.DebugMenu.SetDefaultDialog" owl="1">
<Dialog title="this.constructor.title">
<Dialog title="title">
<table style="width: 100%">
<tr>
<td>
@@ -62,7 +62,7 @@
</t>
<t t-name="web.DebugMenu.GetMetadataDialog" owl="1">
<Dialog title="this.constructor.title">
<Dialog title="title">
<table class="table table-sm table-striped">
<tr>
<th>ID:</th>
@@ -92,7 +92,7 @@
</tr>
<tr>
<th>Creation Date:</th>
<td><t t-esc="state.create_date"/></td>
<td><t t-esc="state.createDate"/></td>
</tr>
<tr>
<th>Latest Modification by:</th>
@@ -100,7 +100,7 @@
</tr>
<tr>
<th>Latest Modification Date:</th>
<td><t t-esc="state.write_date"/></td>
<td><t t-esc="state.writeDate"/></td>
</tr>
</table>
</Dialog>
+17 -4
View File
@@ -2,17 +2,29 @@
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { useActiveElement } from "../ui/ui_service";
import { useForwardRefToParent } from "@web/core/utils/hooks";
const { Component, useRef, useChildSubEnv, useState } = owl;
const { Component, useChildSubEnv, useState } = owl;
export class Dialog extends Component {
setup() {
this.modalRef = useRef("modal");
useActiveElement("modal");
this.modalRef = useForwardRefToParent("modalRef");
useActiveElement("modalRef");
this.data = useState(this.env.dialogData);
useHotkey("escape", () => {
this.data.close();
});
useChildSubEnv({ inDialog: true });
this.id = `dialog_${this.data.id}`;
useChildSubEnv({ inDialog: true, dialogId: this.id });
owl.onWillDestroy(() => {
if (this.env.isSmall) {
this.data.scrollToOrigin();
}
});
}
get isFullscreen() {
return this.props.fullscreen || this.env.isSmall;
}
}
Dialog.template = "web.Dialog";
@@ -24,6 +36,7 @@ Dialog.props = {
size: { type: String, optional: true, validate: (s) => ["sm", "md", "lg", "xl"].includes(s) },
technical: { type: Boolean, optional: true },
title: { type: String, optional: true },
modalRef: { type: Function, optional: true },
slots: {
type: Object,
shape: {
+17 -10
View File
@@ -2,30 +2,37 @@
<templates xml:space="preserve">
<t t-name="web.Dialog" owl="1">
<div class="o_dialog" t-att-class="{ o_inactive_modal: !data.isActive }">
<div class="o_dialog" t-att-id="id" t-att-class="{ o_inactive_modal: !data.isActive }">
<div role="dialog" class="modal d-block"
tabindex="-1"
t-att-class="{ o_technical_modal: props.technical, o_modal_full: props.fullscreen }"
t-ref="modal"
t-att-class="{ o_technical_modal: props.technical, o_modal_full: isFullscreen }"
t-ref="modalRef"
>
<div class="modal-dialog" t-attf-class="modal-{{props.size}}">
<div class="modal-content" t-att-class="props.contentClass">
<header t-if="props.header" class="modal-header">
<t t-if="isFullscreen">
<button class="btn fa fa-arrow-left" data-dismiss="modal" aria-label="Close" t-on-click="data.close" />
</t>
<h4 class="modal-title text-break">
<t t-esc="props.title"/>
</h4>
<button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="data.close">×</button>
<t t-if="!isFullscreen">
<button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="data.close">×</button>
</t>
</header>
<main class="modal-body">
<t t-slot="default"/>
</main>
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1">
<t t-slot="footer">
<button class="btn btn-primary" t-on-click="data.close">
<!-- FIXME: WOWL there is a bug on t-portal on owl, in which t-portal don't work on multinode.
To avoid this we place the footer before the body -->
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1" style="order:2">
<t t-slot="footer" close="data.close">
<button class="btn btn-primary o-default-button" t-on-click="data.close">
<t>Ok</t>
</button>
</t>
</footer>
<main class="modal-body">
<t t-slot="default" close="data.close" />
</main>
</div>
</div>
</div>
@@ -3,7 +3,7 @@
import { registry } from "../registry";
import { DialogContainer } from "./dialog_container";
const { reactive } = owl;
const { markRaw, reactive } = owl;
/**
* @typedef {{
@@ -22,7 +22,7 @@ const { reactive } = owl;
export const dialogService = {
/** @returns {DialogServiceInterface} */
start() {
start(env) {
const dialogs = reactive({});
let dialogId = 0;
@@ -51,9 +51,21 @@ export const dialogService = {
const dialog = {
id,
class: dialogClass,
props: { ...props, close },
dialogData: { isActive: true, close },
props: markRaw({ ...props, close }),
dialogData: {
isActive: true,
close,
id,
},
};
if (env.isSmall) {
const scrollOrigin = { top: window.scrollY, left: window.scrollX };
dialog.dialogData.scrollToOrigin = () => {
if (!Object.keys(dialogs).length) {
window.scrollTo(scrollOrigin);
}
};
}
dialogs[id] = dialog;
return close;
+13 -1
View File
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { shallowEqual } from "@web/core/utils/arrays";
import { evaluate, formatAST, parseExpr } from "./py_js/py";
import { toPyValue } from "./py_js/py_utils";
@@ -187,14 +188,25 @@ function matchCondition(record, condition) {
return condition;
}
const [field, operator, value] = condition;
if (typeof field === "string") {
const names = field.split(".");
if (names.length >= 2) {
return matchCondition(record[names[0]], [names.slice(1).join("."), operator, value]);
}
}
const fieldValue = typeof field === "number" ? field : record[field];
switch (operator) {
case "=":
case "==":
if (Array.isArray(fieldValue) && Array.isArray(value)) {
return shallowEqual(fieldValue, value);
}
return fieldValue === value;
case "!=":
case "<>":
return fieldValue !== value;
return JSON.stringify(fieldValue) !== JSON.stringify(value);
case "<":
return fieldValue < value;
case "<=":
@@ -0,0 +1,207 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { DomainSelectorRootNode } from "./domain_selector_root_node";
const { Component } = owl;
export class DomainSelector extends Component {
setup() {
this.nextNodeId = 0;
}
buildTree() {
try {
const domain = new Domain(this.props.value);
const ctx = {
parent: null,
index: 0,
domain: domain.toList(),
get currentElement() {
return ctx.domain[ctx.index];
},
next() {
ctx.index++;
},
getFullDomain() {
return rootNode.computeDomain().toString();
},
};
const rootNode = this.makeRootNode(ctx);
ctx.parent = rootNode;
this.traverseNode(ctx);
return ctx.parent;
} catch (_e) {
// WOWL TODO: rethrow error when not the expected type
return false;
}
}
traverseNode(ctx) {
if (ctx.index < ctx.domain.length) {
if (typeof ctx.currentElement === "string" && ["&", "|"].includes(ctx.currentElement)) {
this.traverseBranchNode(ctx);
} else {
this.traverseLeafNode(ctx);
}
}
}
traverseBranchNode(ctx) {
if (ctx.parent.type !== "branch" || ctx.parent.operator !== ctx.currentElement) {
const node = this.makeBranchNode(ctx, ctx.currentElement, []);
ctx.parent.operands.push(node);
ctx = Object.assign(Object.create(ctx), { parent: node });
}
ctx.next();
this.traverseNode(ctx);
ctx.next();
this.traverseNode(ctx);
}
traverseLeafNode(ctx) {
const condition = ctx.currentElement;
const [leftOperand, operator, rightOperand] = condition;
const node = this.makeLeafNode(ctx, operator, [leftOperand, rightOperand]);
ctx.parent.operands.push(node);
}
makeBranchNode(ctx, operator, operands) {
const updateDomain = () => this.props.update(ctx.getFullDomain());
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "branch",
id: this.nextNodeId++,
operator,
operands,
computeDomain() {
return Domain.combine(
this.operands.map((operand) => operand.computeDomain()),
this.operator === "&" ? "AND" : "OR"
);
},
update(operator) {
this.operator = operator;
updateDomain();
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
updateDomain();
},
delete() {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this), 1);
updateDomain();
},
};
}
makeLeafNode(ctx, operator, operands) {
const updateDomain = () => this.props.update(ctx.getFullDomain());
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "leaf",
id: this.nextNodeId++,
operator,
operands,
computeDomain() {
return new Domain([[this.operands[0], this.operator, this.operands[1]]]);
},
update(changes) {
if ("fieldName" in changes) {
this.operands[0] = changes.fieldName;
}
if ("operator" in changes) {
this.operator = changes.operator;
}
if ("value" in changes) {
this.operands[1] = changes.value;
}
updateDomain();
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
updateDomain();
},
delete() {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this), 1);
updateDomain();
},
};
}
makeRootNode(ctx) {
const updateDomain = (...args) => this.props.update(...args);
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "root",
id: this.nextNodeId++,
operator: "&",
operands: [],
computeDomain() {
return Domain.combine(
this.operands.map((operand) => operand.computeDomain()),
"AND"
);
},
update(newValue, fromDebug) {
if (typeof newValue === "string") {
updateDomain(newValue, fromDebug);
} else if (this.operands.length) {
this.operands[0].update(newValue);
}
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
if (ctx.parent) {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
} else {
this.operands.push(newNode);
}
updateDomain(ctx.getFullDomain());
},
delete() {},
};
}
makeFakeNode(ctx, type) {
const [field, op, value] = this.props.defaultLeafValue;
if (type === "branch") {
return this.makeBranchNode(ctx, ctx.parent.operator === "&" ? "|" : "&", [
this.makeLeafNode(ctx, op, [field, value]),
this.makeLeafNode(ctx, op, [field, value]),
]);
} else {
return this.makeLeafNode(ctx, op, [field, value]);
}
}
}
Object.assign(DomainSelector, {
template: "web._DomainSelector",
components: {
DomainSelectorRootNode,
},
props: {
className: { type: String, optional: true },
resModel: String,
value: String,
readonly: { type: Boolean, optional: true },
update: { type: Function, optional: true },
isDebugMode: { type: Boolean, optional: true },
defaultLeafValue: { type: Array, optional: true },
},
defaultProps: {
readonly: true,
update: () => {},
isDebugMode: false,
defaultLeafValue: ["id", "=", 1],
},
});
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web._DomainSelector" owl="1">
<t t-set="tree" t-value="buildTree()"/>
<t t-if="tree">
<DomainSelectorRootNode
value="props.value"
node="tree"
readonly="props.readonly"
resModel="props.resModel"
isDebugMode="props.isDebugMode"
className="props.className"
/>
</t>
<t t-else="">
<div t-att-class="props.className">This domain is not supported.</div>
</t>
</t>
</templates>
@@ -0,0 +1,30 @@
/** @odoo-module **/
import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator";
import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
import { DomainSelectorLeafNode } from "./domain_selector_leaf_node";
const { Component, useRef } = owl;
export class DomainSelectorBranchNode extends Component {
setup() {
this.root = useRef("root");
}
onHoverDeleteNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_btns", hovering);
}
onHoverInsertLeafNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
}
onHoverInsertBranchNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
}
}
DomainSelectorBranchNode.template = "web.DomainSelectorBranchNode";
DomainSelectorBranchNode.components = {
DomainSelectorBranchNode,
DomainSelectorBranchOperator,
DomainSelectorControlPanel,
DomainSelectorLeafNode,
};
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBranchNode" owl="1">
<div class="o_domain_node o_domain_tree" t-ref="root">
<div class="o_domain_tree_header o_domain_selector_row">
<t t-if="!props.readonly">
<DomainSelectorControlPanel
node="props.node"
onHoverDeleteNodeBtn.bind="onHoverDeleteNodeBtn"
onHoverInsertLeafNodeBtn.bind="onHoverInsertLeafNodeBtn"
onHoverInsertBranchNodeBtn.bind="onHoverInsertBranchNodeBtn"
/>
</t>
<DomainSelectorBranchOperator node="props.node" readonly="props.readonly" />
<span class="ml4">of:</span>
</div>
<div class="o_domain_node_children_container">
<t t-foreach="props.node.operands" t-as="subNode" t-key="subNode.id">
<t t-if="subNode.type === 'branch'">
<DomainSelectorBranchNode t-props="{ ...props, node: subNode }" />
</t>
<t t-elif="subNode.type === 'leaf'">
<DomainSelectorLeafNode t-props="{ ...props, node: subNode }" />
</t>
</t>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,20 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const { Component } = owl;
export class DomainSelectorBranchOperator extends Component {
onOperatorSelected(operator) {
this.props.node.update(operator);
}
}
DomainSelectorBranchOperator.components = {
Dropdown,
DropdownItem,
};
DomainSelectorBranchOperator.template = "web.DomainSelectorBranchOperator";
DomainSelectorBranchOperator.props = {
node: Object,
readonly: Boolean,
};
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBranchOperator" owl="1">
<t t-if="!props.readonly">
<div class="btn-group o_domain_tree_operator_selector" aria-atomic="true">
<Dropdown togglerClass="'btn btn-sm btn-primary o_domain_tree_operator_caret'">
<t t-set-slot="toggler">
<t t-if="props.node.operator === '&amp;'">All</t>
<t t-elif="props.node.operator === '|'">Any</t>
<t t-elif="props.node.operator === '!'">None</t>
</t>
<DropdownItem onSelected="() => this.onOperatorSelected('&amp;')">All</DropdownItem>
<DropdownItem onSelected="() => this.onOperatorSelected('|')">Any</DropdownItem>
</Dropdown>
</div>
</t>
<t t-else="">
<strong>
<t t-if="props.node.operator === '&amp;'">ALL</t>
<t t-elif="props.node.operator === '|'">ANY</t>
<t t-elif="props.node.operator === '!'">NONE</t>
</strong>
</t>
</t>
</templates>
@@ -0,0 +1,39 @@
/** @odoo-module **/
const { Component, toRaw } = owl;
export class DomainSelectorControlPanel extends Component {
deleteNode() {
this.props.node.delete();
}
insertNode(newNodeType) {
toRaw(this.props.node).insert(newNodeType); // FIXME WOWL reactivity
}
onEnterDeleteNodeBtn() {
this.props.onHoverDeleteNodeBtn(true);
}
onLeaveDeleteNodeBtn() {
this.props.onHoverDeleteNodeBtn(false);
}
onEnterInsertLeafNodeBtn() {
this.props.onHoverInsertLeafNodeBtn(true);
}
onLeaveInsertLeafNodeBtn() {
this.props.onHoverInsertLeafNodeBtn(false);
}
onEnterInsertBranchNodeBtn() {
this.props.onHoverInsertBranchNodeBtn(true);
}
onLeaveInsertBranchNodeBtn() {
this.props.onHoverInsertBranchNodeBtn(false);
}
}
DomainSelectorControlPanel.template = "web.DomainSelectorControlPanel";
DomainSelectorControlPanel.props = {
node: Object,
onHoverDeleteNodeBtn: Function,
onHoverInsertLeafNodeBtn: Function,
onHoverInsertBranchNodeBtn: Function,
};
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorControlPanel" owl="1">
<div class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node">
<button
class="btn btn-link text-danger o_domain_delete_node_button"
title="Delete node"
aria-label="Delete node"
t-on-click="deleteNode"
t-on-mouseenter="onEnterDeleteNodeBtn"
t-on-mouseleave="onLeaveDeleteNodeBtn"
>
<i class="fa fa-times"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add node"
aria-label="Add node"
t-on-click="() => this.insertNode('leaf')"
t-on-mouseenter="onEnterInsertLeafNodeBtn"
t-on-mouseleave="onLeaveInsertLeafNodeBtn"
>
<i class="fa fa-plus-circle"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add branch"
aria-label="Add branch"
data-branch="1"
t-on-click="() => this.insertNode('branch')"
t-on-mouseenter="onEnterInsertBranchNodeBtn"
t-on-mouseleave="onLeaveInsertBranchNodeBtn"
>
<i class="fa fa-ellipsis-h"/>
</button>
</div>
</t>
</templates>
@@ -0,0 +1,101 @@
/** @odoo-module **/
import { useModelField } from "@web/core/model_field_selector/model_field_hook";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { registry } from "@web/core/registry";
import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
const { Component, onWillStart, onWillUpdateProps, useRef } = owl;
export class DomainSelectorLeafNode extends Component {
setup() {
this.root = useRef("root");
this.modelField = useModelField();
this.fieldInfo = {
type: "integer",
string: "ID",
};
onWillStart(async () => {
this.fieldInfo = await this.loadField(this.props.resModel, this.props.node.operands[0]);
});
onWillUpdateProps(async (nextProps) => {
this.fieldInfo = await this.loadField(nextProps.resModel, nextProps.node.operands[0]);
});
}
get displayedOperator() {
const op = this.getOperatorInfo(this.props.node.operator);
return op ? op.label : "?";
}
get isValueHidden() {
return this.getOperatorInfo(this.props.node.operator).hideValue;
}
async loadField(resModel, fieldName) {
const chain = await this.modelField.loadChain(resModel, fieldName);
if (!chain[chain.length - 1].field && chain.length > 1) {
return chain[chain.length - 2].field;
}
return (
chain[chain.length - 1].field || {
type: "integer",
string: "ID",
}
);
}
getFieldComponent(type) {
return registry.category("domain_selector/fields").get(type, null);
}
getOperatorInfo(operator) {
return this.getFieldComponent(this.fieldInfo.type)
.getOperators()
.find((op) =>
op.matches({
field: this.fieldInfo,
operator,
value: this.props.node.operands[1],
})
);
}
async onFieldChange(fieldName) {
const changes = { fieldName };
const fieldInfo = await this.loadField(this.props.resModel, fieldName);
const component = this.getFieldComponent(fieldInfo.type);
Object.assign(changes, component.onDidTypeChange(fieldInfo));
this.props.node.update(changes);
}
onOperatorChange(ev) {
const component = this.getFieldComponent(this.fieldInfo.type);
const operatorInfo = component.getOperators()[parseInt(ev.target.value, 10)];
const changes = { operator: operatorInfo.value };
Object.assign(
changes,
operatorInfo.onDidChange(
this.getOperatorInfo(this.props.node.operator),
component.onDidTypeChange
)
);
this.props.node.update(changes);
}
onHoverDeleteNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_btns", hovering);
}
onHoverInsertLeafNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
}
onHoverInsertBranchNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
}
}
Object.assign(DomainSelectorLeafNode, {
template: "web.DomainSelectorLeafNode",
components: {
DomainSelectorControlPanel,
ModelFieldSelector,
},
});
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorLeafNode" owl="1">
<div class="o_domain_node o_domain_leaf o_domain_selector_row" t-ref="root">
<t t-if="props.readonly">
<div class="o_domain_leaf_info">
<ModelFieldSelector fieldName="props.node.operands[0]" resModel="props.resModel" readonly="props.readonly" isDebugMode="props.isDebugMode" />
<t t-if="typeof props.node.operands[1] === 'string'">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<span class="o_domain_leaf_value text-primary"> "<t t-esc="props.node.operands[1]" />"</span>
</t>
<t t-elif="typeof props.node.operands[1] === 'number'">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<span class="o_domain_leaf_value text-primary"> <t t-esc="props.node.operands[1]" /></span>
</t>
<t t-elif="typeof props.node.operands[1] === 'boolean'">
is
<t t-if="(props.node.operator === '=' and props.node.operands[1] === false) or (props.node.operator === '!=' and props.node.operands[1] === true)">
not
</t>
set
</t>
<t t-elif="Array.isArray(props.node.operands[1])">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<t t-foreach="props.node.operands[1]" t-as="value" t-key="value_index">
<span class="o_domain_leaf_value text-primary"> "<t t-esc="value"/>"</span>
<t t-if="!value_last"> or </t>
</t>
</t>
</div>
</t>
<t t-else="">
<DomainSelectorControlPanel
node="props.node"
onHoverDeleteNodeBtn.bind="onHoverDeleteNodeBtn"
onHoverInsertLeafNodeBtn.bind="onHoverInsertLeafNodeBtn"
onHoverInsertBranchNodeBtn.bind="onHoverInsertBranchNodeBtn"
/>
<div class="o_domain_leaf_edition">
<ModelFieldSelector fieldName="props.node.operands[0]" resModel="props.resModel" readonly="props.readonly" update="(name) => this.onFieldChange(name)" isDebugMode="props.isDebugMode" />
<t t-set="field" t-value="getFieldComponent(fieldInfo.type)" />
<t t-if="field">
<div>
<select class="o_domain_leaf_operator_select o_input" t-on-change="onOperatorChange">
<t t-foreach="field.getOperators()" t-as="operator" t-key="operator.value">
<option
t-att-value="operator_index"
t-att-selected="operator.matches({ fieldInfo, operator: props.node.operator, value: props.node.operands[1] })"
t-esc="operator.label"
/>
</t>
</select>
</div>
<t t-if="!isValueHidden">
<t
t-component="field"
t-key="fieldInfo.type"
field="fieldInfo"
operator="getOperatorInfo(props.node.operator)"
value="props.node.operands[1]"
update="(changes) => props.node.update(changes)"
/>
</t>
</t>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,166 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
function onDidChange(action) {
return function (oldOperator, fieldChange) {
if (this.category !== oldOperator.category) {
return action(fieldChange);
}
return {};
};
}
function matchValue() {
return function ({ operator }) {
return operator === this.value;
};
}
const dso = registry.category("domain_selector/operator");
dso.add("=", {
category: "equality",
label: "=",
value: "=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches({ operator, value }) {
return operator === this.value && typeof value !== "boolean";
},
});
dso.add("!=", {
category: "equality",
label: _lt("is not ="),
value: "!=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches({ operator, value }) {
return operator === this.value && typeof value !== "boolean";
},
});
dso.add(">", {
category: "comparison",
label: ">",
value: ">",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add(">=", {
category: "comparison",
label: ">=",
value: ">=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("<", {
category: "comparison",
label: "<",
value: "<",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("<=", {
category: "comparison",
label: "<=",
value: "<=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("ilike", {
category: "like",
label: _lt("contains"),
value: "ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("not ilike", {
category: "like",
label: _lt("does not contain"),
value: "not ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("like", {
category: "like",
label: _lt("like"),
value: "like",
onDidChange: onDidChange(() => ({ value: "" })),
});
dso.add("not like", {
category: "like",
label: _lt("not like"),
value: "not like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("=like", {
category: "like",
label: _lt("=like"),
value: "=like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("=ilike", {
category: "like",
label: _lt("=ilike"),
value: "=ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("child_of", {
category: "relation",
label: _lt("child of"),
value: "child_of",
onDidChange: onDidChange(() => ({ value: 1 })),
matches: matchValue(),
});
dso.add("parent_of", {
category: "relation",
label: _lt("parent of"),
value: "parent_of",
onDidChange: onDidChange(() => ({ value: 1 })),
matches: matchValue(),
});
dso.add("in", {
category: "in",
label: _lt("in"),
value: "in",
onDidChange: onDidChange(() => ({ value: [] })),
matches: matchValue(),
});
dso.add("not in", {
category: "in",
label: _lt("not in"),
value: "not in",
onDidChange: onDidChange(() => ({ value: [] })),
matches: matchValue(),
});
dso.add("set", {
category: "set",
label: _lt("is set"),
value: "set",
hideValue: true,
onDidChange() {
return {
operator: "!=",
value: false,
};
},
matches({ operator, value }) {
return operator === "!=" && typeof value === "boolean";
},
});
dso.add("not set", {
category: "set",
label: _lt("is not set"),
value: "not set",
hideValue: true,
onDidChange() {
return {
operator: "=",
value: false,
};
},
matches({ operator, value }) {
return operator === "=" && typeof value === "boolean";
},
});

Some files were not shown because too many files have changed in this diff Show More