[MERGE] *: new OWL reporting views
In this PR, we introduce the new OWL view infrastructure and convert the graph and pivot views to OWL. More details are given in each commit. closes odoo/odoo#73311 Related: odoo/enterprise#19469 Signed-off-by: Aaron Bohy (aab) <aab@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: stefanorigano (SRI) <sri@odoo.com>
@@ -31,14 +31,12 @@ Re-implement Odoo's file import system:
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_qweb': [
|
||||
'base_import/static/src/xml/**/*',
|
||||
'base_import/static/src/**/*.xml',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'base_import/static/src/scss/base_import.scss',
|
||||
'base_import/static/src/scss/icons.scss',
|
||||
'base_import/static/lib/javascript-state-machine/state-machine.js',
|
||||
'base_import/static/src/js/import_action.js',
|
||||
'base_import/static/src/js/import_menu.js',
|
||||
'base_import/static/src/**/*.scss',
|
||||
'base_import/static/src/**/*.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'base_import/static/tests/**/*',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { Component } = owl;
|
||||
const favoriteMenuRegistry = registry.category("favoriteMenu");
|
||||
|
||||
/**
|
||||
* 'Import records' menu
|
||||
*
|
||||
* This component is used to import the records for particular model.
|
||||
* @extends Component
|
||||
*/
|
||||
export class ImportRecords extends Component {
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Protected
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
importRecords() {
|
||||
const { context, resModel } = this.env.searchModel;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "import",
|
||||
params: { model: resModel, context }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ImportRecords.template = "base_import.ImportRecords";
|
||||
|
||||
const importRecordsItem = {
|
||||
Component: ImportRecords,
|
||||
groupNumber: 4,
|
||||
isDisplayed: ({ isSmall, searchModel }) =>
|
||||
!isSmall &&
|
||||
searchModel.action.type === "ir.actions.act_window" &&
|
||||
["kanban", "list"].includes(searchModel.view.type)
|
||||
// TODO: add arch info to searchModel?
|
||||
// !!JSON.parse(env.view.arch.attrs.import || "1") &&
|
||||
// !!JSON.parse(env.view.arch.attrs.create || "1"),
|
||||
};
|
||||
|
||||
favoriteMenuRegistry.add("import-menu", importRecordsItem, { sequence: 1 });
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="base_import.ImportRecords" owl="1">
|
||||
<DropdownItem class="o_import_menu dropdown-item" t-on-click="importRecords">
|
||||
Import records
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
Before Width: | Height: | Size: 216 B After Width: | Height: | Size: 216 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -186,7 +186,7 @@
|
||||
padding-right: 24px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
background-image: url('/base_import/static/src/img/ui/dropdown_arrow_small.png');
|
||||
background-image: url('/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png');
|
||||
background-position: center right;
|
||||
background-repeat: no-repeat;
|
||||
appearance: none;
|
||||
@@ -33,7 +33,7 @@
|
||||
border: 1px solid darkgrey;
|
||||
}
|
||||
&:after{
|
||||
background-image: url('/base_import/static/src/img/ui/studio_icons.png');
|
||||
background-image: url('/base_import/static/src/legacy/img/ui/studio_icons.png');
|
||||
background-repeat: no-repeat;
|
||||
content: '';
|
||||
width: 20px;
|
||||
@@ -21,15 +21,14 @@ Allows users to create custom dashboard.
|
||||
'application': True,
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'board/static/src/scss/dashboard.scss',
|
||||
'board/static/src/js/board_view.js',
|
||||
'board/static/src/js/add_to_board_menu.js',
|
||||
'board/static/src/**/*.scss',
|
||||
'board/static/src/**/*.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'board/static/tests/**/*',
|
||||
],
|
||||
'web.assets_qweb': [
|
||||
'board/static/src/xml/**/*',
|
||||
'board/static/src/**/*.xml',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useAutofocus, useService } from "@web/core/utils/hooks";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
|
||||
const { Component, useState } = owl;
|
||||
const favoriteMenuRegistry = registry.category("favoriteMenu");
|
||||
|
||||
/**
|
||||
* 'Add to board' menu
|
||||
*
|
||||
* Component consisiting of a toggle button, a text input and an 'Add' button.
|
||||
* The first button is simply used to toggle the component and will determine
|
||||
* whether the other elements should be rendered.
|
||||
* The input will be given the name (or title) of the view that will be added.
|
||||
* Finally, the last button will send the name as well as some of the action
|
||||
* properties to the server to add the current view (and its context) to the
|
||||
* user's dashboard.
|
||||
* This component is only available in actions of type 'ir.actions.act_window'.
|
||||
* @extends Component
|
||||
*/
|
||||
export class AddToBoard extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.rpc = useService("rpc");
|
||||
this.state = useState({ name: this.env.searchModel.displayName });
|
||||
|
||||
useAutofocus();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Protected
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
async addToBoard() {
|
||||
const {
|
||||
action,
|
||||
displayName,
|
||||
domain,
|
||||
comparison,
|
||||
context,
|
||||
groupBy,
|
||||
orderedBy,
|
||||
view
|
||||
} = this.env.searchModel;
|
||||
|
||||
// Retrieves view context
|
||||
const fns = this.env.__saveParams__.callbacks;
|
||||
const { context: viewContext } = Object.assign({}, ...fns.map((fn) => fn()));
|
||||
|
||||
const contextToSave = {
|
||||
...context,
|
||||
group_by: groupBy,
|
||||
orderedBy,
|
||||
dashboard_merge_domains_contexts: false,
|
||||
...viewContext,
|
||||
};
|
||||
|
||||
if (comparison) {
|
||||
contextToSave.comparison = comparison;
|
||||
}
|
||||
|
||||
const result = await this.rpc("/board/add_to_dashboard", {
|
||||
action_id: action.id,
|
||||
context_to_save: contextToSave,
|
||||
domain,
|
||||
name: this.state.name,
|
||||
view_mode: view.type,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
this.notification.add(
|
||||
this.env._t("Please refresh your browser for the changes to take effect."),
|
||||
{
|
||||
title: sprintf(this.env._t(`"%s" added to dashboard`), this.state.name),
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
this.state.name = displayName;
|
||||
} else {
|
||||
this.notification.add(
|
||||
this.env._t("Could not add filter to dashboard"),
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onInputKeydown(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this.addToBoard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddToBoard.template = "board.AddToBoard";
|
||||
|
||||
const addToBoardItem = {
|
||||
Component: AddToBoard,
|
||||
groupNumber: 4,
|
||||
isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window",
|
||||
};
|
||||
|
||||
favoriteMenuRegistry.add("add-to-board", addToBoardItem, { sequence: 10 });
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="board.AddToBoard" owl="1">
|
||||
<Dropdown class="o_add_to_board">
|
||||
<t t-set-slot="toggler">Add to my dashboard</t>
|
||||
<li class="px-3 py-2">
|
||||
<input type="text" class="o_input" autofocus="" t-model.trim="state.name" t-on-keydown="onInputKeydown" />
|
||||
</li>
|
||||
<li class="px-3 py-2">
|
||||
<button type="button" class="btn btn-primary" t-on-click="addToBoard">Add</button>
|
||||
</li>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
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 |
@@ -8,7 +8,7 @@
|
||||
<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/img/layout_1-1-1.png" width="16" height="16" alt=""/>
|
||||
<img src="/board/static/src/legacy/img/layout_1-1-1.png" width="16" height="16" alt=""/>
|
||||
<span> Change Layout </span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@
|
||||
</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/img/layout_#{layout}.png" alt=""/>
|
||||
<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>
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,10 +20,10 @@ The module adds the possibility to display data from Odoo in Google Spreadsheets
|
||||
'auto_install': False,
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'google_spreadsheet/static/src/js/**/*',
|
||||
'google_spreadsheet/static/src/**/*.js',
|
||||
],
|
||||
'web.assets_qweb': [
|
||||
'google_spreadsheet/static/src/xml/*.xml',
|
||||
'google_spreadsheet/static/src/**/*.xml',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { Component } = owl;
|
||||
const favoriteMenuRegistry = registry.category("favoriteMenu");
|
||||
|
||||
/**
|
||||
* 'Add to Google spreadsheet' menu item
|
||||
*
|
||||
* Component consisting only of a button calling the server to add the current
|
||||
* view to the user's spreadsheet configuration.
|
||||
* This component is only available in actions of type 'ir.actions.act_window'.
|
||||
* @extends Component
|
||||
*/
|
||||
export class AddToGoogleSpreadsheet extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Protected
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
async addToGoogleSpreadsheet() {
|
||||
const { domain, groupBy, resModel, view } = this.env.searchModel;
|
||||
|
||||
const result = await this.orm.call(
|
||||
"google.drive.config",
|
||||
"set_spreadsheet",
|
||||
[resModel, domain, groupBy, view.id]
|
||||
);
|
||||
|
||||
if (result.url) {
|
||||
// According to MDN doc, one should not use _blank as title.
|
||||
// todo: find a good name for the new window
|
||||
window.open(result.url, "_blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddToGoogleSpreadsheet.template = "google_spreadsheet.AddToGoogleSpreadsheet";
|
||||
|
||||
const addToGoogleSpreadsheetItem = {
|
||||
Component: AddToGoogleSpreadsheet,
|
||||
groupNumber: 4,
|
||||
isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window",
|
||||
};
|
||||
|
||||
favoriteMenuRegistry.add("add-to-google-spreadsheet", addToGoogleSpreadsheetItem, { sequence: 20 });
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="google_spreadsheet.AddToGoogleSpreadsheet" owl="1">
|
||||
<DropdownItem class="o_add_to_spreadsheet dropdown-item" t-on-click="addToGoogleSpreadsheet">
|
||||
Add to Google Spreadsheet
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -54,7 +54,8 @@ up a management by affair.
|
||||
'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.js',
|
||||
'hr_timesheet/static/src/js/timesheet_graph_view.js',
|
||||
'hr_timesheet/static/src/js/timesheet_graph_model.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'hr_timesheet/static/tests/**/*',
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
odoo.define('hr_timesheet.GraphView', function (require) {
|
||||
"use strict";
|
||||
|
||||
const viewRegistry = require('web.view_registry');
|
||||
const GraphView = require('web.GraphView');
|
||||
const GraphModel = require('web.GraphModel');
|
||||
|
||||
const hrTimesheetGraphModel = GraphModel.extend({
|
||||
/*
|
||||
* Override the _processData to take into account the analytic line uom.
|
||||
*/
|
||||
_processData: function (originIndex, rawData) {
|
||||
this._super.apply(this, arguments);
|
||||
const session = this.getSession();
|
||||
const currentCompanyId = session.user_context.allowed_company_ids[0];
|
||||
const currentCompany = session.user_companies.allowed_companies[currentCompanyId];
|
||||
const currentCompanyTimesheetUOMFactor = currentCompany.timesheet_uom_factor || 1;
|
||||
const fields = ['unit_amount', 'effective_hours', 'planned_hours', 'remaining_hours', 'total_hours_spent', 'subtask_effective_hours',
|
||||
'overtime', 'number_hours', 'difference', 'hours_effective', 'hours_planned', 'timesheet_unit_amount'];
|
||||
|
||||
if (fields.includes(this.chart.measure) && currentCompanyTimesheetUOMFactor !== 1) {
|
||||
// recalculate the Duration values according to the timesheet_uom_factor
|
||||
this.chart.dataPoints.forEach(function (dataPt) {
|
||||
dataPt.value *= currentCompanyTimesheetUOMFactor;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const hrTimesheetGraphView = GraphView.extend({
|
||||
config: Object.assign({}, GraphView.prototype.config, {
|
||||
Model: hrTimesheetGraphModel,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('hr_timesheet_graphview', hrTimesheetGraphView);
|
||||
return hrTimesheetGraphView;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { GraphModel } from "@web/views/graph/graph_model";
|
||||
|
||||
const FIELDS = [
|
||||
'unit_amount', 'effective_hours', 'planned_hours', 'remaining_hours', 'total_hours_spent', 'subtask_effective_hours',
|
||||
'overtime', 'number_hours', 'difference', 'hours_effective', 'hours_planned', 'timesheet_unit_amount'
|
||||
];
|
||||
|
||||
export class hrTimesheetGraphModel extends GraphModel {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup(params, services) {
|
||||
super.setup(...arguments);
|
||||
this.companyService = services.company;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Override processDataPoints to take into account the analytic line uom.
|
||||
* @override
|
||||
*/
|
||||
_getProcessedDataPoints() {
|
||||
const currentCompany = this.companyService.currentCompany;
|
||||
const factor = currentCompany.timesheet_uom_factor || 1;
|
||||
if (factor !== 1 && FIELDS.includes(this.metaData.measure)) {
|
||||
// recalculate the Duration values according to the timesheet_uom_factor
|
||||
for (const dataPt of this.dataPoints) {
|
||||
dataPt.value *= factor;
|
||||
}
|
||||
}
|
||||
return super._getProcessedDataPoints(...arguments);
|
||||
}
|
||||
}
|
||||
hrTimesheetGraphModel.services = [...GraphModel.services, "company"];
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { GraphView } from "@web/views/graph/graph_view";
|
||||
import { hrTimesheetGraphModel } from "./timesheet_graph_model";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
export class hrTimesheetGraphView extends GraphView {}
|
||||
hrTimesheetGraphView.Model = hrTimesheetGraphModel;
|
||||
|
||||
viewRegistry.add("hr_timesheet_graphview", hrTimesheetGraphView);
|
||||
@@ -1,73 +1,78 @@
|
||||
odoo.define('hr_timesheet.timesheet_graph_tests', function (require) {
|
||||
"use strict";
|
||||
/** @odoo-module **/
|
||||
|
||||
const session = require('web.session');
|
||||
const SetupTimesheetUOMWidgetsTestEnvironment = require('hr_timesheet.timesheet_uom_tests_env');
|
||||
const GraphView = require('hr_timesheet.GraphView');
|
||||
import { companyService } from "@web/webclient/company_service";
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { getGraphRenderer } from "@web/../tests/views/graph_view_tests";
|
||||
import { makeView } from "@web/../tests/views/helpers";
|
||||
import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { session } from "@web/session";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
|
||||
|
||||
QUnit.module('Timesheet UOM Widgets', function (hooks) {
|
||||
let env;
|
||||
let sessionUserCompaniesBackup;
|
||||
let sessionUserContextBackup;
|
||||
let sessionUOMIdsBackup;
|
||||
let sessionUIDBackup;
|
||||
hooks.before(function (assert) {
|
||||
env = new SetupTimesheetUOMWidgetsTestEnvironment();
|
||||
// Backups session parts that this testing module will alter in order to restore it at the end.
|
||||
sessionUserCompaniesBackup = session.user_companies || false;
|
||||
sessionUserContextBackup = session.user_context || false;
|
||||
sessionUOMIdsBackup = session.uom_ids || false;
|
||||
sessionUIDBackup = session.uid || false;
|
||||
});
|
||||
hooks.after(async function (assert) {
|
||||
// Restores the session
|
||||
const sessionToApply = Object.assign(
|
||||
{ },
|
||||
sessionUserCompaniesBackup && {
|
||||
user_companies: sessionUserCompaniesBackup,
|
||||
} || { },
|
||||
sessionUserContextBackup && {
|
||||
user_context: sessionUserContextBackup,
|
||||
} || { },
|
||||
sessionUOMIdsBackup && {
|
||||
uom_ids: sessionUOMIdsBackup,
|
||||
} || { },
|
||||
sessionUIDBackup && {
|
||||
uid: sessionUIDBackup,
|
||||
} || { });
|
||||
await env.patchSessionAndStartServices(sessionToApply, true);
|
||||
});
|
||||
QUnit.module('GraphView', function (hooks) {
|
||||
QUnit.test('the timesheet_graph view data are multiplied by a factor that is company related', async function (assert) {
|
||||
assert.expect(2);
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let options = {
|
||||
View: GraphView,
|
||||
arch: '<graph><field name="unit_amount"/></graph>',
|
||||
viewOptions: {
|
||||
context: {
|
||||
graph_measure: 'unit_amount',
|
||||
QUnit.module('Views', function (hooks) {
|
||||
let serverData;
|
||||
hooks.beforeEach(() => {
|
||||
serverData = {
|
||||
models: {
|
||||
'account.analytic.line': {
|
||||
fields: {
|
||||
unit_amount: { string: "Unit Amount", type: "float", group_operator: "sum", store: true },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, unit_amount: 8 }
|
||||
],
|
||||
},
|
||||
};
|
||||
let graph = await env.createView(options);
|
||||
let renderedData = graph.renderer.componentRef.comp.chart.data.datasets[0].data[0];
|
||||
assert.strictEqual(renderedData, 8, 'The timesheet_graph is taking the timesheet_uom_factor into account');
|
||||
graph.destroy();
|
||||
},
|
||||
views: {
|
||||
// unit_amount is used as group_by and measure
|
||||
"account.analytic.line,false,graph": `
|
||||
<graph>
|
||||
<field name="unit_amount"/>
|
||||
<field name="unit_amount" type="measure"/>
|
||||
</graph>
|
||||
`,
|
||||
}
|
||||
}
|
||||
setupControlPanelServiceRegistry();
|
||||
serviceRegistry.add("company", companyService);
|
||||
serviceRegistry.add("dialog", dialogService);
|
||||
});
|
||||
|
||||
options = Object.assign(
|
||||
{ },
|
||||
options,
|
||||
{
|
||||
session: {
|
||||
user_context: env.singleCompanyDayUOMUser,
|
||||
},
|
||||
});
|
||||
graph = await env.createView(options);
|
||||
renderedData = graph.renderer.componentRef.comp.chart.data.datasets[0].data[0];
|
||||
assert.strictEqual(renderedData, 1, 'The timesheet_graph is taking the timesheet_uom_factor into account');
|
||||
graph.destroy();
|
||||
QUnit.module("hrTimesheetGraphView");
|
||||
|
||||
QUnit.test('the timesheet graph view data are not multiplied by a factor that is company related (factor = 1)', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
patchWithCleanup(session.user_companies.allowed_companies[1], {
|
||||
timesheet_uom_factor: 1,
|
||||
});
|
||||
|
||||
const graph = await makeView({
|
||||
serverData,
|
||||
resModel: "account.analytic.line",
|
||||
type: "hr_timesheet_graphview",
|
||||
});
|
||||
|
||||
const renderedData = getGraphRenderer(graph).chart.data.datasets[0].data;
|
||||
assert.deepEqual(renderedData, [8], 'The timesheet graph view is taking the timesheet_uom_factor into account (factor === 1)');
|
||||
});
|
||||
|
||||
QUnit.test('the timesheet graph view data are multiplied by a factor that is company related (factor !== 1)', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
patchWithCleanup(session.user_companies.allowed_companies[1], {
|
||||
timesheet_uom_factor: 0.125,
|
||||
});
|
||||
|
||||
const graph = await makeView({
|
||||
serverData,
|
||||
resModel: "account.analytic.line",
|
||||
type: "hr_timesheet_graphview",
|
||||
});
|
||||
|
||||
const renderedData = getGraphRenderer(graph).chart.data.datasets[0].data;
|
||||
assert.deepEqual(renderedData, [1], 'The timesheet graph view is taking the timesheet_uom_factor into account (factor !== 1)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -111,7 +111,16 @@
|
||||
top: 10%;
|
||||
}
|
||||
.o_mail_systray_dropdown {
|
||||
position: relative;
|
||||
|
||||
position: fixed;
|
||||
top: $o-mail-chat-window-header-height-mobile;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: map-get($sizes, 100);
|
||||
margin: 0;
|
||||
max-height: none;
|
||||
|
||||
.o_mail_systray_dropdown_top {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -59,19 +59,21 @@
|
||||
'post_init_hook': '_project_post_init',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'project/static/src/burndown_chart/*',
|
||||
'project/static/src/project_control_panel/*',
|
||||
'project/static/src/css/project.css',
|
||||
'project/static/src/js/project_activity.js',
|
||||
'project/static/src/js/project_control_panel.js',
|
||||
'project/static/src/js/project_form.js',
|
||||
'project/static/src/js/project_graph.js',
|
||||
'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_rating_reporting.js',
|
||||
'project/static/src/js/project_pivot_view.js',
|
||||
'project/static/src/js/project_rating_graph_view.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',
|
||||
'project/static/src/js/burndown_chart/*',
|
||||
'project/static/src/js/right_panel/*',
|
||||
'project/static/src/js/update/*',
|
||||
'project/static/src/js/widgets/*',
|
||||
@@ -85,7 +87,7 @@
|
||||
'project/static/src/js/portal_rating.js',
|
||||
],
|
||||
'web.assets_qweb': [
|
||||
'project/static/src/xml/**/*',
|
||||
'project/static/src/**/*.xml',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'project/static/tests/burndown_chart_tests.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>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { hexToRGBA } from "@web/views/graph/colors";
|
||||
import { GraphRenderer } from "@web/views/graph/graph_renderer";
|
||||
|
||||
export class BurndownChartRenderer extends GraphRenderer {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getLineChartData() {
|
||||
const data = super.getLineChartData();
|
||||
const { stacked } = this.props.metaData;
|
||||
if (stacked) {
|
||||
for (const dataset of data.datasets) {
|
||||
dataset.backgroundColor = hexToRGBA(dataset.borderColor, 0.4);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getElementOptions() {
|
||||
const elementOptions = super.getElementOptions();
|
||||
const { mode, stacked } = this.props.metaData;
|
||||
if (mode === "line") {
|
||||
elementOptions.line.fill = stacked;
|
||||
}
|
||||
return elementOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getScaleOptions() {
|
||||
const { xAxes, yAxes } = super.getScaleOptions();
|
||||
const { mode, stacked } = this.props.metaData;
|
||||
if (mode === "line") {
|
||||
for (const y of yAxes) {
|
||||
y.stacked = stacked;
|
||||
}
|
||||
}
|
||||
return { xAxes, yAxes };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { BurndownChartRenderer } from "./burndown_chart_renderer";
|
||||
import { GraphView } from "@web/views/graph/graph_view";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
class BurndownChartView extends GraphView {}
|
||||
BurndownChartView.components = { ...GraphView.components, Renderer: BurndownChartRenderer };
|
||||
BurndownChartView.buttonTemplate = "project.BurndownChartView.Buttons";
|
||||
|
||||
viewRegistry.add("burndown_chart", BurndownChartView);
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.BurndownChartView.Buttons" t-inherit="web.GraphView.Buttons" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//button[@data-mode='pie']" position="replace">
|
||||
</xpath>
|
||||
<xpath expr="//div[@role='toolbar'][3]" position="attributes">
|
||||
<attribute name="t-if">true</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,23 +0,0 @@
|
||||
/** @odoo-module alias=project.BurndownChartController **/
|
||||
import * as GraphController from 'web.GraphController';
|
||||
|
||||
export default GraphController.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateButtons: function() {
|
||||
const result = this._super.apply(this, arguments);
|
||||
if (this.$buttons) {
|
||||
const state = this.model.get();
|
||||
this.$buttons
|
||||
.find('.o_graph_button[data-mode="pie"]')
|
||||
.addClass('d-none');
|
||||
this.$buttons
|
||||
.find('.o_graph_button[data-mode="stack"]')
|
||||
.data('stacked', state.stacked)
|
||||
.toggleClass('active', state.stacked)
|
||||
.toggleClass('o_hidden', state.mode === 'pie');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
/** @odoo-module alias=project.BurndownChartRenderer **/
|
||||
import * as GraphRenderer from 'web/static/src/js/views/graph/graph_renderer';
|
||||
import { hexToRGBA } from "web/static/src/js/views/graph/graph_utils";
|
||||
|
||||
export class BurndownChartRenderer extends GraphRenderer {
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_createLineChartConfig() {
|
||||
const { data, options, type } = super._createLineChartConfig();
|
||||
for (const dataset of data.datasets) {
|
||||
if (this.props.stacked) {
|
||||
dataset.backgroundColor = hexToRGBA(dataset.borderColor, 0.4);
|
||||
}
|
||||
}
|
||||
return { data, options, type };
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getElementOptions() {
|
||||
const elementOptions = super._getElementOptions();
|
||||
if (this.props.mode === 'line') {
|
||||
elementOptions.line.fill = this.props.stacked;
|
||||
}
|
||||
return elementOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_getScaleOptions() {
|
||||
const scaleOptions = super._getScaleOptions();
|
||||
if (this.props.mode !== "line") {
|
||||
return scaleOptions;
|
||||
}
|
||||
|
||||
const { xAxes, yAxes } = scaleOptions;
|
||||
for (const y of yAxes) {
|
||||
y.stacked = this.props.stacked;
|
||||
}
|
||||
return { xAxes, yAxes };
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/** @odoo-module alias=project.BurndownChartView **/
|
||||
import GraphView from 'web.GraphView';
|
||||
import viewRegistry from 'web.view_registry';
|
||||
import BurndownChartController from './controller';
|
||||
import { BurndownChartRenderer } from './renderer';
|
||||
|
||||
export const BurndownChartView = GraphView.extend({
|
||||
config: Object.assign({}, GraphView.prototype.config, {
|
||||
Controller: BurndownChartController,
|
||||
Renderer: BurndownChartRenderer,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('burndown_chart', BurndownChartView);
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import GraphView from 'web.GraphView';
|
||||
import { ProjectControlPanel } from '@project/js/project_control_panel';
|
||||
import viewRegistry from 'web.view_registry';
|
||||
|
||||
export const ProjectGraphView = GraphView.extend({
|
||||
config: Object.assign({}, GraphView.prototype.config, {
|
||||
ControlPanel: ProjectControlPanel,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('project_graph', ProjectGraphView);
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { GraphView } from "@web/views/graph/graph_view";
|
||||
import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
class ProjectGraphView extends GraphView {}
|
||||
ProjectGraphView.components = { ...GraphView, ControlPanel: ProjectControlPanel };
|
||||
|
||||
viewRegistry.add("project_graph", ProjectGraphView);
|
||||
@@ -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 { GraphView } from "@web/views/graph/graph_view";
|
||||
import { GraphArchParser } from "@web/views/graph/graph_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 GraphArchParser {
|
||||
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 ProjectRatingGraphView extends GraphView {}
|
||||
ProjectRatingGraphView.archParser = ProjectRatingArchParser;
|
||||
|
||||
viewRegistry.add("project_rating_graph", ProjectRatingGraphView);
|
||||
@@ -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,62 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from 'web.core';
|
||||
import GraphController from 'web.GraphController';
|
||||
import GraphView from 'web.GraphView';
|
||||
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);
|
||||
|
||||
const ProjectGraphController = GraphController.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
_.each(this.measures, measure => {
|
||||
if (measure.fieldName === 'res_id') {
|
||||
measure.description = _t('Task');
|
||||
} else if (measure.fieldName === 'parent_res_id') {
|
||||
measure.description = _t('Project');
|
||||
} else if (measure.fieldName === 'rating') {
|
||||
measure.description = _t('Rating Value (/5)');
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ProjectGraphView = GraphView.extend({
|
||||
config: Object.assign({}, GraphView.prototype.config, {
|
||||
Controller: ProjectGraphController,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('project_rating_graph', ProjectGraphView);
|
||||
@@ -0,0 +1,51 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { ControlPanel } from "@web/search/control_panel/control_panel";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ProjectControlPanel extends ControlPanel {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.orm = useService("orm");
|
||||
this.user = useService("user");
|
||||
const { active_id, show_project_update } = this.env.searchModel.globalContext;
|
||||
this.showProjectUpdate = this.env.searchModel.view.type === "form" || show_project_update;
|
||||
this.projectId = this.showProjectUpdate ? active_id : false;
|
||||
}
|
||||
|
||||
async willStart() {
|
||||
const proms = [super.willStart(...arguments)];
|
||||
if (this.showProjectUpdate) {
|
||||
proms.push(this.loadData());
|
||||
}
|
||||
await Promise.all(proms);
|
||||
}
|
||||
|
||||
async willUpdateProps() {
|
||||
const proms = [super.willUpdateProps(...arguments)];
|
||||
if (this.showProjectUpdate) {
|
||||
proms.push(this.loadData());
|
||||
}
|
||||
await Promise.all(proms);
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
const [data, isProjectUser] = await Promise.all([
|
||||
this.orm.call("project.project", "get_last_update_or_default", [this.projectId]),
|
||||
this.user.hasGroup("project.group_project_user"),
|
||||
]);
|
||||
this.data = data;
|
||||
this.isProjectUser = isProjectUser;
|
||||
}
|
||||
|
||||
async onStatusClick(ev) {
|
||||
ev.preventDefault();
|
||||
this.actionService.doAction("project.project_update_all_action", {
|
||||
additionalContext: {
|
||||
default_project_id: this.projectId,
|
||||
active_id: this.projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
ProjectControlPanel.template = "project.ProjectControlPanel";
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.ProjectControlPanelContentBadge" owl="1">
|
||||
<t t-tag="isProjectUser ? 'button' : 'span'" class="badge border d-flex p-2 ml-2 bg-white">
|
||||
<span t-attf-class="o_status_bubble o_color_bubble_{{data.color}}"/>
|
||||
<span class="font-weight-normal ml-1" t-esc="data.status"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ProjectControlPanelContent" owl="1">
|
||||
<t t-if="showProjectUpdate">
|
||||
<li t-if="isProjectUser" class="o_project_updates_breadcrumb pl-3" t-on-click="onStatusClick">
|
||||
<t t-call="project.ProjectControlPanelContentBadge"></t>
|
||||
</li>
|
||||
<li t-else="" class="o_project_updates_breadcrumb pl-3">
|
||||
<t t-call="project.ProjectControlPanelContentBadge"></t>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="project.Breadcrumbs" t-inherit="web.Breadcrumbs" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//ol" position="inside">
|
||||
<t t-call="project.ProjectControlPanelContent"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ProjectControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-call='web.Breadcrumbs']" position="replace">
|
||||
<t t-call="project.Breadcrumbs"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -19,28 +19,13 @@
|
||||
|
||||
<div t-name="project.ControlPanel" t-inherit="web.Legacy.ControlPanel" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//ol[hasclass('breadcrumb')]" position="inside">
|
||||
<t t-call="project.ControlPanelContent"/>
|
||||
<t t-call="project.ProjectControlPanelContent">
|
||||
<t t-set="showProjectUpdate" t-value="show_project_update"/>
|
||||
<t t-set="isProjectUser" t-value="is_project_user"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</div>
|
||||
|
||||
<t t-name="project.ControlPanelContentBadge" owl="1">
|
||||
<t t-tag="is_project_user ? 'button' : 'span'" class="badge border d-flex p-2 ml-2 bg-white">
|
||||
<span t-attf-class="o_status_bubble o_color_bubble_{{data.color}}"/>
|
||||
<span class="font-weight-normal ml-1" t-esc="data.status"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ControlPanelContent" owl="1">
|
||||
<t t-if="show_project_update">
|
||||
<li t-if="is_project_user" class="o_project_updates_breadcrumb pl-3" t-on-click="onStatusClick">
|
||||
<t t-call="project.ControlPanelContentBadge"></t>
|
||||
</li>
|
||||
<li t-else="" class="o_project_updates_breadcrumb pl-3">
|
||||
<t t-call="project.ControlPanelContentBadge"></t>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="project.ProjectRightPanel" owl="1">
|
||||
<div class="o_rightpanel">
|
||||
<t t-call="project.StatButtonsSection"/>
|
||||
|
||||
@@ -1,74 +1,85 @@
|
||||
/** @odoo-module alias=project.BurndownView */
|
||||
import { dom, createView, nextTick } from 'web.test_utils';
|
||||
import { COLORS, hexToRGBA } from 'web/static/src/js/views/graph/graph_utils';
|
||||
import { BurndownChartView } from '../src/js/burndown_chart/view';
|
||||
/** @odoo-module */
|
||||
|
||||
QUnit.module('Project', {}, function () {
|
||||
QUnit.module('Views', {
|
||||
beforeEach() {
|
||||
this.data = {
|
||||
burndown_chart: {
|
||||
fields: {
|
||||
date: { string: "Date", type: 'date', store: true, sortable: true },
|
||||
project_id: { string: "Project", type: "many2one", relation: 'project', store: true },
|
||||
stage_id: { string: "Stage", type: "many2one", relation: 'stage', store: true },
|
||||
nb_tasks: { string: "Number of Tasks", type: 'integer', store: true }
|
||||
import { click } from "@web/../tests/helpers/utils";
|
||||
import { COLORS, hexToRGBA } from "@web/views/graph/colors";
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { getGraphRenderer } from "@web/../tests/views/graph_view_tests";
|
||||
import { makeView } from "@web/../tests/views/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
QUnit.module("Project", {}, () => {
|
||||
QUnit.module("Views", (hooks) => {
|
||||
let serverData;
|
||||
hooks.beforeEach(async () => {
|
||||
serverData = {
|
||||
models: {
|
||||
burndown_chart: {
|
||||
fields: {
|
||||
date: { string: "Date", type: "date", store: true, sortable: true },
|
||||
project_id: { string: "Project", type: "many2one", relation: "project", store: true },
|
||||
stage_id: { string: "Stage", type: "many2one", relation: "stage", store: true },
|
||||
nb_tasks: { string: "Number of Tasks", type: "integer", store: true, group_operator: "sum" }
|
||||
},
|
||||
records: [
|
||||
{ id: 1, project_id: 1, stage_id: 1, date: "2020-01-01", nb_tasks: 10 },
|
||||
{ id: 2, project_id: 1, stage_id: 2, date: "2020-02-01", nb_tasks: 5 },
|
||||
{ id: 3, project_id: 1, stage_id: 3, date: "2020-03-01", nb_tasks: 2 },
|
||||
],
|
||||
},
|
||||
records: [
|
||||
{ id: 1, project_id: 1, stage_id: 1, date: "2020-01-01", nb_tasks: 10 },
|
||||
{ id: 2, project_id: 1, stage_id: 2, date: "2020-02-01", nb_tasks: 5 },
|
||||
{ id: 3, project_id: 1, stage_id: 3, date: "2020-03-01", nb_tasks: 2 },
|
||||
],
|
||||
project: {
|
||||
fields: {
|
||||
name: { string: "Project Name", type: "char" },
|
||||
},
|
||||
records: [{ id: 1, name: "Project A" }]
|
||||
},
|
||||
stage: {
|
||||
fields: {
|
||||
name: { string: "Stage Name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: "Todo" },
|
||||
{ id: 2, name: "In Progress" },
|
||||
{ id: 3, name: "Done" },
|
||||
],
|
||||
}
|
||||
},
|
||||
project: {
|
||||
fields: {
|
||||
name: { string: 'Project Name', type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: 'Project A' },
|
||||
]
|
||||
views: {
|
||||
"burndown_chart,false,graph": `
|
||||
<graph type="line">
|
||||
<field name="date" string="Date" interval="month"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="nb_tasks" type="measure"/>
|
||||
</graph>
|
||||
`,
|
||||
},
|
||||
stage: {
|
||||
fields: {
|
||||
name: {string: 'Stage Name', type: 'char' },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, name: 'Todo' },
|
||||
{ id: 2, name: 'In Progress' },
|
||||
{ id: 3, name: 'Done' },
|
||||
]
|
||||
}
|
||||
};
|
||||
setupControlPanelServiceRegistry();
|
||||
serviceRegistry.add("dialog", dialogService);
|
||||
});
|
||||
|
||||
this.burndown_chart = {
|
||||
View: BurndownChartView,
|
||||
model: 'burndown_chart',
|
||||
data: this.data,
|
||||
arch: `<graph type="line">
|
||||
<field name="date" string="Date" interval="month" type="row"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="nb_tasks" type="measure"/>
|
||||
</graph>`,
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
QUnit.module('BurndownChart');
|
||||
QUnit.module("BurndownChart");
|
||||
|
||||
QUnit.test('check if default mode is line chart and line chart is stacked for burndown chart', async function (assert) {
|
||||
QUnit.test("check if default mode is line chart and line chart is stacked for burndown chart", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const burndown_chart = await createView(this.burndown_chart);
|
||||
const burndownChart = await makeView({
|
||||
serverData,
|
||||
resModel: "burndown_chart",
|
||||
type: "burndown_chart",
|
||||
});
|
||||
|
||||
assert.strictEqual(burndown_chart.renderer.props.mode, "line", "should be in line chart mode by default.");
|
||||
assert.ok(burndown_chart.renderer.props.stacked, "should be stacked by default.");
|
||||
assert.strictEqual(burndownChart.model.metaData.mode, "line", "should be in line chart mode.");
|
||||
assert.ok(burndownChart.model.metaData.stacked, "should be stacked by default.");
|
||||
|
||||
assert.ok(burndown_chart.renderer.componentRef.comp._getScaleOptions().yAxes.every(y => y.stacked), "the stacked property in y axes should be true when the stacked is enabled in line chart");
|
||||
assert.ok(burndown_chart.renderer.componentRef.comp._getElementOptions().line.fill, "The fill property should be true to add backgroundColor in line chart.");
|
||||
assert.ok(getGraphRenderer(burndownChart).getScaleOptions().yAxes.every(y => y.stacked), "the stacked property in y axes should be true when the stacked is enabled in line chart");
|
||||
assert.ok(getGraphRenderer(burndownChart).getElementOptions().line.fill, "The fill property should be true to add backgroundColor in line chart.");
|
||||
|
||||
const actualDatasets = [];
|
||||
const expectedDatasets = [];
|
||||
const keysToEvaluate = ['backgroundColor', 'borderColor', 'originIndex', 'pointBackgroundColor'];
|
||||
const datasets = burndown_chart.renderer.componentRef.comp.chart.data.datasets;
|
||||
const keysToEvaluate = ["backgroundColor", "borderColor", "originIndex", "pointBackgroundColor"];
|
||||
const datasets = getGraphRenderer(burndownChart).chart.data.datasets;
|
||||
|
||||
for (let i = 0; i < datasets.length; i++) {
|
||||
const dataset = datasets[i];
|
||||
@@ -88,43 +99,43 @@ QUnit.module('Project', {}, function () {
|
||||
pointBackgroundColor: expectedColor,
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(actualDatasets, expectedDatasets);
|
||||
|
||||
burndown_chart.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('check if the stacked button is visible in the line chart', async function (assert) {
|
||||
QUnit.test("check if the stacked button is visible in the line chart", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const burndown_chart = await createView(this.burndown_chart);
|
||||
|
||||
assert.containsN(burndown_chart, '.o_graph_button[data-mode="stack"]', 1, "should find the stacked button in the controller of the view.");
|
||||
const $stackButton = burndown_chart.$buttons.find('.o_graph_button[data-mode="stack"]');
|
||||
assert.doesNotHaveClass($stackButton, 'o_hidden', "the stacked button should not have the .o_hidden class when we are in the line chart.");
|
||||
|
||||
await dom.click($stackButton);
|
||||
assert.notOk(burndown_chart.renderer.props.stacked, "should be disabled and display a classic line chart.");
|
||||
|
||||
burndown_chart.destroy();
|
||||
const burndownChart = await makeView({
|
||||
serverData,
|
||||
resModel: "burndown_chart",
|
||||
type: "burndown_chart",
|
||||
});
|
||||
assert.ok(burndownChart.model.metaData.stacked, "graph should be a burndown chart.");
|
||||
assert.containsOnce(burndownChart, `button.o_graph_button[data-tooltip="Stacked"]`);
|
||||
const stackButton = burndownChart.el.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`);
|
||||
await click(stackButton);
|
||||
assert.notOk(burndownChart.model.metaData.stacked, "graph should be a classic line chart.");
|
||||
});
|
||||
|
||||
QUnit.test('check if it is classic line chart when stacked prop is false in line chart', async function (assert) {
|
||||
QUnit.test("check if it is classic line chart when stacked prop is false in line chart", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const burndown_chart = await createView(this.burndown_chart);
|
||||
const burndownChart = await makeView({
|
||||
serverData,
|
||||
resModel: "burndown_chart",
|
||||
type: "burndown_chart",
|
||||
});
|
||||
|
||||
const $stackButton = burndown_chart.$buttons.find('.o_graph_button[data-mode="stack"]');
|
||||
await dom.click($stackButton);
|
||||
assert.notOk(burndown_chart.renderer.props.stacked, "should be disabled and display a classic line chart.");
|
||||
const stackButton = burndownChart.el.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`);
|
||||
await click(stackButton);
|
||||
assert.notOk(burndownChart.model.metaData.stacked, "graph should be a classic line chart.");
|
||||
|
||||
assert.notOk(burndown_chart.renderer.componentRef.comp._getScaleOptions().yAxes.every(y => y.stacked), "the y axes should have a stacked property set to false since the stacked property in line chart is false.");
|
||||
assert.notOk(burndown_chart.renderer.componentRef.comp._getElementOptions().line.fill, "The fill property should be false since the stacked property is false.");
|
||||
assert.notOk(getGraphRenderer(burndownChart).getScaleOptions().yAxes.every(y => y.stacked), "the y axes should have a stacked property set to false since the stacked property in line chart is false.");
|
||||
assert.notOk(getGraphRenderer(burndownChart).getElementOptions().line.fill, "The fill property should be false since the stacked property is false.");
|
||||
|
||||
const actualDatasets = [];
|
||||
const expectedDatasets = [];
|
||||
const keysToEvaluate = ['backgroundColor', 'borderColor', 'originIndex', 'pointBackgroundColor'];
|
||||
const datasets = burndown_chart.renderer.componentRef.comp.chart.data.datasets;
|
||||
const keysToEvaluate = ["backgroundColor", "borderColor", "originIndex", "pointBackgroundColor"];
|
||||
const datasets = getGraphRenderer(burndownChart).chart.data.datasets;
|
||||
|
||||
for (let i = 0; i < datasets.length; i++) {
|
||||
const dataset = datasets[i];
|
||||
@@ -145,8 +156,6 @@ QUnit.module('Project', {}, function () {
|
||||
}
|
||||
|
||||
assert.deepEqual(actualDatasets, expectedDatasets);
|
||||
|
||||
burndown_chart.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="stock.GraphRenderer" owl="1" t-inherit="web.GraphRenderer" t-inherit-mode="primary">
|
||||
<t t-name="stock.GraphRenderer" owl="1" t-inherit="web.Legacy.GraphRenderer" t-inherit-mode="primary">
|
||||
<xpath expr="//canvas[@t-ref='canvas']" position="attributes">
|
||||
<attribute name="height">300</attribute>
|
||||
</xpath>
|
||||
|
||||
@@ -48,9 +48,9 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/xml/kanban.xml',
|
||||
'web/static/src/legacy/xml/pivot.xml',
|
||||
'web/static/src/legacy/xml/report.xml',
|
||||
'web/static/src/legacy/xml/search_panel.xml',
|
||||
'web/static/src/legacy/xml/web_calendar.xml',
|
||||
'web/static/src/legacy/xml/graph.xml',
|
||||
'web/static/src/legacy/xml/search_panel.xml',
|
||||
'web/static/src/legacy/xml/week_days.xml',
|
||||
],
|
||||
'web.assets_common_minimal': [
|
||||
@@ -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',
|
||||
@@ -123,8 +122,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/scss/kanban_column_progressbar.scss',
|
||||
'web/static/src/legacy/scss/kanban_view.scss',
|
||||
'web/static/src/legacy/scss/web_calendar.scss',
|
||||
'web/static/src/legacy/scss/search_view.scss',
|
||||
'web/static/src/legacy/scss/search_panel.scss',
|
||||
'web/static/src/legacy/scss/data_export.scss',
|
||||
'base/static/src/scss/onboarding.scss',
|
||||
'web/static/src/legacy/scss/attachment_preview.scss',
|
||||
@@ -137,7 +134,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/scss/fields_extra.scss',
|
||||
'web/static/src/legacy/scss/form_view_extra.scss',
|
||||
'web/static/src/legacy/scss/list_view_extra.scss',
|
||||
'web/static/src/legacy/scss/search_view_extra.scss',
|
||||
|
||||
'web/static/src/legacy/action_adapters.js',
|
||||
'web/static/src/legacy/debug_manager.js',
|
||||
@@ -150,6 +146,7 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/root_widget.js',
|
||||
'web/static/src/legacy/systray_menu.js',
|
||||
'web/static/src/legacy/systray_menu_item.js',
|
||||
'web/static/src/legacy/backend_utils.js',
|
||||
'web/static/src/legacy/utils.js',
|
||||
'web/static/src/legacy/web_client.js',
|
||||
'web/static/src/legacy/js/_deprecated/*',
|
||||
@@ -453,13 +450,13 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/lib/ace/mode-python.js',
|
||||
'web/static/lib/ace/mode-xml.js',
|
||||
'web/static/lib/ace/mode-js.js',
|
||||
'web/static/lib/Chart/Chart.js',
|
||||
'web/static/lib/nearest/jquery.nearest.js',
|
||||
'web/static/lib/daterangepicker/daterangepicker.js',
|
||||
'web/static/lib/stacktracejs/stacktrace.js',
|
||||
|
||||
# 'web/static/tests/legacy/main_tests.js',
|
||||
'web/static/tests/helpers/**/*.js',
|
||||
'web/static/tests/search/helpers.js',
|
||||
'web/static/tests/webclient/**/helpers.js',
|
||||
'web/static/tests/qunit.js',
|
||||
'web/static/tests/main.js',
|
||||
@@ -481,6 +478,7 @@ This module provides the core of the Odoo Web Client.
|
||||
'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',
|
||||
'web/static/tests/webclient/**/*.js',
|
||||
('remove', 'web/static/tests/webclient/**/helpers.js'),
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { DateTime } = luxon;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
const formatters = registry.category("formatters");
|
||||
const parsers = registry.category("parsers");
|
||||
|
||||
let datePickerId = 0;
|
||||
|
||||
/**
|
||||
* @param {DateTime} date
|
||||
* @returns {moment}
|
||||
*/
|
||||
const luxonDateToMomentDate = (date) => {
|
||||
return window.moment(String(date));
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} format
|
||||
* @returns {string}
|
||||
*/
|
||||
const luxonFormatToMomentFormat = (format) => {
|
||||
return format.replace(/[dy]/g, (x) => x.toUpperCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Date picker
|
||||
*
|
||||
* This component exposes the API of the tempusdominus datepicker library.
|
||||
* As such, its template is a simple input that will open the TD datepicker
|
||||
* when clicked on. The component will also synchronize any user-input value
|
||||
* with the library widget and vice-versa.
|
||||
*
|
||||
* Note that all props given to this component will be passed as arguments to
|
||||
* instantiate the picker widget. Also any luxon date is automatically
|
||||
* stringified since tempusdominus only works with moment objects.
|
||||
* @extends Component
|
||||
*/
|
||||
export class DatePicker extends Component {
|
||||
setup() {
|
||||
this.inputRef = useRef("input");
|
||||
this.state = useState({ warning: false });
|
||||
|
||||
this.datePickerId = `o_datepicker_${datePickerId++}`;
|
||||
|
||||
this.initFormat();
|
||||
this.setDate(this.props);
|
||||
|
||||
useAutofocus();
|
||||
useExternalListener(window, "scroll", this.onWindowScroll);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
window.$(this.el).on("show.datetimepicker", () => this.inputRef.el.select());
|
||||
window.$(this.el).on("hide.datetimepicker", () => this.onDateChange());
|
||||
window.$(this.el).on("error.datetimepicker", () => false); // Ignores datepicker errors
|
||||
|
||||
this.bootstrapDateTimePicker(this.props);
|
||||
this.updateInput();
|
||||
}
|
||||
|
||||
willUpdateProps(nextProps) {
|
||||
const pickerParams = {};
|
||||
for (const prop in nextProps) {
|
||||
if (this.props[prop] !== nextProps[prop]) {
|
||||
pickerParams[prop] = nextProps[prop];
|
||||
if (prop === "date") {
|
||||
this.setDate(nextProps);
|
||||
this.updateInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.bootstrapDateTimePicker(pickerParams);
|
||||
}
|
||||
|
||||
willUnmount() {
|
||||
window.$(this.el).off(); // Removes all jQuery events
|
||||
|
||||
this.bootstrapDateTimePicker("destroy");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
get options() {
|
||||
return {
|
||||
// Fallback to default localization format in `core/l10n/dates.js`.
|
||||
format: this.props.format,
|
||||
locale: this.props.locale || this.date.locale,
|
||||
timezone: this.isLocal,
|
||||
};
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Protected
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialises formatting and parsing parameters
|
||||
*/
|
||||
initFormat() {
|
||||
this.defaultFormat = localization.dateFormat;
|
||||
this.format = formatters.get("date");
|
||||
this.parse = parsers.get("date");
|
||||
this.isLocal = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current date value. If a locale is provided, the given date
|
||||
* will first be set in that locale.
|
||||
* @param {object} params
|
||||
* @param {DateTime} params.date
|
||||
* @param {string} [params.locale]
|
||||
*/
|
||||
setDate({ date, locale }) {
|
||||
this.date = locale ? date.setLocale(locale) : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the input element with the current formatted date value.
|
||||
*/
|
||||
updateInput() {
|
||||
try {
|
||||
this.inputRef.el.value = this.format(this.date, this.options);
|
||||
} catch (err) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Bootstrap datepicker
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handles bootstrap datetimepicker calls.
|
||||
* @param {string | Object} commandOrParams
|
||||
*/
|
||||
bootstrapDateTimePicker(commandOrParams) {
|
||||
if (typeof commandOrParams === "object") {
|
||||
const format = luxonFormatToMomentFormat(this.props.format || this.defaultFormat);
|
||||
const params = { ...commandOrParams, format };
|
||||
for (const prop in params) {
|
||||
if (params[prop] instanceof DateTime) {
|
||||
const luxonDate = params[prop];
|
||||
const momentDate = luxonDateToMomentDate(luxonDate);
|
||||
if (momentDate.locale() !== luxonDate.locale) {
|
||||
momentDate.locale(params.locale);
|
||||
}
|
||||
params[prop] = momentDate;
|
||||
}
|
||||
}
|
||||
commandOrParams = params;
|
||||
}
|
||||
window.$(this.el).datetimepicker(commandOrParams);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
onInputClick() {
|
||||
this.bootstrapDateTimePicker("toggle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called either when the input value has changed or when the boostrap
|
||||
* datepicker is closed. The actual "datetime-changed" emitted by the
|
||||
* component is only triggered if the date value has changed.
|
||||
*/
|
||||
onDateChange() {
|
||||
try {
|
||||
const date = this.parse(this.inputRef.el.value, this.options);
|
||||
if (!date.equals(this.props.date)) {
|
||||
this.state.warning = date > DateTime.local();
|
||||
this.trigger("datetime-changed", { date });
|
||||
}
|
||||
} catch (err) {
|
||||
// Reset to default (= given) date.
|
||||
this.updateInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onWindowScroll(ev) {
|
||||
if (ev.target !== this.inputRef.el) {
|
||||
this.bootstrapDateTimePicker("hide");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker.defaultProps = {
|
||||
calendarWeeks: true,
|
||||
icons: {
|
||||
clear: "fa fa-delete",
|
||||
close: "fa fa-check primary",
|
||||
date: "fa fa-calendar",
|
||||
down: "fa fa-chevron-down",
|
||||
next: "fa fa-chevron-right",
|
||||
previous: "fa fa-chevron-left",
|
||||
time: "fa fa-clock-o",
|
||||
today: "fa fa-calendar-check-o",
|
||||
up: "fa fa-chevron-up",
|
||||
},
|
||||
maxDate: DateTime.fromObject({ year: 9999, month: 12, day: 31 }),
|
||||
minDate: DateTime.fromObject({ year: 1000 }),
|
||||
useCurrent: false,
|
||||
widgetParent: "body",
|
||||
};
|
||||
DatePicker.props = {
|
||||
// Components props
|
||||
date: DateTime,
|
||||
warn_future: { type: Boolean, optional: true },
|
||||
// Bootstrap datepicker options
|
||||
buttons: {
|
||||
type: Object,
|
||||
shape: {
|
||||
showClear: Boolean,
|
||||
showClose: Boolean,
|
||||
showToday: Boolean,
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
calendarWeeks: Boolean,
|
||||
format: { type: String, optional: true },
|
||||
icons: {
|
||||
type: Object,
|
||||
shape: {
|
||||
clear: String,
|
||||
close: String,
|
||||
date: String,
|
||||
down: String,
|
||||
next: String,
|
||||
previous: String,
|
||||
time: String,
|
||||
today: String,
|
||||
up: String,
|
||||
},
|
||||
},
|
||||
keyBinds: { validate: (kb) => typeof kb === "object" || kb === null, optional: true },
|
||||
locale: { type: String, optional: true },
|
||||
maxDate: DateTime,
|
||||
minDate: DateTime,
|
||||
readonly: { type: Boolean, optional: true },
|
||||
useCurrent: Boolean,
|
||||
widgetParent: String,
|
||||
};
|
||||
DatePicker.template = "web.DatePicker";
|
||||
|
||||
/**
|
||||
* Date/time picker
|
||||
*
|
||||
* Similar to the DatePicker component, adding the handling of more specific
|
||||
* time values: hour-minute-second.
|
||||
*
|
||||
* Once again, refer to the tempusdominus documentation for implementation
|
||||
* details.
|
||||
* @extends DatePicker
|
||||
*/
|
||||
export class DateTimePicker extends DatePicker {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
initFormat() {
|
||||
this.defaultFormat = localization.dateTimeFormat;
|
||||
this.format = formatters.get("datetime");
|
||||
this.parse = parsers.get("datetime");
|
||||
this.isLocal = true;
|
||||
}
|
||||
}
|
||||
|
||||
DateTimePicker.defaultProps = {
|
||||
...DatePicker.defaultProps,
|
||||
buttons: {
|
||||
showClear: false,
|
||||
showClose: true,
|
||||
showToday: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
|
||||
<t t-name="web.DatePicker" owl="1">
|
||||
<div class="o_datepicker" aria-atomic="true" t-att-id="datePickerId" data-target-input="nearest">
|
||||
<input type="text"
|
||||
class="o_datepicker_input o_input datetimepicker-input"
|
||||
autofocus=""
|
||||
t-att-name="props.name"
|
||||
t-att-placeholder="props.placeholder"
|
||||
t-attf-data-target="#{{ datePickerId }}"
|
||||
t-att-readonly="props.readonly"
|
||||
t-ref="input"
|
||||
t-on-change="onDateChange"
|
||||
t-on-click="onInputClick"
|
||||
/>
|
||||
<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>
|
||||
|
||||
</templates>
|
||||
@@ -1,4 +1,5 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "../registry";
|
||||
import { memoize } from "../utils/functions";
|
||||
import { useEffect } from "@web/core/utils/hooks";
|
||||
@@ -78,9 +79,12 @@ export function useEnvDebugContext() {
|
||||
}
|
||||
|
||||
export function useDebugCategory(category, context = {}) {
|
||||
const debugContext = useEnvDebugContext();
|
||||
useEffect(
|
||||
() => debugContext.activateCategory(category, context),
|
||||
() => []
|
||||
);
|
||||
const env = useEnv();
|
||||
if (env.debug) {
|
||||
const debugContext = useEnvDebugContext();
|
||||
useEffect(
|
||||
() => debugContext.activateCategory(category, context),
|
||||
() => []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "../browser/browser";
|
||||
import { routeToUrl } from "../browser/router_service";
|
||||
import { registry } from "../registry";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { routeToUrl } from "@web/core/browser/router_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
function activateAssetsDebugging({ env }) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
export function editModelDebug(env, title, model, id) {
|
||||
return env.services.action.doAction({
|
||||
res_model: model,
|
||||
res_id: id,
|
||||
name: title,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
target: "new",
|
||||
flags: { action_buttons: true, headless: true },
|
||||
});
|
||||
}
|
||||
@@ -203,16 +203,16 @@ function matchCondition(record, condition) {
|
||||
return fieldValue > value;
|
||||
case ">=":
|
||||
return fieldValue >= value;
|
||||
case "in":
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.some((fv) => value.includes(fv));
|
||||
}
|
||||
return value.includes(fieldValue);
|
||||
case "not in":
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return !fieldValue.some((fv) => value.includes(fv));
|
||||
}
|
||||
return !value.includes(fieldValue);
|
||||
case "in": {
|
||||
const val = Array.isArray(value) ? value : [value];
|
||||
const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
|
||||
return fieldVal.some((fv) => val.includes(fv));
|
||||
}
|
||||
case "not in": {
|
||||
const val = Array.isArray(value) ? value : [value];
|
||||
const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
|
||||
return !fieldVal.some((fv) => val.includes(fv));
|
||||
}
|
||||
case "like":
|
||||
if (fieldValue === false) {
|
||||
return false;
|
||||
|
||||
@@ -48,12 +48,6 @@ export class Dropdown extends Component {
|
||||
|
||||
// Set up UI active element related behavior ---------------------------
|
||||
this.ui = useService("ui");
|
||||
useBus(this.ui.bus, "active-element-changed", (activeElement) => {
|
||||
if (activeElement !== this.myActiveEl && this.state.open) {
|
||||
// Close when UI active element changes to something different
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
Promise.resolve().then(() => {
|
||||
|
||||
@@ -68,21 +68,14 @@
|
||||
&_menu {
|
||||
@extend .dropdown-menu;
|
||||
|
||||
&.o_dropdown_menu_right {
|
||||
// FIXME: does commenting this break anything?
|
||||
// @extend .dropdown-menu-right;
|
||||
}
|
||||
|
||||
& > li.o_dropdown {
|
||||
&.focus > .o_dropdown_toggler {
|
||||
background-color: $dropdown-link-hover-bg;
|
||||
}
|
||||
& > .o_dropdown_toggler {
|
||||
// FIXME: without this there is no selection feedback
|
||||
// @extend .dropdown-item;
|
||||
&:after {
|
||||
@include o-position-absolute($right: 0);
|
||||
transform: translate(-.6em, .3em);
|
||||
@include o-position-absolute($right: 0, $top: 0);
|
||||
transform: translate(-.6em, .6em);
|
||||
font: .9em/1em FontAwesome;
|
||||
content: "\f0da";
|
||||
}
|
||||
@@ -145,10 +138,6 @@ button.o_dropdown_toggler {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.o_dropdown_active {
|
||||
@extend %--state-active;
|
||||
}
|
||||
|
||||
@include hover-focus-active() {
|
||||
@extend %--state-active;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
t-att-data-hotkey="props.hotkey"
|
||||
t-ref="togglerRef"
|
||||
>
|
||||
<span>
|
||||
<span class="d-flex align-items-center">
|
||||
<t t-slot="toggler" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { memoize } from "../utils/functions";
|
||||
import { sprintf } from "../utils/strings";
|
||||
import { localization } from "./localization";
|
||||
import { _t } from "./translation";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
/**
|
||||
* Change the method toJSON to return the formated value to send server side.
|
||||
*/
|
||||
DateTime.prototype.toJSON = function () {
|
||||
return this.toFormat("yyyy-MM-dd HH:mm:ss");
|
||||
};
|
||||
const SERVER_DATE_FORMAT = "yyyy-MM-dd";
|
||||
const SERVER_TIME_FORMAT = "HH:mm:ss";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -146,7 +142,24 @@ export const strftimeToLuxonFormat = memoize(function strftimeToLuxonFormat(valu
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a DateTime object
|
||||
* Formats a DateTime object to a date string
|
||||
*
|
||||
* `options.timezone` is defaulted to false on dates since we assume they
|
||||
* shouldn't be affected by timezones like datetimes.
|
||||
*
|
||||
* @see formatDateTime
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDate(value, options = {}) {
|
||||
return formatDateTime(value, {
|
||||
timezone: false, // Timezone should never alter a 'date' value.
|
||||
...options,
|
||||
format: options.format || localization.dateFormat,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a DateTime object to a datetime string
|
||||
*
|
||||
* @param {DateTime | false} value
|
||||
* @param {Object} options
|
||||
@@ -178,28 +191,21 @@ export function formatDateTime(value, options = {}) {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parses a string value to an UTC Luxon DateTime object.
|
||||
* Parses a string value to a Luxon DateTime object.
|
||||
*
|
||||
* @param {string} value value to parse.
|
||||
* - Value can take the form of a smart date:
|
||||
* e.g. "+3w" for three weeks from now.
|
||||
* (`options.format` and `options.timezone` are ignored in this case)
|
||||
* `options.timezone` is defaulted to false on dates since we assume they
|
||||
* shouldn't be affected by timezones like datetimes.
|
||||
*
|
||||
* - If value cannot be parsed within the provided format,
|
||||
* ISO8601 and SQL formats are then tried.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} [options.format]
|
||||
* Provided format used to parse the input value.
|
||||
* Default=the session localization format
|
||||
* @returns {DateTime|false} Luxon DateTime object (in the UTC timezone)
|
||||
* @see parseDateTime (Note: since we're only interested by the date itself, the
|
||||
* returned value will always be set at the start of the day)
|
||||
* @returns {DateTime | false} Luxon DateTime object
|
||||
*/
|
||||
export function parseDate(value, options = {}) {
|
||||
return parseDateTime(value, { dateOnly: true, format: options.format });
|
||||
return parseDateTime(value, { timezone: false, ...options }).startOf("day");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string value to an UTC Luxon DateTime object.
|
||||
* Parses a string value to a Luxon DateTime object.
|
||||
*
|
||||
* @param {string} value value to parse.
|
||||
* - Value can take the form of a smart date:
|
||||
@@ -210,11 +216,6 @@ export function parseDate(value, options = {}) {
|
||||
* ISO8601 and SQL formats are then tried.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {boolean} [options.dateOnly]
|
||||
* The output date will represent the start of day (time is truncated).
|
||||
*
|
||||
* Default=false.
|
||||
*
|
||||
* @param {string} [options.format]
|
||||
* Provided format used to parse the input value.
|
||||
*
|
||||
@@ -222,19 +223,18 @@ export function parseDate(value, options = {}) {
|
||||
*
|
||||
* @param {boolean} [options.timezone]
|
||||
* - True = input value is considered being in localtime.
|
||||
* - False = input value is considered being in utc time.
|
||||
* - False = input value is considered being in utc time, and the returned
|
||||
* value will have the UTC zone.
|
||||
*
|
||||
* NB: ISO strings containing timezone information
|
||||
* will have priority over this option.
|
||||
*
|
||||
* Default=false.
|
||||
*
|
||||
* @returns {DateTime|false} Luxon DateTime object (in the UTC timezone)
|
||||
* @returns {DateTime | false} Luxon DateTime object
|
||||
*/
|
||||
export function parseDateTime(value, options = {}) {
|
||||
if (!value) {
|
||||
// BOI: is {DateTime|false} really what we want ?
|
||||
// I think we should return a DateTime.invalid when falsy.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ export function parseDateTime(value, options = {}) {
|
||||
const parseOpts = {
|
||||
setZone: true,
|
||||
zone: options.timezone ? "local" : "utc",
|
||||
locale: options.locale,
|
||||
};
|
||||
|
||||
let result = constrain(parseSmartDateInput(value));
|
||||
@@ -279,6 +280,23 @@ export function parseDateTime(value, options = {}) {
|
||||
throw new Error(sprintf(_t("'%s' is not a correct date or datetime"), value));
|
||||
}
|
||||
|
||||
result = result.toUTC();
|
||||
return options.dateOnly ? result.startOf("day") : result;
|
||||
return options.timezone ? result : result.toUTC();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a serialized string representing the given date.
|
||||
* @param {DateTime} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export const serializeDate = (value) => {
|
||||
return formatDate(value, { format: SERVER_DATE_FORMAT });
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a serialized string representing the given datetime.
|
||||
* @param {DateTime} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export const serializeDateTime = (value) => {
|
||||
return formatDateTime(value, { format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}` });
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ function validateArray(name, array) {
|
||||
}
|
||||
}
|
||||
|
||||
class ORM {
|
||||
export class ORM {
|
||||
constructor(rpc, user) {
|
||||
this.rpc = rpc;
|
||||
this.user = user;
|
||||
@@ -129,9 +129,9 @@ class ORM {
|
||||
}
|
||||
|
||||
readGroup(model, domain, fields, groupby, options = {}, ctx = {}) {
|
||||
validateArray("domain", domain);
|
||||
validatePrimitiveList("fields", "string", fields);
|
||||
validatePrimitiveList("groupby", "string", groupby);
|
||||
validateArray("domain", domain);
|
||||
const kwargs = {
|
||||
domain,
|
||||
groupby,
|
||||
@@ -139,7 +139,7 @@ class ORM {
|
||||
context: ctx,
|
||||
};
|
||||
assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]);
|
||||
return this.call(model, "web_read_group", [], kwargs);
|
||||
return this.call(model, "read_group", [], kwargs);
|
||||
}
|
||||
|
||||
search(model, domain, options = {}, ctx = {}) {
|
||||
@@ -169,6 +169,20 @@ class ORM {
|
||||
return this.call(model, "unlink", [ids], { context: ctx });
|
||||
}
|
||||
|
||||
webReadGroup(model, domain, fields, groupby, options = {}, ctx = {}) {
|
||||
validateArray("domain", domain);
|
||||
validatePrimitiveList("fields", "string", fields);
|
||||
validatePrimitiveList("groupby", "string", groupby);
|
||||
const kwargs = {
|
||||
domain,
|
||||
groupby,
|
||||
fields,
|
||||
context: ctx,
|
||||
};
|
||||
assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]);
|
||||
return this.call(model, "web_read_group", [], kwargs);
|
||||
}
|
||||
|
||||
webSearchRead(model, domain, fields, options = {}, ctx = {}) {
|
||||
validateArray("domain", domain);
|
||||
validatePrimitiveList("fields", "string", fields);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class Tooltip extends Component {}
|
||||
Tooltip.template = "web.Tooltip";
|
||||
Tooltip.props = {
|
||||
tooltip: String,
|
||||
Component: { optional: true },
|
||||
props: { optional: true },
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
.o-tooltip {
|
||||
font-size: smaller;
|
||||
|
||||
.o-tooltip--string {
|
||||
background-color: $o-tooltip-title-background-color;
|
||||
font-weight: bold;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.o-tooltip--help {
|
||||
white-space: pre-line;
|
||||
padding: 8px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.o-tooltip--technical {
|
||||
padding: 8px;
|
||||
margin: 0 0 0 15px;
|
||||
list-style-type: circle;
|
||||
|
||||
.o-tooltip--technical--title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.Tooltip" owl="1">
|
||||
<div class="o-tooltip px-2 py-1">
|
||||
<t t-if="props.Component" t-component="props.Component" t-props="props.props" />
|
||||
<small t-else="" t-esc="props.tooltip"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,102 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useListener } from "@web/core/utils/hooks";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { Tooltip } from "./tooltip";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
/**
|
||||
* The useTooltip hook allows to display custom tooltips on every elements with
|
||||
* a "data-tooltip" attribute inside the Component using the hook. This attribute
|
||||
* can be set on elements for which we prefer a custom tooltip instead of the
|
||||
* native one displaying the value of the "title" attribute.
|
||||
*
|
||||
* Usage (with a parent somewhere using the useTooltip hook):
|
||||
* <button data-tooltip="This is a tooltip">Do something</button>
|
||||
*
|
||||
* The ideal position of the tooltip can be specified thanks to the attribute
|
||||
* "data-tooltip-position":
|
||||
* <button data-tooltip="This is a tooltip" data-tooltip-position="left">Do something</button>
|
||||
*/
|
||||
const OPEN_DELAY = 400;
|
||||
const CLOSE_DELAY = 200;
|
||||
export function useTooltip() {
|
||||
const popover = usePopover();
|
||||
let openTooltipTimeout;
|
||||
let closeTooltip;
|
||||
let target = null;
|
||||
|
||||
/**
|
||||
* Closes the currently opened tooltip if any, or prevent it from opening.
|
||||
*/
|
||||
function cleanup() {
|
||||
browser.clearTimeout(openTooltipTimeout);
|
||||
if (closeTooltip) {
|
||||
closeTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the tooltip after a delay, if the event's target has a data-tooltip
|
||||
* attribute.
|
||||
*
|
||||
* @param {MouseEvent} ev a "mouseenter" event
|
||||
*/
|
||||
function onMouseEnter(ev) {
|
||||
const dataset = ev.target.dataset;
|
||||
const tooltip = dataset.tooltip;
|
||||
|
||||
let Component, props;
|
||||
if ("tooltipComponent" in dataset) {
|
||||
({ Component, props } = JSON.parse(dataset["tooltipComponent"]));
|
||||
Component = registry.category("tooltips").get(Component, null);
|
||||
}
|
||||
if (tooltip || Component) {
|
||||
cleanup();
|
||||
target = ev.target;
|
||||
openTooltipTimeout = browser.setTimeout(() => {
|
||||
const position = dataset.tooltipPosition;
|
||||
closeTooltip = popover.add(
|
||||
target,
|
||||
Tooltip,
|
||||
{ tooltip, Component, props },
|
||||
{ position }
|
||||
);
|
||||
}, OPEN_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
// Track mouse position to be able to detect that we are no longer hovering
|
||||
// the target, thus that we should close the tooltip
|
||||
let positionX;
|
||||
let positionY;
|
||||
useListener("mousemove", (ev) => {
|
||||
positionX = ev.x;
|
||||
positionY = ev.y;
|
||||
});
|
||||
|
||||
// Regularly check that the target is still in the DOM and we're still
|
||||
// hovering it, because if not, we have to close the tooltip
|
||||
browser.setInterval(() => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (!document.body.contains(target)) {
|
||||
return cleanup(); // target is no longer in the DOM
|
||||
}
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
if (
|
||||
positionX < targetRect.left ||
|
||||
positionX > targetRect.right ||
|
||||
positionY < targetRect.top ||
|
||||
positionY > targetRect.bottom
|
||||
) {
|
||||
return cleanup(); // mouse is no longer hovering the target
|
||||
}
|
||||
}, CLOSE_DELAY);
|
||||
|
||||
// Listen (using event delegation) to "mouseenter" events on all nodes with
|
||||
// the "data-tooltip" attribute, to open the tooltip.
|
||||
useListener("mouseenter", "[data-tooltip]", onMouseEnter, { capture: true });
|
||||
}
|
||||
@@ -130,6 +130,84 @@ export function useEffect(effect, computeDependencies = () => [NaN]) {
|
||||
onWillUnmount(() => cleanup());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// useListener
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The useListener hook offers an alternative to Owl's classical event
|
||||
* registration mechanism (with attribute 't-on-eventName' in xml).
|
||||
*
|
||||
* It is especially useful for abstract components, meant to be extended by
|
||||
* specific ones. If those abstract components need to define event handlers,
|
||||
* but don't have any template (because the template completely depends on
|
||||
* specific cases), then using the 't-on' mechanism isn't adequate, as the
|
||||
* handlers would be lost by the template override. In this case, using this
|
||||
* hook instead is more convenient.
|
||||
*
|
||||
* Usage: like all Owl hooks, this function has to be called in the
|
||||
* constructor of an Owl component:
|
||||
*
|
||||
* useListener('click', () => { console.log('clicked'); });
|
||||
*
|
||||
* It also allows to do event delegation, by specifying a native query selector
|
||||
* as second argument. In this case, the handler is only called if the event
|
||||
* is triggered on an element matching the given selector.
|
||||
*
|
||||
* useListener('click', 'button', () => { console.log('clicked'); });
|
||||
*
|
||||
* Note: components that alter the event's target (e.g. Portal) are not
|
||||
* expected to behave as expected with event delegation.
|
||||
*
|
||||
* @param {string} eventName the name of the event
|
||||
* @param {string} [querySelector] a JS native selector for event delegation
|
||||
* @param {function} handler the event handler (will be bound to the component)
|
||||
* @param {Object} [options] to be passed to addEventListener as options.
|
||||
* Useful for listening in the capture phase
|
||||
*/
|
||||
export function useListener(eventName, querySelector, handler, options = {}) {
|
||||
if (typeof arguments[1] !== "string") {
|
||||
querySelector = null;
|
||||
handler = arguments[1];
|
||||
options = arguments[2] || {};
|
||||
}
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("The handler must be a function");
|
||||
}
|
||||
|
||||
const comp = useComponent();
|
||||
let boundHandler;
|
||||
if (querySelector) {
|
||||
boundHandler = function (ev) {
|
||||
let el = ev.target;
|
||||
let target;
|
||||
while (el && !target) {
|
||||
if (el.matches(querySelector)) {
|
||||
target = el;
|
||||
} else if (el === comp.el) {
|
||||
el = null;
|
||||
} else {
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
if (el) {
|
||||
handler.call(comp, ev);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
boundHandler = handler.bind(comp);
|
||||
}
|
||||
useEffect(
|
||||
() => {
|
||||
comp.el.addEventListener(eventName, boundHandler, options);
|
||||
return () => {
|
||||
comp.el.removeEventListener(eventName, boundHandler, options);
|
||||
};
|
||||
},
|
||||
() => []
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// useService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,3 +10,14 @@ export function shallowEqual(obj1, obj2) {
|
||||
obj1Keys.every((key) => obj1[key] === obj2[key])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep copies an object. As it relies on JSON this function as some limitations
|
||||
* - no support for circular objects
|
||||
* - no support for specific classes, that will at best be lost and at worst crash (Map, Set etc...)
|
||||
* @param {Object} An object that is fully JSON stringifiable
|
||||
* @return {Object}
|
||||
*/
|
||||
export function deepCopy(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
export class XMLParser {
|
||||
/**
|
||||
* to override. Should return the parsed content of the arch.
|
||||
* It can call the visitArch function if desired
|
||||
*/
|
||||
parse() {}
|
||||
|
||||
visitXML(xml, callback) {
|
||||
function visit(xml) {
|
||||
if (xml) {
|
||||
let didVisitChildren = false;
|
||||
const visitChildren = () => {
|
||||
for (let child of xml.children) {
|
||||
visit(child);
|
||||
}
|
||||
didVisitChildren = true;
|
||||
};
|
||||
const shouldVisitChildren = callback(xml, visitChildren);
|
||||
if (shouldVisitChildren !== false && !didVisitChildren) {
|
||||
visitChildren();
|
||||
}
|
||||
}
|
||||
}
|
||||
const xmlDoc = typeof xml === "string" ? this.parseXML(xml) : xml;
|
||||
visit(xmlDoc);
|
||||
}
|
||||
|
||||
parseXML(arch) {
|
||||
const parser = new DOMParser();
|
||||
const xml = parser.parseFromString(arch, "text/xml");
|
||||
return xml.documentElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/** @odoo-module **/
|
||||
import { registry } from "@web/core/registry";
|
||||
import legacyFieldRegistry from "web.field_registry";
|
||||
import owlFieldRegistry from "web.field_registry_owl";
|
||||
import { ComponentAdapter } from "web.OwlCompatibility";
|
||||
import { useEffect } from "@web/core/utils/hooks";
|
||||
|
||||
const { Component, tags, hooks } = owl;
|
||||
|
||||
const fieldRegistry = registry.category("fields");
|
||||
|
||||
function getFieldFromRegistry(registry, { fieldType, viewType, fieldName, fieldsDescription }) {
|
||||
if (viewType && fieldType) {
|
||||
const specificType = `${viewType}.${fieldType}`;
|
||||
if (registry.contains(specificType)) {
|
||||
return registry.get(specificType);
|
||||
}
|
||||
}
|
||||
if (!registry.contains(fieldType)) {
|
||||
const field = fieldsDescription[fieldName];
|
||||
fieldType = field && field.type;
|
||||
}
|
||||
return registry.get(fieldType, null);
|
||||
}
|
||||
|
||||
class Field extends Component {
|
||||
static getTangibleField({ record, type, name }) {
|
||||
const FieldClass = getFieldFromRegistry(fieldRegistry, {
|
||||
fieldName: name,
|
||||
fieldType: type,
|
||||
viewType: record.viewtype,
|
||||
fieldsDescription: record.fields,
|
||||
});
|
||||
return { FieldClass };
|
||||
}
|
||||
|
||||
setup() {
|
||||
const { record, type, name } = this.props;
|
||||
this.fieldRef = hooks.useRef("fieldRef");
|
||||
this.FieldComponent = Field.getTangibleField({ record, type, name }).FieldClass;
|
||||
}
|
||||
}
|
||||
|
||||
Field.template = tags.xml/* xml */ `
|
||||
<t t-component="FieldComponent" t-props="props" class="o-field" t-key="props.record.id" t-ref="fieldRef"/>
|
||||
`;
|
||||
|
||||
class FieldSupportsLegacy extends Field {
|
||||
static getTangibleField({ record, type, fieldName }) {
|
||||
let { FieldClass } = super.getTangibleField(...arguments);
|
||||
if (!FieldClass) {
|
||||
FieldClass = getFieldFromRegistry(owlFieldRegistry, {
|
||||
fieldName,
|
||||
fieldType: type,
|
||||
viewType: record.viewType,
|
||||
fieldsDescription: record.fields,
|
||||
});
|
||||
if (FieldClass) {
|
||||
return { FieldClass, isOwlLegacy: true };
|
||||
} else {
|
||||
FieldClass = getFieldFromRegistry(legacyFieldRegistry, {
|
||||
fieldName,
|
||||
fieldType: type,
|
||||
viewType: record.viewType,
|
||||
fieldsDescription: record.fields,
|
||||
});
|
||||
return { FieldClass, isLegacy: true };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.renderId = 0;
|
||||
useEffect(() => {
|
||||
this.renderId++;
|
||||
});
|
||||
if (!this.FieldComponent) {
|
||||
this.env = owl.Component.env;
|
||||
const { record, type, name } = this.props;
|
||||
const { FieldClass, isOwlLegacy, isLegacy } = FieldSupportsLegacy.getTangibleField({
|
||||
record,
|
||||
type,
|
||||
name,
|
||||
});
|
||||
this.FieldComponent = FieldClass;
|
||||
this.isOwlLegacy = isOwlLegacy;
|
||||
this.isLegacy = isLegacy;
|
||||
}
|
||||
}
|
||||
|
||||
get legacyProps() {
|
||||
const legacyRecord = Object.assign({}, this.props.model._legacyRecord_);
|
||||
legacyRecord.data = Object.assign({}, legacyRecord.data, this.props.record.data);
|
||||
return {
|
||||
fieldName: this.props.name,
|
||||
record: legacyRecord,
|
||||
};
|
||||
}
|
||||
|
||||
get widgetArgs() {
|
||||
const { fieldName, record } = this.legacyProps;
|
||||
return [fieldName, record];
|
||||
}
|
||||
}
|
||||
FieldSupportsLegacy.template = tags.xml/* xml */ `<t>
|
||||
<t t-if="!isOwlLegacy and !isLegacy" t-call="${Field.template}" />
|
||||
<t t-elif="isOwlLegacy" t-component="FieldComponent" t-props="legacyProps" />
|
||||
<t t-elif="isLegacy" t-component="ComponentAdapter" widgetArgs="widgetArgs" Component="FieldComponent" t-key="renderId" />
|
||||
</t>
|
||||
`;
|
||||
FieldSupportsLegacy.components = { ComponentAdapter };
|
||||
|
||||
export { FieldSupportsLegacy as Field };
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { formatDateTime } from "@web/core/l10n/dates";
|
||||
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
|
||||
import { localization as l10n } from "@web/core/l10n/localization";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
@@ -36,7 +36,9 @@ function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) {
|
||||
* @private
|
||||
* @param {number} number to format
|
||||
* @param {Object} [options] Options to format
|
||||
* @param {number} [options.decimals=0] maximum number of decimals to use
|
||||
* @param {number} [options.decimals=0] number of decimals to use
|
||||
* if minDigits > 1 is used and effective on the number then decimals
|
||||
* will be shrunk to zero, to avoid displaying irrelevant figures ( 0.01 compared to 1000 )
|
||||
* @param {number} [options.minDigits=1]
|
||||
* the minimum number of digits to preserve when switching to another
|
||||
* level of thousands (e.g. with a value of '2', 4321 will still be
|
||||
@@ -44,11 +46,11 @@ function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) {
|
||||
* @returns {string}
|
||||
*/
|
||||
function humanNumber(number, options = { decimals: 0, minDigits: 1 }) {
|
||||
number = Math.round(number);
|
||||
const decimals = options.decimals || 0;
|
||||
const minDigits = options.minDigits || 1;
|
||||
const d2 = Math.pow(10, decimals);
|
||||
const numberMagnitude = +number.toExponential().split("e+")[1];
|
||||
number = Math.round(number * d2) / d2;
|
||||
// the case numberMagnitude >= 21 corresponds to a number
|
||||
// better expressed in the scientific format.
|
||||
if (numberMagnitude >= 21) {
|
||||
@@ -72,7 +74,10 @@ function humanNumber(number, options = { decimals: 0, minDigits: 1 }) {
|
||||
}
|
||||
}
|
||||
const { decimalPoint, grouping, thousandsSep } = l10n;
|
||||
const [integerPart, decimalPart] = String(number).split(".");
|
||||
|
||||
// determine if we should keep the decimals (we don't want to display 1,020.02k for 1020020)
|
||||
const decimalsToKeep = number >= 1000 ? 0 : decimals;
|
||||
const [integerPart, decimalPart] = number.toFixed(decimalsToKeep).split(".");
|
||||
const int = insertThousandsSep(sign * Number(integerPart), thousandsSep, grouping);
|
||||
if (!decimalPart) {
|
||||
return int + symbol;
|
||||
@@ -282,7 +287,13 @@ export function formatMonetary(value, options = {}) {
|
||||
const currency = session.currencies[currencyId];
|
||||
const digits = (currency && currency.digits) || options.digits;
|
||||
|
||||
const formatted = options.humanReadable ? humanNumber(value) : formatFloat(value, { digits });
|
||||
let formatted;
|
||||
if (options.humanReadable) {
|
||||
formatted = humanNumber(value, { decimals: digits ? digits[1] : 2 });
|
||||
} else {
|
||||
formatted = formatFloat(value, { digits });
|
||||
}
|
||||
|
||||
if (!currency || options.noSymbol) {
|
||||
return formatted;
|
||||
}
|
||||
@@ -312,6 +323,7 @@ export function formatPercentage(value, options = {}) {
|
||||
|
||||
registry
|
||||
.category("formatters")
|
||||
.add("date", formatDate)
|
||||
.add("datetime", formatDateTime)
|
||||
.add("float", formatFloat)
|
||||
.add("float_factor", formatFloatFactor)
|
||||
|
||||
@@ -152,4 +152,5 @@ registry
|
||||
.add("float", parseFloat)
|
||||
.add("float_time", parseFloatTime)
|
||||
.add("integer", parseInteger)
|
||||
.add("monetary", parseMonetary)
|
||||
.add("percentage", parsePercentage);
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useDebugCategory } from "../core/debug/debug_context";
|
||||
import { Dialog } from "../core/dialog/dialog";
|
||||
import { useEffect, useService } from "@web/core/utils/hooks";
|
||||
import { ViewNotFoundError } from "../webclient/actions/action_service";
|
||||
import { cleanDomFromBootstrap, wrapSuccessOrFail, mapDoActionOptionAPI } from "./utils";
|
||||
import { cleanDomFromBootstrap, wrapSuccessOrFail } from "./utils";
|
||||
import { mapDoActionOptionAPI } from "./backend_utils";
|
||||
|
||||
const { Component, tags, hooks } = owl;
|
||||
|
||||
@@ -335,15 +336,16 @@ export class ViewAdapter extends ActionAdapter {
|
||||
async _trigger_up(ev) {
|
||||
const payload = ev.data;
|
||||
if (ev.name === "switch_view") {
|
||||
const state = ev.target.exportState();
|
||||
try {
|
||||
await this.actionService.switchView(payload.view_type, {
|
||||
resId: payload.res_id,
|
||||
resIds: state.resIds,
|
||||
searchModel: state.searchModel,
|
||||
searchPanel: state.searchPanel,
|
||||
mode: payload.mode,
|
||||
});
|
||||
const props = {};
|
||||
if (payload.mode) {
|
||||
props.mode = payload.mode;
|
||||
}
|
||||
// if (payload.res_id) {
|
||||
// if make 'open a record, come back, and create a new record' crash
|
||||
props.resId = payload.res_id;
|
||||
// }
|
||||
await this.actionService.switchView(payload.view_type, props);
|
||||
} catch (e) {
|
||||
if (e instanceof ViewNotFoundError) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import ActionModel from "@web/legacy/js/views/action_model";
|
||||
|
||||
/**
|
||||
* @param {string} state
|
||||
* @returns {string}
|
||||
*/
|
||||
function searchModelStateFromLegacy(state) {
|
||||
/**
|
||||
* Possible problem if only one of ControlPanelModelExtension or SearchPanelModelExtension is installed.
|
||||
* We might need to do something in SearchModel.
|
||||
* @todo (DAM) check when search panel is reworked.
|
||||
*/
|
||||
const parsedState = JSON.parse(state);
|
||||
const newState = {};
|
||||
|
||||
if (parsedState.ControlPanelModelExtension) {
|
||||
const {
|
||||
query,
|
||||
filters,
|
||||
nextGroupId,
|
||||
nextGroupNumber,
|
||||
nextId,
|
||||
} = parsedState.ControlPanelModelExtension;
|
||||
|
||||
newState.nextGroupId = nextGroupId;
|
||||
newState.nextGroupNumber = nextGroupNumber;
|
||||
newState.nextId = nextId;
|
||||
newState.query = [];
|
||||
newState.searchItems = {};
|
||||
|
||||
for (const queryElem of query) {
|
||||
const filterId = queryElem.filterId;
|
||||
const filter = filters[filterId];
|
||||
const newQueryElem = { searchItemId: filterId };
|
||||
switch (filter.type) {
|
||||
case "filter":
|
||||
if (filter.hasOptions) {
|
||||
newQueryElem.generatorId = queryElem.optionId;
|
||||
}
|
||||
break;
|
||||
case "groupBy":
|
||||
if (filter.hasOptions) {
|
||||
newQueryElem.intervalId = queryElem.optionId;
|
||||
}
|
||||
break;
|
||||
case "field":
|
||||
newQueryElem.autocompleteValue = {
|
||||
value: queryElem.value,
|
||||
label: queryElem.label,
|
||||
operator: queryElem.operator,
|
||||
};
|
||||
break;
|
||||
}
|
||||
newState.query.push(newQueryElem);
|
||||
}
|
||||
|
||||
for (const filter of Object.values(filters)) {
|
||||
const item = Object.assign({}, filter);
|
||||
switch (item.type) {
|
||||
case "groupBy":
|
||||
if (filter.hasOptions) {
|
||||
item.type = "dateGroupBy";
|
||||
item.defaultIntervalId = item.defaultOptionId;
|
||||
delete item.hasOptions;
|
||||
delete item.defaultOptionId;
|
||||
}
|
||||
break;
|
||||
case "filter":
|
||||
if (filter.hasOptions) {
|
||||
item.type = "dateFilter";
|
||||
item.defaultGeneratorId = item.defaultOptionId;
|
||||
delete item.hasOptions;
|
||||
delete item.isDateFilter;
|
||||
delete item.defaultOptionId;
|
||||
}
|
||||
break;
|
||||
case "favorite":
|
||||
item.orderBy = item.orderedBy;
|
||||
delete item.orderedBy;
|
||||
break;
|
||||
}
|
||||
newState.searchItems[filter.id] = item;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedState.SearchPanelModelExtension) {
|
||||
const { sections, searchPanelInfo } = parsedState.SearchPanelModelExtension;
|
||||
newState.sections = sections;
|
||||
//! Can be undefined. See search_model.__legacyParseSearchPanelArchAnyway
|
||||
newState.searchPanelInfo = searchPanelInfo;
|
||||
}
|
||||
|
||||
for (const [key, extension] of Object.entries(ActionModel.registry.entries())) {
|
||||
if (
|
||||
!["ControlPanel", "SearchPanel"].includes(key) &&
|
||||
parsedState[extension.name] !== undefined
|
||||
) {
|
||||
newState[key] = parsedState[extension.name];
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(newState).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return JSON.stringify(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} state
|
||||
* @returns {string}
|
||||
*/
|
||||
export function searchModelStateToLegacy(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedState = JSON.parse(state);
|
||||
const query = [];
|
||||
for (const queryElem of parsedState.query) {
|
||||
const searchItemId = queryElem.searchItemId;
|
||||
const searchItem = parsedState.searchItems[searchItemId];
|
||||
const groupId = searchItem.groupId;
|
||||
const legacyQueryElem = { filterId: searchItemId, groupId };
|
||||
switch (searchItem.type) {
|
||||
case "dateFilter":
|
||||
legacyQueryElem.optionId = queryElem.generatorId;
|
||||
break;
|
||||
case "dateGroupBy":
|
||||
legacyQueryElem.optionId = queryElem.intervalId;
|
||||
break;
|
||||
case "comparison":
|
||||
legacyQueryElem.type = "comparison";
|
||||
legacyQueryElem.dateFilterId = searchItem.dateFilterId;
|
||||
break;
|
||||
case "field":
|
||||
legacyQueryElem.value = queryElem.autocompleteValue.value;
|
||||
legacyQueryElem.label = queryElem.autocompleteValue.label;
|
||||
legacyQueryElem.operator = queryElem.autocompleteValue.operator;
|
||||
break;
|
||||
}
|
||||
query.push(legacyQueryElem);
|
||||
}
|
||||
|
||||
const filters = {};
|
||||
for (const item of Object.values(parsedState.searchItems)) {
|
||||
const filter = Object.assign({}, item);
|
||||
switch (item.type) {
|
||||
case "dateGroupBy":
|
||||
filter.type = "groupBy";
|
||||
filter.hasOptions = true;
|
||||
filter.defaultOptionId = item.defaultIntervalId;
|
||||
delete filter.defaultIntervalId;
|
||||
break;
|
||||
case "dateFilter":
|
||||
filter.type = "filter";
|
||||
filter.isDateFilter = true;
|
||||
filter.hasOptions = true;
|
||||
filter.defaultOptionId = item.defaultGeneratorId;
|
||||
delete filter.defaultGeneratorId;
|
||||
break;
|
||||
case "favorite":
|
||||
filter.orderedBy = item.orderBy;
|
||||
delete filter.orderBy;
|
||||
break;
|
||||
}
|
||||
filters[item.id] = filter;
|
||||
}
|
||||
|
||||
const { nextGroupId, nextGroupNumber, nextId, sections, searchPanelInfo } = parsedState;
|
||||
const legacyState = {};
|
||||
legacyState.ControlPanelModelExtension = {
|
||||
query,
|
||||
filters,
|
||||
nextGroupId,
|
||||
nextGroupNumber,
|
||||
nextId,
|
||||
};
|
||||
legacyState.SearchPanelModelExtension = { sections, searchPanelInfo };
|
||||
|
||||
for (const [key, extension] of Object.entries(ActionModel.registry.entries())) {
|
||||
if (!["ControlPanel", "SearchPanel"].includes(key) && parsedState[key] !== undefined) {
|
||||
legacyState[extension.name] = parsedState[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(legacyState);
|
||||
}
|
||||
|
||||
export function exportGlobalState(legacyControllerState) {
|
||||
const { resIds, searchModel, searchPanel } = legacyControllerState;
|
||||
const globalState = {};
|
||||
if (searchPanel) {
|
||||
globalState.searchPanel = searchPanel;
|
||||
}
|
||||
if (resIds) {
|
||||
globalState.resIds = resIds;
|
||||
}
|
||||
const newSearchModel = searchModelStateFromLegacy(searchModel);
|
||||
if (newSearchModel) {
|
||||
globalState.searchModel = newSearchModel;
|
||||
}
|
||||
return globalState;
|
||||
}
|
||||
|
||||
export function exportLocalState(legacyControllerState) {
|
||||
const state = Object.assign({}, legacyControllerState);
|
||||
delete state.searchModel;
|
||||
delete state.searchPanel;
|
||||
delete state.resIds;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function mapDoActionOptionAPI(legacyOptions) {
|
||||
legacyOptions = legacyOptions || {};
|
||||
// use camelCase instead of snake_case for some keys
|
||||
Object.assign(legacyOptions, {
|
||||
additionalContext: legacyOptions.additional_context,
|
||||
clearBreadcrumbs: legacyOptions.clear_breadcrumbs,
|
||||
viewType: legacyOptions.view_type,
|
||||
onClose: legacyOptions.on_close,
|
||||
props: {
|
||||
resId: legacyOptions.res_id,
|
||||
},
|
||||
});
|
||||
if (legacyOptions.controllerState) {
|
||||
legacyOptions.props.globalState = exportGlobalState(legacyOptions.controllerState);
|
||||
}
|
||||
delete legacyOptions.additional_context;
|
||||
delete legacyOptions.clear_breadcrumbs;
|
||||
delete legacyOptions.view_type;
|
||||
delete legacyOptions.res_id;
|
||||
delete legacyOptions.on_close;
|
||||
return legacyOptions;
|
||||
}
|
||||
|
||||
export function makeLegacyActionManagerService(legacyEnv) {
|
||||
// add a service to redirect 'do-action' events triggered on the bus in the
|
||||
// legacy env to the action-manager service in the wowl env
|
||||
return {
|
||||
dependencies: ["action"],
|
||||
start(env) {
|
||||
function do_action(action, options) {
|
||||
const legacyOptions = mapDoActionOptionAPI(options);
|
||||
return env.services.action.doAction(action, legacyOptions);
|
||||
}
|
||||
legacyEnv.bus.on("do-action", null, (payload) => {
|
||||
const { action, options } = payload;
|
||||
do_action(action, options);
|
||||
});
|
||||
return { do_action };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function breadcrumbsToLegacy(breadcrumbs) {
|
||||
if (!breadcrumbs) {
|
||||
return;
|
||||
}
|
||||
return breadcrumbs.slice().map((bc) => {
|
||||
return { title: bc.name, controllerID: bc.jsId };
|
||||
});
|
||||
}
|
||||
@@ -1,212 +1,15 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Dialog } from "../core/dialog/dialog";
|
||||
import { formatDateTime, parseDateTime } from "../core/l10n/dates";
|
||||
import { _lt } from "../core/l10n/translation";
|
||||
import { registry } from "../core/registry";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { formatDateTime, parseDateTime } from "@web/core/l10n/dates";
|
||||
import { formatMany2one } from "@web/fields/formatters";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const { hooks, tags } = owl;
|
||||
const { hooks } = owl;
|
||||
const { useState } = hooks;
|
||||
|
||||
function editModelDebug(env, title, model, id) {
|
||||
return env.services.action.doAction({
|
||||
res_model: model,
|
||||
res_id: id,
|
||||
name: title,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
target: "new",
|
||||
flags: { action_buttons: true, headless: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Action items
|
||||
|
||||
function actionSeparator() {
|
||||
return {
|
||||
type: "separator",
|
||||
sequence: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function accessSeparator({ accessRights, action }) {
|
||||
const { canSeeModelAccess, canSeeRecordRules } = accessRights;
|
||||
if (!action.res_model || (!canSeeModelAccess && !canSeeRecordRules)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "separator",
|
||||
sequence: 200,
|
||||
};
|
||||
}
|
||||
|
||||
function editAction({ action, env }) {
|
||||
if (!action.id) {
|
||||
return null;
|
||||
}
|
||||
const description = env._t("Edit Action");
|
||||
return {
|
||||
type: "item",
|
||||
description,
|
||||
callback: () => {
|
||||
editModelDebug(env, description, action.type, action.id);
|
||||
},
|
||||
sequence: 110,
|
||||
};
|
||||
}
|
||||
|
||||
function viewFields({ action, env }) {
|
||||
if (!action.res_model) {
|
||||
return null;
|
||||
}
|
||||
const description = env._t("View Fields");
|
||||
return {
|
||||
type: "item",
|
||||
description,
|
||||
callback: async () => {
|
||||
const modelId = (
|
||||
await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
|
||||
limit: 1,
|
||||
})
|
||||
)[0];
|
||||
env.services.action.doAction({
|
||||
res_model: "ir.model.fields",
|
||||
name: description,
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
domain: [["model_id", "=", modelId]],
|
||||
type: "ir.actions.act_window",
|
||||
context: {
|
||||
default_model_id: modelId,
|
||||
},
|
||||
});
|
||||
},
|
||||
sequence: 120,
|
||||
};
|
||||
}
|
||||
|
||||
function manageFilters({ action, env }) {
|
||||
if (!action.res_model) {
|
||||
return null;
|
||||
}
|
||||
const description = env._t("Manage Filters");
|
||||
return {
|
||||
type: "item",
|
||||
description,
|
||||
callback: () => {
|
||||
// manage_filters
|
||||
env.services.action.doAction({
|
||||
res_model: "ir.filters",
|
||||
name: description,
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
type: "ir.actions.act_window",
|
||||
context: {
|
||||
search_default_my_filters: true,
|
||||
search_default_model_id: action.res_model,
|
||||
},
|
||||
});
|
||||
},
|
||||
sequence: 130,
|
||||
};
|
||||
}
|
||||
|
||||
function technicalTranslation({ action, env }) {
|
||||
if (!action.res_model) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "item",
|
||||
description: env._t("Technical Translation"),
|
||||
callback: async () => {
|
||||
const result = await env.services.orm.call(
|
||||
"ir.translation",
|
||||
"get_technical_translations",
|
||||
[action.res_model]
|
||||
);
|
||||
env.services.action.doAction(result);
|
||||
},
|
||||
sequence: 140,
|
||||
};
|
||||
}
|
||||
|
||||
function viewAccessRights({ accessRights, action, env }) {
|
||||
if (!action.res_model || !accessRights.canSeeModelAccess) {
|
||||
return null;
|
||||
}
|
||||
const description = env._t("View Access Rights");
|
||||
return {
|
||||
type: "item",
|
||||
description,
|
||||
callback: async () => {
|
||||
const modelId = (
|
||||
await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
|
||||
limit: 1,
|
||||
})
|
||||
)[0];
|
||||
env.services.action.doAction({
|
||||
res_model: "ir.model.access",
|
||||
name: description,
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
domain: [["model_id", "=", modelId]],
|
||||
type: "ir.actions.act_window",
|
||||
context: {
|
||||
default_model_id: modelId,
|
||||
},
|
||||
});
|
||||
},
|
||||
sequence: 210,
|
||||
};
|
||||
}
|
||||
|
||||
function viewRecordRules({ accessRights, action, env }) {
|
||||
if (!action.res_model || !accessRights.canSeeRecordRules) {
|
||||
return null;
|
||||
}
|
||||
const description = env._t("Model Record Rules");
|
||||
return {
|
||||
type: "item",
|
||||
description: env._t("View Record Rules"),
|
||||
callback: async () => {
|
||||
const modelId = (
|
||||
await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
|
||||
limit: 1,
|
||||
})
|
||||
)[0];
|
||||
env.services.action.doAction({
|
||||
res_model: "ir.rule",
|
||||
name: description,
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
domain: [["model_id", "=", modelId]],
|
||||
type: "ir.actions.act_window",
|
||||
context: {
|
||||
default_model_id: modelId,
|
||||
},
|
||||
});
|
||||
},
|
||||
sequence: 220,
|
||||
};
|
||||
}
|
||||
|
||||
class FieldViewGetDialog extends Dialog {}
|
||||
FieldViewGetDialog.props = Object.assign({}, Dialog.props, {
|
||||
arch: { type: String },
|
||||
close: Function,
|
||||
});
|
||||
FieldViewGetDialog.bodyTemplate = tags.xml`<pre t-esc="props.arch"/>`;
|
||||
FieldViewGetDialog.title = _lt("Fields View Get");
|
||||
const debugRegistry = registry.category("debug");
|
||||
|
||||
class GetMetadataDialog extends Dialog {
|
||||
setup() {
|
||||
@@ -243,7 +46,6 @@ class GetMetadataDialog extends Dialog {
|
||||
}
|
||||
GetMetadataDialog.bodyTemplate = "web.DebugMenu.getMetadataBody";
|
||||
GetMetadataDialog.title = _lt("View Metadata");
|
||||
|
||||
class SetDefaultDialog extends Dialog {
|
||||
setup() {
|
||||
super.setup();
|
||||
@@ -389,66 +191,7 @@ SetDefaultDialog.bodyTemplate = "web.DebugMenu.setDefaultBody";
|
||||
SetDefaultDialog.footerTemplate = "web.DebugMenu.SetDefaultFooter";
|
||||
SetDefaultDialog.title = _lt("Set Default");
|
||||
|
||||
function viewSeparator() {
|
||||
return {
|
||||
type: "separator",
|
||||
sequence: 300,
|
||||
};
|
||||
}
|
||||
|
||||
function fieldsViewGet({ component, env }) {
|
||||
return {
|
||||
type: "item",
|
||||
description: env._t("Fields View Get"),
|
||||
callback: () => {
|
||||
const props = {
|
||||
arch: component.props.viewInfo.arch,
|
||||
};
|
||||
env.services.dialog.add(FieldViewGetDialog, props);
|
||||
},
|
||||
sequence: 340,
|
||||
};
|
||||
}
|
||||
|
||||
export function editView({ accessRights, action, component, env }) {
|
||||
if (!accessRights.canEditView) {
|
||||
return null;
|
||||
}
|
||||
let { view_id: viewId, type: viewType } = component.props.viewInfo;
|
||||
viewType = viewType === "tree" ? "list" : viewType;
|
||||
const displayName = action.views.find((v) => v.type === viewType).name.toString();
|
||||
const description = env._t("Edit View: ") + displayName;
|
||||
return {
|
||||
type: "item",
|
||||
description,
|
||||
callback: () => {
|
||||
editModelDebug(env, description, "ir.ui.view", viewId);
|
||||
},
|
||||
sequence: 350,
|
||||
};
|
||||
}
|
||||
|
||||
export function editSearchView({ accessRights, component, env }) {
|
||||
if (!accessRights.canEditView) {
|
||||
return null;
|
||||
}
|
||||
const description = env._t("Edit SearchView");
|
||||
return {
|
||||
type: "item",
|
||||
description,
|
||||
callback: () => {
|
||||
editModelDebug(
|
||||
env,
|
||||
description,
|
||||
"ir.ui.view",
|
||||
component.props.viewParams.controlPanelFieldsView.view_id
|
||||
);
|
||||
},
|
||||
sequence: 360,
|
||||
};
|
||||
}
|
||||
|
||||
// Form view itemss
|
||||
// Form view items
|
||||
|
||||
function setDefaults({ action, component, env }) {
|
||||
return {
|
||||
@@ -515,26 +258,6 @@ function manageAttachments({ action, component, env }) {
|
||||
};
|
||||
}
|
||||
|
||||
const debugRegistry = registry.category("debug");
|
||||
|
||||
debugRegistry
|
||||
.category("action")
|
||||
.add("actionSeparator", actionSeparator)
|
||||
.add("editAction", editAction)
|
||||
.add("viewFields", viewFields)
|
||||
.add("manageFilters", manageFilters)
|
||||
.add("technicalTranslation", technicalTranslation)
|
||||
.add("accessSeparator", accessSeparator)
|
||||
.add("viewAccessRights", viewAccessRights)
|
||||
.add("viewRecordRules", viewRecordRules);
|
||||
|
||||
debugRegistry
|
||||
.category("view")
|
||||
.add("viewSeparator", viewSeparator)
|
||||
.add("fieldsViewGet", fieldsViewGet)
|
||||
.add("editView", editView)
|
||||
.add("editSearchView", editSearchView);
|
||||
|
||||
debugRegistry
|
||||
.category("form")
|
||||
.add("setDefaults", setDefaults)
|
||||
|
||||
@@ -221,7 +221,7 @@ odoo.define('web.DatePickerOwl', function (require) {
|
||||
useCurrent: Boolean,
|
||||
widgetParent: String,
|
||||
};
|
||||
DatePicker.template = 'web.DatePicker';
|
||||
DatePicker.template = "web.Legacy.DatePicker";
|
||||
|
||||
/**
|
||||
* Date/time picker
|
||||
|
||||
@@ -13,10 +13,6 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
const FAVORITE_SHARED_GROUP = 2;
|
||||
const DISABLE_FAVORITE = "search_disable_custom_filters";
|
||||
|
||||
let filterId = 1;
|
||||
let groupId = 1;
|
||||
let groupNumber = 0;
|
||||
|
||||
/**
|
||||
* Control panel model
|
||||
*
|
||||
@@ -215,6 +211,9 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
*/
|
||||
prepareState() {
|
||||
Object.assign(this.state, {
|
||||
nextGroupId: 1,
|
||||
nextGroupNumber: 1,
|
||||
nextId: 1,
|
||||
filters: {},
|
||||
query: [],
|
||||
});
|
||||
@@ -318,13 +317,13 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
const preFavorite = await this._saveQuery(preFilter);
|
||||
this.clearQuery();
|
||||
const filter = Object.assign(preFavorite, {
|
||||
groupId,
|
||||
id: filterId,
|
||||
groupId: this.state.nextGroupId,
|
||||
id: this.state.nextId,
|
||||
});
|
||||
this.state.filters[filterId] = filter;
|
||||
this.state.query.push({ groupId, filterId });
|
||||
groupId++;
|
||||
filterId++;
|
||||
this.state.filters[this.state.nextId] = filter;
|
||||
this.state.query.push({ groupId: this.state.nextGroupId, filterId: this.state.nextId });
|
||||
this.state.nextGroupId++;
|
||||
this.state.nextId++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,18 +339,18 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
const newFilterIdS = [];
|
||||
prefilters.forEach(preFilter => {
|
||||
const filter = Object.assign(preFilter, {
|
||||
groupId,
|
||||
groupNumber,
|
||||
id: filterId,
|
||||
groupId: this.state.nextGroupId,
|
||||
groupNumber: this.state.nextGroupNumber,
|
||||
id: this.state.nextId,
|
||||
type: 'filter',
|
||||
});
|
||||
this.state.filters[filterId] = filter;
|
||||
this.state.query.push({ groupId, filterId });
|
||||
newFilterIdS.push(filterId);
|
||||
filterId++;
|
||||
this.state.filters[this.state.nextId] = filter;
|
||||
this.state.query.push({ groupId: this.state.nextGroupId, filterId: this.state.nextId });
|
||||
newFilterIdS.push(this.state.nextId);
|
||||
this.state.nextId++;
|
||||
});
|
||||
groupId++;
|
||||
groupNumber++;
|
||||
this.state.nextGroupId++;
|
||||
this.state.nextGroupNumber++;
|
||||
return newFilterIdS;
|
||||
}
|
||||
|
||||
@@ -366,22 +365,22 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
description: field.string || field.name,
|
||||
fieldName: field.name,
|
||||
fieldType: field.type,
|
||||
groupId: groupBy ? groupBy.groupId : groupId++,
|
||||
groupNumber,
|
||||
id: filterId,
|
||||
groupId: groupBy ? groupBy.groupId : this.state.nextGroupId++,
|
||||
groupNumber: this.state.nextGroupNumber,
|
||||
id: this.state.nextId,
|
||||
type: 'groupBy',
|
||||
custom: true,
|
||||
};
|
||||
this.state.filters[filterId] = filter;
|
||||
this.state.filters[this.state.nextId] = filter;
|
||||
if (['date', 'datetime'].includes(field.type)) {
|
||||
filter.hasOptions = true;
|
||||
filter.defaultOptionId = DEFAULT_INTERVAL;
|
||||
this.toggleFilterWithOptions(filterId);
|
||||
this.toggleFilterWithOptions(this.state.nextId);
|
||||
} else {
|
||||
this.toggleFilter(filterId);
|
||||
this.toggleFilter(this.state.nextId);
|
||||
}
|
||||
groupNumber++;
|
||||
filterId++;
|
||||
this.state.nextGroupNumber++;
|
||||
this.state.nextId++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -732,14 +731,14 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
*/
|
||||
_createGroupOfFilters(pregroup) {
|
||||
pregroup.forEach(preFilter => {
|
||||
const filter = Object.assign(preFilter, { groupId, id: filterId });
|
||||
this.state.filters[filterId] = filter;
|
||||
const filter = Object.assign(preFilter, { groupId: this.state.nextGroupId, id: this.state.nextId });
|
||||
this.state.filters[this.state.nextId] = filter;
|
||||
if (!this.defaultFavoriteId && filter.isDefault && filter.type === 'field') {
|
||||
this._prepareDefaultLabel(filter);
|
||||
}
|
||||
filterId++;
|
||||
this.state.nextId++;
|
||||
});
|
||||
groupId++;
|
||||
this.state.nextGroupId++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -784,7 +783,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
}
|
||||
currentTag = preFilter.tag;
|
||||
currentGroup = [];
|
||||
groupNumber++;
|
||||
this.state.nextGroupNumber++;
|
||||
}
|
||||
if (preFilter.tag !== 'separator') {
|
||||
const filter = {
|
||||
@@ -811,7 +810,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
|
||||
}
|
||||
}
|
||||
if (filter.type === 'filter' || filter.type === 'groupBy') {
|
||||
filter.groupNumber = groupNumber;
|
||||
filter.groupNumber = this.state.nextGroupNumber;
|
||||
}
|
||||
this._extractAttributes(filter, preFilter.attrs);
|
||||
currentGroup.push(filter);
|
||||
|
||||
@@ -151,7 +151,7 @@ odoo.define("web.Model", function (require) {
|
||||
* 3. Registration of the extension into the main model
|
||||
* @see Registry()
|
||||
* ```
|
||||
* ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10);
|
||||
* ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10);
|
||||
* ```
|
||||
*
|
||||
*-------------------------------------------------------------------------
|
||||
|
||||
@@ -75,6 +75,10 @@ return AbstractModel.extend({
|
||||
orderBy: params.orderBy
|
||||
});
|
||||
|
||||
if (this.chart.measure === "__count") {
|
||||
this.chart.measure = "__count__";
|
||||
}
|
||||
|
||||
this._computeDerivedParams();
|
||||
|
||||
return this._loadGraph();
|
||||
@@ -117,6 +121,10 @@ return AbstractModel.extend({
|
||||
this.chart.timeRanges = params.timeRanges;
|
||||
}
|
||||
|
||||
if (this.chart.measure === "__count") {
|
||||
this.chart.measure = "__count__";
|
||||
}
|
||||
|
||||
this._computeDerivedParams();
|
||||
|
||||
if ('mode' in params) {
|
||||
|
||||
@@ -956,7 +956,7 @@ odoo.define("web/static/src/js/views/graph/graph_renderer", function (require) {
|
||||
}
|
||||
}
|
||||
|
||||
GraphRenderer.template = "web.GraphRenderer";
|
||||
GraphRenderer.template = "web.Legacy.GraphRenderer";
|
||||
GraphRenderer.props = {
|
||||
arch: {
|
||||
type: Object,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
this._updateActiveValues();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
get sections() {
|
||||
return this.model.get("sections", s => !s.empty);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
@@ -204,6 +212,6 @@
|
||||
importedState: { type: String, optional: 1 },
|
||||
searchModel: Model,
|
||||
};
|
||||
SearchPanel.template = "web.SearchPanel";
|
||||
SearchPanel.template = "web.Legacy.SearchPanel";
|
||||
|
||||
export default SearchPanel;
|
||||
|
||||
@@ -6,7 +6,7 @@ import Widget from "web.Widget";
|
||||
import { setScrollPosition } from "../core/utils/scrolling";
|
||||
import { useSetupAction } from "../webclient/actions/action_hook";
|
||||
import { ClientActionAdapter } from "./action_adapters";
|
||||
import { breadcrumbsToLegacy } from "./utils";
|
||||
import { breadcrumbsToLegacy } from "./backend_utils";
|
||||
|
||||
const { Component, hooks, tags } = owl;
|
||||
const actionRegistry = registry.category("actions");
|
||||
@@ -42,7 +42,7 @@ function registerClientAction(name, action) {
|
||||
this.props.state && this.props.state.__on_reverse_breadcrumb__;
|
||||
useSetupAction({
|
||||
beforeLeave: () => this.controllerRef.comp.__widget.canBeRemoved(),
|
||||
export: () => this.controllerRef.comp.exportState(),
|
||||
exportLocalState: () => this.controllerRef.comp.exportState(),
|
||||
});
|
||||
this.onScrollTo = (ev) => {
|
||||
setScrollPosition(this, { left: ev.detail.left, top: ev.detail.top });
|
||||
|
||||
@@ -5,7 +5,7 @@ import Context from "web.Context";
|
||||
|
||||
import { browser } from "../core/browser/browser";
|
||||
import { registry } from "../core/registry";
|
||||
import { mapDoActionOptionAPI } from "./utils";
|
||||
import { mapDoActionOptionAPI } from "./backend_utils";
|
||||
|
||||
export const legacyServiceProvider = {
|
||||
dependencies: ["effect", "action"],
|
||||
|
||||