[IMP] web,*: new PivotView component

Part-of: odoo/odoo#73311
Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2021-09-07 10:04:38 +00:00
committed by Aaron Bohy
co-authored by Aaron Bohy Bruno Boi Julien Mougenot Lucas Perais Jorge Pinna Puissant
parent 242b1caa65
commit d1e227b07b
38 changed files with 7322 additions and 154 deletions
+19 -7
View File
@@ -7,6 +7,15 @@ var ListController = require('web.ListController');
var testUtils = require('web.test_utils');
var ListRenderer = require('web.ListRenderer');
var pyUtils = require('web.py_utils');
const registry = require("@web/core/registry");
const { makeFakeUserService } = require("@web/../tests/helpers/mock_services");
const {
toggleFilterMenu,
toggleMenuItem,
toggleMenuItemOption,
toggleComparisonMenu,
toggleFavoriteMenu,
} = require("@web/../tests/search/helpers");
const cpHelpers = testUtils.controlPanel;
const { createWebClient, doAction } = require("@web/../tests/webclient/helpers");
@@ -1053,7 +1062,8 @@ QUnit.test('click on a cell of pivot view inside dashboard', async function (ass
form.destroy();
});
QUnit.test(
// TODO: The button "Add to my dashboard" is not yet developped on the new control panel search view
QUnit.skip(
"correctly save the time ranges of a reporting view in comparison mode",
async function (assert) {
assert.expect(1);
@@ -1090,6 +1100,8 @@ QUnit.test(
}
};
registry.category("services").add("user", makeFakeUserService());
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
@@ -1100,16 +1112,16 @@ QUnit.test(
});
// filter on July 2020
await cpHelpers.toggleFilterMenu(webClient);
await cpHelpers.toggleMenuItem(webClient, "Date");
await cpHelpers.toggleMenuItemOption(webClient, "Date", "July");
await toggleFilterMenu(webClient);
await toggleMenuItem(webClient, "Date");
await toggleMenuItemOption(webClient, "Date", "July");
// compare July 2020 to June 2020
await cpHelpers.toggleComparisonMenu(webClient);
await cpHelpers.toggleMenuItem(webClient, 0);
await toggleComparisonMenu(webClient);
await toggleMenuItem(webClient, 0);
// add the view to the dashboard
await cpHelpers.toggleFavoriteMenu(webClient);
await toggleFavoriteMenu(webClient);
await testUtils.dom.click($(".o_add_to_board > button"));
await testUtils.fields.editInput($(".o_add_to_board input"), "a name");
+1 -1
View File
@@ -648,7 +648,7 @@
<field name="name">event.registration.pivot</field>
<field name="model">event.registration</field>
<field name="arch" type="xml">
<pivot string="Registration" display_quantity="True" sample="1">
<pivot string="Registration" display_quantity="1" sample="1">
<field name="event_id" type="row"/>
</pivot>
</field>
@@ -3,14 +3,12 @@ odoo.define('hr_expense.expenses.tree', function (require) {
var DocumentUploadMixin = require('hr_expense.documents.upload.mixin');
var KanbanController = require('web.KanbanController');
var KanbanView = require('web.KanbanView');
var PivotView = require('web.PivotView');
var ListController = require('web.ListController');
var ListView = require('web.ListView');
var viewRegistry = require('web.view_registry');
var core = require('web.core');
var ListRenderer = require('web.ListRenderer');
var KanbanRenderer = require('web.KanbanRenderer');
var PivotRenderer = require('web.PivotRenderer');
var session = require('web.session');
const config = require('web.config');
@@ -19,7 +19,7 @@
<field name="name">hr.leave.employee.type.report.view.pivot</field>
<field name="model">hr.leave.employee.type.report</field>
<field name="arch" type="xml">
<pivot sample="1" disable_linking="True">
<pivot sample="1" disable_linking="1">
<field name="employee_id" type="row"/>
<field name="number_of_days" type="measure"/>
<field name="leave_type" type="col"/>
@@ -6,7 +6,7 @@
<field name="name">im_livechat.report.channel.pivot</field>
<field name="model">im_livechat.report.channel</field>
<field name="arch" type="xml">
<pivot string="Livechat Support Statistics" disable_linking="True" sample="1">
<pivot string="Livechat Support Statistics" disable_linking="1" sample="1">
<field name="technical_name" type="row"/>
<field name="duration" type="measure"/>
<field name="nbr_message" type="measure"/>
@@ -6,7 +6,7 @@
<field name="name">im_livechat.report.operator.pivot</field>
<field name="model">im_livechat.report.operator</field>
<field name="arch" type="xml">
<pivot string="Livechat Support Statistics" disable_linking="True" sample="1">
<pivot string="Livechat Support Statistics" disable_linking="1" sample="1">
<field name="partner_id" type="row"/>
<field name="duration" type="measure"/>
<field name="nbr_channel" type="measure"/>
@@ -208,7 +208,7 @@
<field name="model">mailing.contact</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<pivot string="Mailing List Contacts" stacked="True" sample="1">
<pivot string="Mailing List Contacts" stacked="1" sample="1">
<field name="create_date" type="row"/>
</pivot>
</field>
+2 -2
View File
@@ -68,9 +68,9 @@
'project/static/src/js/project_graph_view.js',
'project/static/src/js/project_kanban.js',
'project/static/src/js/project_list.js',
'project/static/src/js/project_pivot.js',
'project/static/src/js/project_pivot_view.js',
'project/static/src/js/project_rating_graph_view.js',
'project/static/src/js/project_rating_reporting.js',
'project/static/src/js/project_rating_pivot_view.js',
'project/static/src/js/project_task_kanban_examples.js',
'project/static/src/js/tours/project.js',
'project/static/src/js/project_calendar.js',
@@ -5,7 +5,7 @@
<field name="name">report.project.task.user.pivot</field>
<field name="model">report.project.task.user</field>
<field name="arch" type="xml">
<pivot string="Tasks Analysis" display_quantity="true" sample="1">
<pivot string="Tasks Analysis" display_quantity="1" sample="1">
<field name="project_id" type="row"/>
</pivot>
</field>
@@ -1,13 +0,0 @@
/** @odoo-module **/
import PivotView from 'web.PivotView';
import { ProjectControlPanel } from '@project/js/project_control_panel';
import viewRegistry from 'web.view_registry';
export const ProjectPivotView = PivotView.extend({
config: Object.assign({}, PivotView.prototype.config, {
ControlPanel: ProjectControlPanel,
}),
});
viewRegistry.add('project_pivot', ProjectPivotView);
@@ -0,0 +1,12 @@
/** @odoo-module **/
import { PivotView } from "@web/views/pivot/pivot_view";
import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel";
import { registry } from "@web/core/registry";
const viewRegistry = registry.category("views");
class ProjectPivotView extends PivotView {}
ProjectPivotView.components = { ...PivotView, ControlPanel: ProjectControlPanel };
viewRegistry.add("project_pivot", ProjectPivotView);
@@ -0,0 +1,34 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { PivotView } from "@web/views/pivot/pivot_view";
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
import { registry } from "@web/core/registry";
const viewRegistry = registry.category("views");
const MEASURE_STRINGS = {
parent_res_id: _lt("Project"),
rating: _lt("Rating Value (/5)"),
res_id: _lt("Task"),
};
class ProjectRatingArchParser extends PivotArchParser {
parse() {
const archInfo = super.parse(...arguments);
for (const [key, val] of Object.entries(MEASURE_STRINGS)) {
archInfo.fieldAttrs[key] = {
...archInfo.fieldAttrs[key],
string: val.toString(),
};
}
return archInfo;
}
}
// Would it be not better achiedved by using a proper arch directly?
class ProjectRatingPivotView extends PivotView {}
ProjectRatingPivotView.archParser = ProjectRatingArchParser;
viewRegistry.add("project_rating_pivot", ProjectRatingPivotView);
@@ -1,34 +0,0 @@
/** @odoo-module **/
import { _t } from 'web.core';
import PivotController from 'web.PivotController';
import PivotView from 'web.PivotView';
import viewRegistry from 'web.view_registry';
const ProjectPivotController = PivotController.extend({
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
const measures = JSON.parse(JSON.stringify(this.measures));
if ('res_id' in measures) {
measures.res_id.string = _t('Task');
}
if ('parent_res_id' in measures) {
measures.parent_res_id.string = _t('Project');
}
if ('rating' in measures) {
measures.rating.string = _t('Rating Value (/5)');
}
this.measures = measures;
},
});
const ProjectPivotView = PivotView.extend({
config: Object.assign({}, PivotView.prototype.config, {
Controller: ProjectPivotController,
}),
});
viewRegistry.add('project_rating_pivot', ProjectPivotView);
@@ -4,7 +4,7 @@
<field name="name">product.month.pivot</field>
<field name="model">purchase.report</field>
<field name="arch" type="xml">
<pivot string="Purchase Analysis" display_quantity="true" sample="1">
<pivot string="Purchase Analysis" display_quantity="1" sample="1">
<field name="category_id" type="row"/>
<field name="order_id" type="measure"/>
<field name="untaxed_total" type="measure"/>
+1 -1
View File
@@ -111,7 +111,7 @@
<field name="name">purchase.order.pivot</field>
<field name="model">purchase.order</field>
<field name="arch" type="xml">
<pivot string="Purchase Order" display_quantity="True" sample="1">
<pivot string="Purchase Order" display_quantity="1" sample="1">
<field name="partner_id" type="row"/>
<field name="amount_total" type="measure"/>
</pivot>
+1 -1
View File
@@ -107,7 +107,7 @@
<field name="name">rating.rating.pivot</field>
<field name="model">rating.rating</field>
<field name="arch" type="xml">
<pivot string="Ratings" display_quantity="true" sample="1">
<pivot string="Ratings" display_quantity="1" sample="1">
<field name="rated_partner_id" type="row"/>
<field name="create_date" type="col"/>
<field name="rating" type="measure" string="Rating Value (/5)"/>
@@ -5,7 +5,7 @@
<field name="name">project.profitability.report.pivot</field>
<field name="model">project.profitability.report</field>
<field name="arch" type="xml">
<pivot string="Profitability Analysis" display_quantity="true" sample="1">
<pivot string="Profitability Analysis" display_quantity="1" sample="1">
<field name="project_id" type="row"/>
<field name="amount_untaxed_to_invoice" type="measure"/>
<field name="amount_untaxed_invoiced" type="measure"/>
-1
View File
@@ -114,7 +114,6 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/scss/fields.scss',
'web/static/src/legacy/scss/file_upload.scss',
'web/static/src/legacy/scss/views.scss',
'web/static/src/legacy/scss/pivot_view.scss',
'web/static/src/legacy/scss/graph_view.scss',
'web/static/src/legacy/scss/form_view.scss',
'web/static/src/legacy/scss/list_view.scss',
@@ -0,0 +1,22 @@
/** @odoo-module **/
/**
* @param {Number} value
* @param {Number} comparisonValue
* @returns {Number}
*/
export function computeVariation(value, comparisonValue) {
if (isNaN(value) || isNaN(comparisonValue)) {
return NaN;
}
if (comparisonValue === 0) {
if (value === 0) {
return 0;
} else if (value > 0) {
return 1;
} else {
return -1;
}
}
return (value - comparisonValue) / Math.abs(comparisonValue);
}
@@ -49,7 +49,7 @@
}
}
PivotCustomGroupByItem.template = "web.PivotCustomGroupByItem";
PivotCustomGroupByItem.template = "web.legacy.PivotCustomGroupByItem";
PivotCustomGroupByItem.props = { fields: Array };
export class PivotGroupByMenu extends DropdownMenu {
@@ -98,7 +98,7 @@
}
}
PivotGroupByMenu.template = "web.PivotGroupByMenu";
PivotGroupByMenu.template = "web.legacy.PivotGroupByMenu";
PivotGroupByMenu.components = { PivotCustomGroupByItem };
PivotGroupByMenu.props = {
...DropdownMenu.props,
@@ -303,7 +303,7 @@
}
}
PivotRenderer.template = 'web.PivotRenderer';
PivotRenderer.template = 'web.legacy.PivotRenderer';
PivotRenderer.components = { PivotGroupByMenu };
export default PivotRenderer;
+10 -10
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="web.PivotRenderer" class="o_pivot" owl="1">
<div t-name="web.legacy.PivotRenderer" class="o_pivot" owl="1">
<t t-if="!(props.hasData and props.measures.length) or (props.isSample and !props.isEmbedded)">
<t t-if="props.noContentHelp" t-call="web.ActionHelper" >
<t t-set="noContentHelp" t-value="props.noContentHelp"/>
@@ -13,8 +13,8 @@
<thead>
<tr t-foreach="props.table.headers" t-as="row" t-key="'header_' + row_index">
<t t-foreach="row" t-as="cell" t-key="'header_row_' + cell_index">
<t t-if="cell.measure" t-call="web.PivotMeasure"/>
<t t-elif="cell.isLeaf !== undefined" t-call="web.PivotHeader">
<t t-if="cell.measure" t-call="web.legacy.PivotMeasure"/>
<t t-elif="cell.isLeaf !== undefined" t-call="web.legacy.PivotHeader">
<t t-set="isXAxis" t-value="true"/>
</t>
<th t-else="1" t-att-colspan="cell.width" t-att-rowspan="cell.height"/>
@@ -23,7 +23,7 @@
</thead>
<tbody>
<tr t-foreach="props.table.rows" t-as="row" t-key="'row_' + row_index">
<t t-call="web.PivotHeader">
<t t-call="web.legacy.PivotHeader">
<t t-set="isXAxis" t-value="false"/>
<t t-set="cell" t-value="row"/>
</t>
@@ -54,17 +54,17 @@
</table>
</div>
<t t-name="web.PivotHeader" owl="1">
<t t-name="web.legacy.PivotHeader" owl="1">
<th t-att-colspan="isXAxis ? cell.width : undefined" t-att-rowspan="isXAxis ? cell.height : undefined" t-att-class="{
o_pivot_header_cell_closed: cell.isLeaf,
o_pivot_header_cell_opened: !cell.isLeaf,
}" t-attf-style="{{ isXAxis ? undefined : 'padding-left: ' + _getPadding(cell) + 'px;' }}" t-att-title="cell.label" t-on-click.self.prevent="_onHeaderClick(cell, isXAxis ? 'col' : 'row')">
<span t-on-click.self.prevent="_onHeaderClick(cell, isXAxis ? 'col' : 'row')" t-esc="cell.title"/>
<t t-if="_isClicked(cell.groupId, isXAxis)" t-call="web.PivotGroupBySelection"/>
<t t-if="_isClicked(cell.groupId, isXAxis)" t-call="web.legacy.PivotGroupBySelection"/>
</th>
</t>
<t t-name="web.PivotMeasure" owl="1">
<t t-name="web.legacy.PivotMeasure" owl="1">
<th class="text-muted" t-att-colspan="cell.width" t-att-rowspan="cell.height" t-att-class="{
o_pivot_origin_row: cell.originIndexes,
o_pivot_measure_row: !cell.originIndexes,
@@ -75,7 +75,7 @@
</th>
</t>
<div t-name="web.PivotGroupBySelection" class="dropdown" owl="1">
<div t-name="web.legacy.PivotGroupBySelection" class="dropdown" owl="1">
<PivotGroupByMenu
customGroupBys="customGroupBys"
fields="props.groupableFields"
@@ -85,7 +85,7 @@
/>
</div>
<t t-name="web.PivotGroupByMenu" owl="1">
<t t-name="web.legacy.PivotGroupByMenu" owl="1">
<div class="dropdown-menu o_pivot_field_menu show" data-display="static" role="menu">
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.fieldName">
@@ -133,7 +133,7 @@
</div>
</t>
<t t-name="web.PivotCustomGroupByItem" t-inherit="web.Legacy.CustomGroupByItem" t-inherit-mode="primary" owl="1">
<t t-name="web.legacy.PivotCustomGroupByItem" t-inherit="web.Legacy.CustomGroupByItem" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_generator_menu')]" position="attributes">
<attribute name="t-on-click">_onToggleCustomGroupbyItem</attribute>
</xpath>
@@ -0,0 +1,76 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { XMLParser } from "@web/core/utils/xml";
import { archParseBoolean } from "@web/views/helpers/utils";
export class PivotArchParser extends XMLParser {
parse(arch) {
const archInfo = {
activeMeasures: [], // store the defined active measures
colGroupBys: [], // store the defined group_by used on cols
defaultOrder: null,
fieldAttrs: {},
rowGroupBys: [], // store the defined group_by used on rows
widgets: {}, // wigdets defined in the arch
};
this.visitXML(arch, (node) => {
switch (node.tagName) {
case "pivot": {
if (node.hasAttribute("disable_linking")) {
archInfo.disableLinking = archParseBoolean(
node.getAttribute("disable_linking")
);
}
if (node.hasAttribute("default_order")) {
archInfo.defaultOrder = node.getAttribute("default_order");
}
if (node.hasAttribute("string")) {
archInfo.title = node.getAttribute("string");
}
if (node.hasAttribute("display_quantity")) {
archInfo.displayQuantity = archParseBoolean(
node.getAttribute("display_quantity")
);
}
break;
}
case "field": {
let fieldName = node.getAttribute("name"); // exists (rng validation)
archInfo.fieldAttrs[fieldName] = {};
if (node.hasAttribute("string")) {
archInfo.fieldAttrs[fieldName].string = node.getAttribute("string");
}
if (node.hasAttribute("invisible")) {
const isInvisible = Boolean(evaluateExpr(node.getAttribute("invisible")));
if (isInvisible) {
archInfo.fieldAttrs[fieldName].isInvisible = true;
break;
}
}
if (node.hasAttribute("interval")) {
fieldName += ":" + node.getAttribute("interval");
}
if (node.hasAttribute("widget")) {
archInfo.widgets[fieldName] = node.getAttribute("widget");
}
if (node.getAttribute("type") === "measure" || node.hasAttribute("operator")) {
archInfo.activeMeasures.push(fieldName);
}
if (node.getAttribute("type") === "col") {
archInfo.colGroupBys.push(fieldName);
}
if (node.getAttribute("type") === "row") {
archInfo.rowGroupBys.push(fieldName);
}
break;
}
}
});
return archInfo;
}
}
@@ -0,0 +1,59 @@
/** @odoo-module */
import { GroupByMenu } from "@web/search/group_by_menu/group_by_menu";
import { getIntervalOptions } from "@web/search/utils/dates";
export class PivotGroupByMenu extends GroupByMenu {
/**
* @override
* @returns {Object[]}
*/
get items() {
let items = super.items.filter((i) => !i.custom);
if (items.length === 0) {
items = this.fields;
}
// Add custom groupbys
let groupNumber = 1 + Math.max(0, ...items.map(({ groupNumber: n }) => n));
for (const [fieldName, customGroupBy] of this.props.customGroupBys.entries()) {
items.push({ ...customGroupBy, name: fieldName, groupNumber: groupNumber++ });
}
return items.map((item) => ({
...item,
id: item.id || item.name,
fieldName: item.fieldName || item.name,
description: item.description || item.string,
isActive: false,
options:
item.options || ["date", "datetime"].includes(item.type)
? getIntervalOptions()
: undefined,
}));
}
/**
* @override
* @param {CustomEvent} ev
*/
onAddCustomGroup(ev) {
this.trigger("add-custom-group-by", {
fieldName: ev.detail.fieldName,
groupId: this.props.cell.groupId,
});
}
/**
* @override
* @param {CustomEvent} ev
*/
onGroupBySelected(ev) {
const { itemId, optionId } = ev.detail.payload;
const item = this.items.find(({ id }) => id === itemId);
// Augment payload data
Object.assign(ev.detail.payload, {
fieldName: item.fieldName,
interval: optionId,
groupId: this.props.cell.groupId,
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
/** @odoo-module **/
import { PivotGroupByMenu } from "@web/views/pivot/pivot_group_by_menu";
import { registry } from "@web/core/registry";
import fieldUtils from "web.field_utils";
const { Component } = owl;
const formatterRegistry = registry.category("formatters");
export class PivotRenderer extends Component {
setup() {
this.model = this.props.model;
this.table = this.model.getTable();
}
willUpdateProps() {
this.table = this.model.getTable();
}
/**
* Get the formatted value of the cell.
*
* @private
* @param {Object} cell
* @returns {string} Formatted value
*/
getFormattedValue(cell) {
const field = this.model.meta.measures[cell.measure];
let formatType = this.model.meta.widgets[cell.measure];
if (!formatType) {
const fieldType = field.type;
formatType = fieldType === "many2one" ? "integer" : fieldType;
}
//If the formatter is not found on the registry, search on the legacy fieldUtils.format.
//This must be removed when all the formatters will be on the registry
const formatter = formatterRegistry.get(formatType, null) || fieldUtils.format[formatType];
if (!formatter) {
throw new Error(`${formatType} is not a defined formatter!`);
}
return formatter(cell.value, field);
}
/**
* Get the formatted variation of a cell.
*
* @private
* @param {Object} cell
* @returns {string} Formatted variation
*/
getFormattedVariation(cell) {
if (isNaN(cell.value)) {
return "-";
}
const formatPercentage = formatterRegistry.get("percentage");
return formatPercentage(cell.value, this.model.meta.fields[cell.measure]);
}
/**
* Retrieve the padding of a left header.
*
* @param {Object} cell
* @returns {Number} Padding
*/
getPadding(cell) {
return 5 + cell.indent * 30;
}
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
onCellClicked(cell) {
this.props.onCellClicked(cell);
}
/**
* Handle the adding of a custom groupby (inside the view, not the searchview).
*
* @param {"col"|"row"} type
* @param {CustomEvent} ev
*/
onAddCustomGroupBy(type, ev) {
this.model.addGroupBy({ ...ev.detail, custom: true, type });
}
/**
* Handle the selection of a groupby dropdown item.
*
* @param {"col"|"row"} type
* @param {CustomEvent} ev
*/
onDropdownItemSelected(type, ev) {
this.model.addGroupBy({ ...ev.detail.payload, type });
}
/**
* Handle a click on a header cell.
*
* @param {Object} cell
* @param {string} type col or row
*/
onHeaderClick(cell, type) {
if (cell.isLeaf && cell.isFolded) {
this.model.expandGroup(cell.groupId, type);
} else if (!cell.isLeaf) {
this.model.closeGroup(cell.groupId, type);
}
}
/**
* Handle a click on a measure cell.
*
* @param {Object} cell
*/
onMeasureClick(cell) {
this.model.sortRows({
groupId: cell.groupId,
measure: cell.measure,
order: (cell.order || "desc") === "asc" ? "desc" : "asc",
originIndexes: cell.originIndexes,
});
}
/**
* Hover the column in which the mouse is.
*
* @param {MouseEvent} ev
*/
onMouseEnter(ev) {
var index = [...ev.currentTarget.parentNode.children].indexOf(ev.currentTarget);
if (ev.currentTarget.tagName === "TH") {
index += 1;
}
this.el
.querySelectorAll("td:nth-child(" + (index + 1) + ")")
.forEach((elt) => elt.classList.add("o_cell_hover"));
}
/**
* Remove the hover on the columns.
*/
onMouseLeave() {
this.el
.querySelectorAll(".o_cell_hover")
.forEach((elt) => elt.classList.remove("o_cell_hover"));
}
}
PivotRenderer.template = "web.PivotRenderer";
PivotRenderer.components = { PivotGroupByMenu };
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.PivotRenderer" owl="1">
<div class="o_pivot">
<table class="table-hover table-sm table-bordered" t-att-class="{ o_enable_linking: !model.meta.disableLinking, o_sample_data_disabled: model.meta.useSampleModel }">
<thead>
<tr t-foreach="table.headers" t-as="row" t-key="'header_' + row_index">
<t t-foreach="row" t-as="cell" t-key="'header_row_' + cell_index">
<t t-if="cell.measure" t-call="web.PivotMeasure"/>
<t t-elif="cell.isLeaf !== undefined" t-call="web.PivotHeader">
<t t-set="isXAxis" t-value="true"/>
</t>
<th t-else="" t-att-colspan="cell.width" t-att-rowspan="cell.height"/>
</t>
</tr>
</thead>
<tbody>
<tr t-foreach="table.rows" t-as="row" t-key="'row_' + row_index">
<t t-call="web.PivotHeader">
<t t-set="isXAxis" t-value="false"/>
<t t-set="cell" t-value="row"/>
</t>
<t t-foreach="row.subGroupMeasurements" t-as="cell" t-key="'row_cell_' + cell_index">
<td class="o_pivot_cell_value text-right" t-att-class="{
o_empty: cell.value === undefined,
'font-weight-bold': cell.isBold,
}" t-on-click="onCellClicked(cell)"
t-on-mouseover="onMouseEnter" t-on-mouseout="onMouseLeave">
<t t-if="cell.value !== undefined">
<div t-if="cell.originIndexes.length > 1" class="o_variation" t-att-class="{
o_positive: cell.value &gt; 0,
o_negative: cell.value &lt; 0,
o_null: cell.value === 0,
}" t-esc="getFormattedVariation(cell)"/>
<div t-elif="model.meta.measures[cell.measure].type === 'boolean'" class="o_value">
<CheckBox disabled="true" value="cell.value" />
</div>
<div t-else="1" class="o_value" t-esc="getFormattedValue(cell)"/>
</t>
</td>
</t>
</tr>
</tbody>
</table>
</div>
</t>
<t t-name="web.PivotHeader" owl="1">
<th t-att-colspan="isXAxis ? cell.width : undefined" t-att-rowspan="isXAxis ? cell.height : undefined" t-att-class="{
o_pivot_header_cell_closed: cell.isLeaf,
o_pivot_header_cell_opened: !cell.isLeaf,
}" t-attf-style="{{ isXAxis ? undefined : 'padding-left: ' + getPadding(cell) + 'px;' }}" t-att-data-tooltip="cell.label" data-tooltip-position="right" t-on-click.prevent="onHeaderClick(cell, isXAxis ? 'col' : 'row')">
<t t-if="cell.isLeaf and !cell.isFolded">
<PivotGroupByMenu
tag="'span'"
toggler="'parent'"
cell="cell"
customGroupBys="model.meta.customGroupBys"
showActiveItems="false"
t-on-click.stop=""
t-on-dropdown-item-selected="onDropdownItemSelected(isXAxis ? 'col' : 'row')"
t-on-add-custom-group-by="onAddCustomGroupBy(isXAxis ? 'col' : 'row')"
/>
</t>
<span t-esc="cell.title"/>
</th>
</t>
<t t-name="web.PivotMeasure" owl="1">
<th class="text-muted" t-att-colspan="cell.width" t-att-rowspan="cell.height" t-att-class="{
o_pivot_origin_row: cell.originIndexes,
o_pivot_measure_row: !cell.originIndexes,
o_pivot_sort_order_asc: cell.order === 'asc',
o_pivot_sort_order_desc: cell.order === 'desc',
}" t-on-click.prevent="onMeasureClick(cell)" t-on-mouseover="onMouseEnter" t-on-mouseout="onMouseLeave">
<t t-esc="cell.title"/>
</th>
</t>
</templates>
@@ -0,0 +1,186 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { download } from "@web/core/network/download";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { SearchPanel } from "@web/search/search_panel/search_panel";
import { useModel } from "@web/views/helpers/model";
import { standardViewProps } from "@web/views/helpers/standard_view_props";
import { useSetupView } from "@web/views/helpers/view_hook";
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
import { PivotModel } from "@web/views/pivot/pivot_model";
import { PivotRenderer } from "@web/views/pivot/pivot_renderer";
const viewRegistry = registry.category("views");
const { Component } = owl;
export class PivotView extends Component {
setup() {
this.actionService = useService("action");
let modelParams = {};
if (this.props.state) {
modelParams.data = this.props.state.data;
modelParams.meta = this.props.state.meta;
} else {
const { arch } = this.props;
// parse arch
const archInfo = new this.constructor.archParser().parse(arch);
if (!archInfo.activeMeasures.length || archInfo.displayQuantity) {
archInfo.activeMeasures.unshift("__count");
}
modelParams.meta = {
activeMeasures: archInfo.activeMeasures,
additionalMeasures: this.props.additionalMeasures,
colGroupBys: archInfo.colGroupBys,
defaultOrder: archInfo.defaultOrder,
disableLinking: Boolean(archInfo.disableLinking),
fields: this.props.fields,
fieldAttrs: archInfo.fieldAttrs,
resModel: this.props.resModel,
rowGroupBys: archInfo.rowGroupBys,
title: this.props.title || archInfo.title || this.env._t("Untitled"),
useSampleModel: Boolean(this.props.useSampleModel),
widgets: archInfo.widgets,
};
}
this.model = useModel(this.constructor.Model, modelParams);
useSetupView({
exportLocalState: () => {
const { data, meta } = this.model;
return { data, meta };
},
saveParams: () => this.saveParams(), // FIXME: rename this
});
}
/**
* @returns {Object}
*/
saveParams() {
return {
context: {
pivot_measures: this.model.meta.activeMeasures,
pivot_column_groupby: this.model.meta.fullColGroupBys,
pivot_row_groupby: this.model.meta.fullRowGroupBys,
},
};
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Exports the current pivot table data in a xls file. For this, we have to
* serialize the current state, then call the server /web/pivot/export_xlsx.
* Force a reload before exporting to ensure to export up-to-date data.
*/
onDownloadButtonClicked() {
if (this.model.getTableWidth() > 16384) {
throw new Error(
this.env._t(
"For Excel compatibility, data cannot be exported if there are more than 16384 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures."
)
);
}
const table = this.model.exportData();
download({
url: "/web/pivot/export_xlsx",
data: { data: JSON.stringify(table) },
});
}
/**
* Expands all groups
*/
onExpandButtonClicked() {
this.model.expandAll();
}
/**
* Flips axis
*/
onFlipButtonClicked() {
this.model.flip();
}
/**
* Toggles the given measure
*
* @param {CustomEvent} ev
*/
onMeasureSelected(ev) {
this.model.toggleMeasure(ev.detail.payload.measure);
}
/**
* @param {CustomEvent} ev
*/
onOpenView(cell) {
if (cell.value === undefined || this.model.meta.disableLinking) {
return;
}
const context = Object.assign({}, this.model.searchParams.context);
Object.keys(context).forEach((x) => {
if (x === "group_by" || x.startsWith("search_default_")) {
delete context[x];
}
});
// retrieve form and list view ids from the action
this.views = ["list", "form"].map((viewType) => {
const view = this.props.info.views.find((view) => view[1] === viewType);
return [view ? view[0] : false, viewType];
});
const group = {
rowValues: cell.groupId[0],
colValues: cell.groupId[1],
originIndex: cell.originIndexes[0],
};
const domain = this.model.getGroupDomain(group);
this.actionService.doAction({
type: "ir.actions.act_window",
name: this.model.meta.title,
res_model: this.props.resModel,
views: this.views,
view_mode: "list",
target: "current",
context,
domain,
});
}
}
PivotView.template = "web.PivotView";
PivotView.buttonTemplate = "web.PivotView.Buttons";
PivotView.components = { ControlPanel, SearchPanel, Renderer: PivotRenderer };
PivotView.props = {
...standardViewProps,
additionalMeasures: { type: Array, elements: String, optional: 1 },
display: { type: Object, optional: 1 },
title: { type: String, optional: 1 },
};
PivotView.defaultProps = {
additionalMeasures: [],
display: {},
};
PivotView.Model = PivotModel;
PivotView.archParser = PivotArchParser;
PivotView.type = "pivot";
PivotView.display_name = _lt("Pivot");
PivotView.icon = "fa-table";
PivotView.multiRecord = true;
PivotView.searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"];
viewRegistry.add("pivot", PivotView);
@@ -1,5 +1,4 @@
.o_pivot {
.o_pivot_cell_value {
font-size: 1em;
.o_comparison {
@@ -16,6 +15,13 @@
}
table {
th > .o_dropdown > .o_dropdown_toggler {
background: none!important;
border: none!important;
padding: 0!important;
// width: 100%!important;
// text-align: left!important;
}
background-color: $o-view-background-color;
width: auto; // bootstrap override
@@ -108,8 +114,8 @@
top: 10px;
}
.o_pivot_field_menu .dropdown-item.disabled {
color: $text-muted;
cursor: default;
}
// .o_pivot_field_menu .dropdown-item.disabled {
// color: $text-muted;
// cursor: default;
// }
}
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.PivotView.Buttons" owl="1">
<div class="btn-group" role="toolbar" aria-label="Main actions" t-on-dropdown-item-selected="onMeasureSelected">
<t t-call="web.ReportViewMeasures">
<t t-set="measures" t-value="model.meta.measures"/>
<t t-set="activeMeasures" t-value="model.meta.activeMeasures"/>
</t>
</div>
<div class="btn-group" role="toolbar" aria-label="Pivot settings">
<t t-set="noDataDisplayed" t-value="!model.hasData() || !model.meta.activeMeasures.length"/>
<button class="btn btn-secondary fa fa-exchange o_pivot_flip_button" t-on-click="onFlipButtonClicked" data-tooltip="Flip axis" aria-label="Flip axis" t-attf-disabled="{{noDataDisplayed ? 'disabled' : false}}"/>
<button class="btn btn-secondary fa fa-arrows o_pivot_expand_button" t-on-click="onExpandButtonClicked" data-tooltip="Expand all" aria-label="Expand all" t-attf-disabled="{{noDataDisplayed ? 'disabled' : false}}"/>
<button class="btn btn-secondary fa fa-download o_pivot_download" t-on-click="onDownloadButtonClicked" data-tooltip="Download xlsx" aria-label="Download xlsx" t-attf-disabled="{{noDataDisplayed ? 'disabled' : false}}"/>
</div>
</t>
<t t-name="web.PivotView" owl="1">
<div class="o_pivot_view" t-att-class="{o_view_sample_data: model.meta.useSampleModel }">
<ControlPanel t-if="env.searchModel.display.controlPanel" t-props="props.info">
<t t-set-slot="control-panel-bottom-left">
<t t-call="{{ constructor.buttonTemplate }}"/>
</t>
</ControlPanel>
<div class="o_content">
<SearchPanel t-if="env.searchModel.display.searchPanel" />
<t t-set="displayNoContent" t-value="
props.info.noContentHelp !== false and (
!(model.hasData() and model.meta.activeMeasures.length) or
model.meta.useSampleModel
)" />
<t t-if="displayNoContent">
<t t-if="props.info.noContentHelp" t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.info.noContentHelp"/>
</t>
<t t-else="" t-call="web.NoContentHelper"/>
</t>
<t t-if="model.hasData() and model.meta.activeMeasures.length">
<Renderer model="model" onCellClicked="cell => onOpenView(cell)"/>
</t>
</div>
</div>
</t>
</templates>
@@ -15,6 +15,7 @@ import {
fakeCommandService,
makeFakeDialogService,
makeFakeLocalizationService,
makeFakeUserService,
} from "../../helpers/mock_services";
import { click, getFixture, legacyExtraNextTick, patchWithCleanup } from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "../../webclient/helpers";
@@ -357,6 +358,7 @@ QUnit.module("DebugMenu", (hooks) => {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("view").add("editViewItem", editView);
const serverData = getActionManagerServerData();
@@ -330,6 +330,9 @@ export class MockServer {
}
// generate a field_view_get result
const fields = Object.assign({}, this.models[modelName].fields);
for (const fieldName in fields) {
fields[fieldName].name = fieldName;
}
// var viewOptions = params.viewOptions || {};
const fvg = _fieldsViewGet({
arch,
@@ -726,7 +729,7 @@ export class MockServer {
let aggregatedFields = [];
// if no fields have been given, the server picks all stored fields
if (kwargs.fields.length === 0) {
aggregatedFields = Object.keys(this.models[modelName].fields).filter(
aggregatedFields = Object.keys(fields).filter(
(fieldName) => !groupByFieldNames.includes(fieldName)
);
} else {
@@ -1499,7 +1502,11 @@ export class MockServer {
fieldNames = [...new Set(fieldNames.concat(["id"]))];
const { context } = params;
const active_test = context && "active_test" in context ? context.active_test : true;
let records = this.getRecords(params.model, params.domain || [], { active_test });
let records = this.getRecords(
params.model,
params.domain || [],
Object.assign({}, params.context, { active_test })
);
if (params.sort) {
// warning: only consider first level of sort
params.sort = params.sort.split(",")[0];
@@ -246,6 +246,17 @@ export function makeFakeDialogService(addDialog) {
};
}
export function makeFakeUserService(hasGroup = () => false) {
return {
...userService,
start() {
const fakeUserService = userService.start(...arguments);
fakeUserService.hasGroup = hasGroup;
return fakeUserService;
},
};
}
export const mocks = {
company: () => companyService,
command: () => fakeCommandService,
@@ -2,9 +2,11 @@
import testUtils from "web.test_utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
import { Model } from "web.Model";
import Registry from "web.Registry";
import SearchBar from "web.SearchBar";
import { registry } from "@web/core/registry";
let serverData;
@@ -321,6 +323,8 @@ QUnit.module("Search Bar (legacy)", (hooks) => {
</pivot>
`;
registry.category("services").add("user", makeFakeUserService());
let rpcs;
const webClient = await createWebClient({
serverData,
@@ -6,6 +6,8 @@ var PivotView = require('web.PivotView');
const PivotController = require("web.PivotController");
var testUtils = require('web.test_utils');
var testUtilsDom = require('web.test_utils_dom');
const { registry } = require('@web/core/registry');
const legacyViewRegistry = require('web.view_registry');
const { createWebClient, doAction } = require('@web/../tests/webclient/helpers');
@@ -31,6 +33,9 @@ let serverData;
QUnit.module('Views', {
beforeEach: function () {
registry.category("views").remove("pivot"); // remove new pivot from registry
legacyViewRegistry.add("pivot", PivotView); // add legacy pivot -> will be wrapped and added to new registry
this.data = {
partner: {
fields: {
@@ -117,7 +122,7 @@ QUnit.module('Views', {
serverData = { models: this.data };
},
}, function () {
QUnit.module('PivotView');
QUnit.module('Legacy PivotView');
QUnit.test('simple pivot rendering', async function (assert) {
assert.expect(3);
@@ -2986,67 +2991,6 @@ QUnit.module('Views', {
pivot.destroy();
});
QUnit.test('Navigation list view for a group and back with breadcrumbs', async function (assert) {
assert.expect(16);
// create a webClient to test the interactions with the search view
serverData.views = {
'partner,false,pivot': '<pivot>' +
'<field name="customer" type="row"/>' +
'</pivot>',
'partner,false,search': '<search><filter name="bayou" string="Bayou" domain="[(\'foo\',\'=\', 12)]"/></search>',
'partner,false,list': '<tree><field name="foo"/></tree>',
'partner,false,form': '<form><field name="foo"/></form>',
};
let readGroupCount = 0;
const mockRPC = (route, args) => {
if (args.method === 'read_group') {
assert.step('read_group');
const domain = args.kwargs.domain;
if ([0,1].indexOf(readGroupCount) !== -1) {
assert.deepEqual(domain, [], 'domain empty');
} else if ([2,3,4,5].indexOf(readGroupCount) !== -1) {
assert.deepEqual(domain, [['foo', '=', 12]],
'domain conserved when back with breadcrumbs');
}
readGroupCount++;
}
if (route === '/web/dataset/search_read') {
assert.step('search_read');
const domain = args.domain;
assert.deepEqual(domain, ['&', ['customer', '=', 1], ['foo', '=', 12]],
'list domain is correct');
}
};
const webClient = await createWebClient({serverData, mockRPC});
await doAction(webClient, {
res_model: 'partner',
type: 'ir.actions.act_window',
views: [[false, 'pivot']],
});
await cpHelpers.toggleFilterMenu(webClient);
await cpHelpers.toggleMenuItem(webClient, 0);
await testUtils.nextTick();
await testUtilsDom.click($(webClient.el).find('.o_pivot_cell_value:nth(1)'));
await testUtils.nextTick();
assert.containsOnce(webClient, '.o_list_view');
await testUtilsDom.click($(webClient.el).find('.o_control_panel ol.breadcrumb li.breadcrumb-item').eq(0));
assert.verifySteps([
'read_group', 'read_group',
'read_group', 'read_group',
'search_read',
'read_group', 'read_group']);
});
QUnit.test('Cell values are kept when flippin a pivot view in comparison mode', async function (assert) {
assert.expect(2);
@@ -10,8 +10,11 @@ const SearchPanel = require("web.searchPanel");
const cpHelpers = testUtils.controlPanel;
const createView = testUtils.createView;
const { makeFakeUserService } = require("@web/../tests/helpers/mock_services");
const { createWebClient, doAction } = require('@web/../tests/webclient/helpers');
const { legacyExtraNextTick } = require("@web/../tests/helpers/utils");
const { registry } = require("@web/core/registry");
const { PivotView } = require("@web/views/pivot/pivot_view");
/**
* Return the list of counters displayed in the search panel (if any).
@@ -2336,6 +2339,7 @@ QUnit.module('Views', {
QUnit.test('search panel is available on list and kanban by default', async function (assert) {
assert.expect(8);
registry.category("services").add("user", makeFakeUserService());
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
@@ -2359,7 +2363,7 @@ QUnit.module('Views', {
assert.containsNone(webClient, '.o_content .o_search_panel');
});
QUnit.test('search panel with view_types attribute', async function (assert) {
QUnit.skip('search panel with view_types attribute', async function (assert) {
assert.expect(6);
serverData.views['partner,false,search'] =
@@ -2371,6 +2375,7 @@ QUnit.module('Views', {
</search>`;
registry.category("views").add("pivot", PivotView, { force: true });
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
@@ -2486,9 +2491,10 @@ QUnit.module('Views', {
]);
});
QUnit.test('search panel filters are kept when switching to a view with no search panel', async function (assert) {
QUnit.skip('search panel filters are kept when switching to a view with no search panel', async function (assert) {
assert.expect(13);
registry.category("views").add("pivot", PivotView, { force: true });
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
+1 -1
View File
@@ -1,9 +1,9 @@
/** @odoo-module **/
import { View } from "@web/views/view";
import { getFixture } from "@web/../tests/helpers/utils";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { View } from "@web/views/view";
import { _fieldsViewGet } from "../helpers/mock_server";
import { addLegacyMockEnvironment } from "../webclient/helpers";
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,7 @@ import { errorService } from "../../../src/core/errors/error_service";
import { RPCError } from "@web/core/network/rpc_service";
import { registerCleanup } from "../../helpers/cleanup";
import { WarningDialog } from "@web/core/errors/error_dialogs";
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
let serverData;
const serviceRegistry = registry.category("services");
@@ -2304,6 +2305,8 @@ QUnit.module("ActionManager", (hooks) => {
assert.step(args.method);
}
};
registry.category("services").add("user", makeFakeUserService());
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.doesNotHaveClass(