[IMP] project: add rightpanel in project update view

This commit adds a right panel in the project update view.

This is done by extending each View and Controller by mixins handling
the RightPanel functionnality.
In the RightPanelControllerMixin, we use the component Adapter to launch
the Owl RightPanel Component from a Legacy Controller.
This solution is based on SearchPanel and ControlPanel implementation.
In the RendererMixin we handle some styling just as it's done for the
SearchPanel.
In the ViewMixin, we add the handling of a RightSidePanel config
parameter which is passed to the Controller via params.

The Project RightPanel Component gets information about the active
project in the context through a rpc call and render the resulting data
in a owl template. This template doen't have the ambition to be generic
but only specific to this view.

Serverside, the data are collected in a dict through few methods in
order to be easily extended.

Mixins are used in kanban and list views, each time a project update
view is added, the developper will have to extend the dedicated View,
Renderer and Controller.

PR: #68899

task-2393768
This commit is contained in:
Thibault Libioulle
2021-06-01 12:03:55 +02:00
parent c92673c10e
commit e48be257e1
12 changed files with 630 additions and 3 deletions
+3
View File
@@ -63,9 +63,12 @@
'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/*',
'project/static/src/scss/project_dashboard.scss',
'project/static/src/scss/project_form.scss',
'project/static/src/scss/project_rightpanel.scss',
'project/static/src/scss/project_widgets.scss',
],
'web.assets_frontend': [
+53 -1
View File
@@ -2,13 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import json
from collections import defaultdict
from datetime import timedelta, datetime
from random import randint
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, AccessError, ValidationError, RedirectWarning
from odoo.tools.misc import format_date, get_lang
from odoo.osv.expression import OR
from .project_task_recurrence import DAYS, WEEKS
@@ -510,6 +510,58 @@ class Project(models.Model):
'color': self.last_update_color,
}
def get_panel_data(self):
self.ensure_one()
return {
'user': self._get_user_values(),
'tasks_analysis': self._get_tasks_analysis(),
'milestones': self._get_milestones(),
}
def _get_user_values(self):
return {
'is_project_manager': self.user_has_groups('project.group_project_manager'),
}
def _get_tasks_analysis(self):
self.ensure_one()
counts = self._get_tasks_analysis_counts(updated=True)
data = [{
'name': _("Open Tasks"),
'action': {
'action': "project.action_project_task_user_tree",
'additional_context': json.dumps({
'search_default_project_id': self.id,
'active_id': self.id,
'search_default_open_tasks': True,
'search_default_Stage': True,
'search_default_User': True
}),
},
'value': counts['open_tasks_count']
}, {
'name': _("Updated (Last 30 Days)"),
'action': {
'action': "project.action_project_task_burndown_chart_report",
'additional_context': json.dumps({
'search_default_project_id': self.id,
'active_id': self.id,
'search_default_last_month': 1,
'graph_mode': 'bar'
}),
},
'value': counts['updated_tasks_count']
}]
return {
'data': data,
}
def _get_milestones(self):
self.ensure_one()
return {
'data': self.milestone_ids._get_data_list(),
}
def _get_tasks_analysis_counts(self, created=False, updated=False):
tasks = self.env['project.task'].search([('display_project_id', '=', self.id)])
open_tasks_count = created_tasks_count = updated_tasks_count = 0
@@ -38,6 +38,11 @@ class ProjectMilestone(models.Model):
for ms in self:
ms.is_deadline_future = ms.deadline and ms.deadline > fields.Date.context_today(self)
def toggle_is_reached(self, is_reached):
self.ensure_one()
self.update({'is_reached': is_reached})
return self._get_data()
@api.model
def _get_fields_to_export(self):
return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future']
@@ -38,6 +38,7 @@
<field name="partner_id" filter_domain="[('partner_id', 'child_of', self)]"/>
<field name="stage_id"/>
<filter string="Unassigned" name="unassigned" domain="[('user_id', '=', False)]"/>
<filter string="Open tasks" name="open_tasks" domain="[('stage_id.fold', '=', False), ('stage_id.is_closed', '=', False)]"/>
<separator/>
<filter name="filter_date_deadline" date="date_deadline"/>
<filter name="filter_date_assign" date="date_assign"/>
@@ -48,8 +49,8 @@
</group>
<group expand="1" string="Group By">
<filter string="Project" name="project" context="{'group_by': 'project_id'}"/>
<filter string="Assigned to" name="User" context="{'group_by': 'user_id'}"/>
<filter string="Stage" name="Stage" context="{'group_by': 'stage_id'}"/>
<filter string="Assigned to" name="User" context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
@@ -0,0 +1,69 @@
/** @odoo-module **/
import { AddMilestone, OpenMilestone } from '@project/js/right_panel/project_utils';
const { useState } = owl.hooks;
export default class ProjectRightPanel extends owl.Component {
constructor() {
super(...arguments);
this.context = this.props.action.context;
this.domain = this.props.action.domain;
this.project_id = this.context.active_id;
this.state = useState({
data: {
tasks_analysis: {
data: []
},
milestones: {
data: []
},
user: {},
}
});
}
async willStart() {
await super.willStart(...arguments);
await this._loadQwebContext();
}
async willUpdateProps() {
await super.willUpdateProps(...arguments);
await this._loadQwebContext();
}
async _loadQwebContext() {
const data = await this.rpc({
model: 'project.project',
method: 'get_panel_data',
args: [this.project_id],
});
this.state.data = data;
return data;
}
async onProjectActionClick(event) {
let action = event.currentTarget.dataset.action;
const additionalContext = JSON.parse(event.currentTarget.dataset.additional_context || "{}");
if (event.currentTarget.dataset.type === "object") {
action = await this.rpc({
model: 'project.project',
method: event.currentTarget.dataset.action,
args: [this.project_id]
});
}
this._doAction(action, {
additional_context: additionalContext
});
}
_doAction(action, options) {
this.trigger('do-action', {
action,
options
});
}
}
ProjectRightPanel.template = "project.ProjectRightPanel";
ProjectRightPanel.components = {AddMilestone, OpenMilestone};
@@ -0,0 +1,70 @@
/** @odoo-module **/
import { ComponentWrapper } from 'web.OwlCompatibility';
export const RightPanelRendererMixin = {
/**
* Add RightPanel css class to renderer in order to handle panel style.
*
* @override
*/
async start() {
this.$el.addClass('o_renderer_with_rightpanel');
await Promise.all([this._render(), this._super()]);
},
};
export const RightPanelControllerMixin = {
/**
* Init the rightSidePanel from the config parameters
*
* @override
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.rightSidePanel = params.rightSidePanel;
},
/**
* Add the Rightpanel component through the component wrapper.
*
* @override
*/
start: async function () {
const promises = [this._super(...arguments)];
this._rightPanelWrapper = new ComponentWrapper(this, this.rightSidePanel.Component, this.rightSidePanel.props);
const content = this.el.querySelector(':scope .o_content');
content.classList.add('o_controller_with_rightpanel');
promises.push(this._rightPanelWrapper.mount(content, { position: 'last-child' }));
await Promise.all(promises);
},
/**
* Make sure the right panel is updated each time the view is reloaded.
*
* @override
*/
async _update() {
this._rightPanelWrapper.update(this.rightSidePanel.props);
await this._super.apply(this, arguments);
},
};
export const RightPanelViewMixin = {
searchMenuTypes: ['filter', 'favorite'],
/**
* Adds the rightSidePanel in the view.
* This extention is assuming the rightSidePanel is always given in the case this Mixin is used.
*
* @override
*/
_createSearchModel: function (params) {
const result = this._super.apply(this, arguments);
const props = {
action: params.action,
};
this.controllerParams.rightSidePanel = {
Component: this.config.RightSidePanel,
props: props,
};
return result;
}
};
@@ -0,0 +1,144 @@
/** @odoo-module **/
import { _lt } from 'web.core';
import fieldUtils from 'web.field_utils';
import { ComponentAdapter } from 'web.OwlCompatibility';
import { FormViewDialog } from 'web.view_dialogs';
const { useState, useRef } = owl.hooks;
export class FormViewDialogComponentAdapter extends ComponentAdapter {
renderWidget() {
// Ensure the dialog is properly reconstructed. Without this line, it is
// impossible to open the dialog again after having it closed a first
// time, because the DOM of the dialog has disappeared.
return this.willStart();
}
}
const components = {
FormViewDialogComponentAdapter,
};
class MilestoneComponent extends owl.Component {
constructor() {
super(...arguments);
this.contextValue = Object.assign({}, {
'default_project_id': this.props.context.active_id,
}, this.props.context);
this.FormViewDialog = FormViewDialog;
this.state = useState({
openDialog: false
});
this._dialogRef = useRef('dialog');
this._isDialogOpen = false;
this._onDialogSaved = this._onDialogSaved.bind(this);
}
get context() {
return this.contextValue;
}
set context(value) {
this.contextValue = Object.assign({}, {
'default_project_id': value.active_id,
}, value);
}
dialogClosed() {
this._isDialogOpen = false;
this.state.openDialog = false;
}
patched() {
if (this.state.openDialog && !this._isDialogOpen) {
this._isDialogOpen = true;
this._dialogRef.comp.widget.on('closed', this, () => {
this.dialogClosed();
});
this._dialogRef.comp.widget.open();
}
}
async _onDialogSaved() {
await this.__owl__.parent.willUpdateProps();
}
}
MilestoneComponent.components = components;
export class AddMilestone extends MilestoneComponent {
get NEW_PROJECT_MILESTONE() {
return _lt("New Milestone");
}
onAddMilestoneClick() {
if (!this._isDialogOpen) {
this.state.openDialog = true;
}
}
}
AddMilestone.template = 'project.AddMilestone';
export class OpenMilestone extends MilestoneComponent {
constructor() {
super(...arguments);
this.milestone = useState(this.props.milestone);
this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "";
this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square" : "fa-square-o";
}
get OPEN_PROJECT_MILESTONE() {
return _lt("Milestone");
}
get deadline() {
return fieldUtils.format.date(moment(this.milestone.deadline));
}
willUpdateProps(nextProps) {
if (nextProps.milestone) {
this.milestone = nextProps.milestone;
this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "";
this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square" : "fa-square-o";
}
if (nextProps.context) {
this.contextValue = nextProps.context;
}
}
dialogClosed() {
super.dialogClosed();
this.write_mutex = false;
}
async onDeleteMilestone() {
await this.rpc({
model: 'project.milestone',
method: 'unlink',
args: [this.milestone.id]
});
await this.__owl__.parent.willUpdateProps();
}
onOpenMilestone() {
if (!this._isDialogOpen && !this.write_mutex) {
this.write_mutex = true;
this.state.openDialog = true;
}
}
async onMilestoneClick() {
if (!this.write_mutex) {
this.write_mutex = true;
this.milestone = await this.rpc({
model: 'project.milestone',
method: 'toggle_is_reached',
args: [[this.milestone.id], !this.milestone.is_reached],
});
this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "";
this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square" : "fa-square-o";
this.write_mutex = false;
}
}
}
OpenMilestone.template = 'project.OpenMilestone';
@@ -0,0 +1,26 @@
/** @odoo-module **/
import KanbanController from 'web.KanbanController';
import KanbanRenderer from 'web.KanbanRenderer';
import KanbanView from 'web.KanbanView';
import viewRegistry from 'web.view_registry';
import ProjectRightSidePanel from '@project/js/right_panel/project_right_panel';
import {
RightPanelControllerMixin,
RightPanelRendererMixin,
RightPanelViewMixin,
} from '@project/js/right_panel/project_right_panel_mixin';
const ProjectUpdateKanbanRenderer = KanbanRenderer.extend(RightPanelRendererMixin);
const ProjectUpdateKanbanController = KanbanController.extend(RightPanelControllerMixin);
export const ProjectUpdateKanbanView = KanbanView.extend(RightPanelViewMixin).extend({
config: Object.assign({}, KanbanView.prototype.config, {
Controller: ProjectUpdateKanbanController,
Renderer: ProjectUpdateKanbanRenderer,
RightSidePanel: ProjectRightSidePanel,
}),
});
viewRegistry.add('project_update_kanban', ProjectUpdateKanbanView);
@@ -0,0 +1,26 @@
/** @odoo-module **/
import ListController from 'web.ListController';
import ListRenderer from 'web.ListRenderer';
import ListView from 'web.ListView';
import viewRegistry from 'web.view_registry';
import ProjectRightSidePanel from '@project/js/right_panel/project_right_panel';
import {
RightPanelControllerMixin,
RightPanelRendererMixin,
RightPanelViewMixin,
} from '@project/js/right_panel/project_right_panel_mixin';
const ProjectUpdateListRenderer = ListRenderer.extend(RightPanelRendererMixin);
const ProjectUpdateListController = ListController.extend(RightPanelControllerMixin);
export const ProjectUpdateListView = ListView.extend(RightPanelViewMixin).extend({
config: Object.assign({}, ListView.prototype.config, {
Controller: ProjectUpdateListController,
Renderer: ProjectUpdateListRenderer,
RightSidePanel: ProjectRightSidePanel,
}),
});
viewRegistry.add('project_update_list', ProjectUpdateListView);
@@ -0,0 +1,119 @@
// ------- View with rightpanel -------
$o-rightpanel-p: $o-horizontal-padding;
$o-rightpanel-p-small: $o-horizontal-padding*0.5;
$o-rightpanel-p-tiny: $o-rightpanel-p-small*0.5;
$o-rightpanel-category-default-color: $o-brand-primary;
$o-rightpanel-filter-default-color: #D59244;
.o_controller_with_rightpanel {
overflow: hidden !important;
display: flex;
align-items: flex-start;
.o_renderer_with_rightpanel {
flex: 1 1 100%;
overflow: auto; // make the renderer and search panel scroll individually
max-height: 100%;
position: relative;
}
}
.o_rightpanel {
flex: 0 0 30%;
overflow: auto;
height: 100%;
padding: $o-rightpanel-p-small $o-rightpanel-p $o-rightpanel-p*2 $o-rightpanel-p;
border-left: 1px solid $gray-300;
background-color: white;
.o_rightpanel_section {
padding-bottom: $o-rightpanel-p;
padding-top: $o-rightpanel-p;
&:not(:first-child){
border-top: 1px solid $gray-300;
}
.o_rightpanel_header {
padding: $o-rightpanel-p-tiny 0 $o-rightpanel-p-small 0;
.o_rightpanel_title {
cursor: default;
padding: 8px 0;
font-weight: bold;
font-size: large;
display:inline;
}
.o_rightpanel_button {
cursor: pointer;
font-weight: bold;
font-size: small;
color: $o-brand-primary;
float: right;
padding-top: $o-rightpanel-p-tiny;
}
}
.o_rightpanel_data {
font-size: small;
.o_rightpanel_data_row {
&:hover {
background-color: $gray-200;
border-radius: 5px;
}
}
.o_rightpanel_left_col {
font-size: small;
padding-top: 0;
padding-bottom: 0;
&.o_rightpanel_left_button {
color: $o-brand-primary;
padding-left: 0;
}
&.o_rightpanel_left_text {
padding-right: $o-rightpanel-p-small;
}
}
.o_rightpanel_right_col {
float: right;
}
.o_open_milestone {
margin-bottom: $o-rightpanel-p-tiny;
display: flex;
&.o_milestone_danger {
color: red;
}
.o_milestone_checkbox {
cursor: pointer;
padding-right: $o-rightpanel-p-tiny;
}
.o_milestone_detail {
cursor: pointer;
display: flex;
justify-content: space-between;
flex-grow: 1;
}
.o_delete_icon {
cursor: pointer;
padding-left: $o-rightpanel-p-tiny;
}
}
}
.o_rightpanel_hidden {
display: none;
}
.o_color_red {
color: $red;
}
.o_color_green {
color: $green;
}
}
}
@@ -36,4 +36,101 @@
</t>
</t>
<t t-name="project.ProjectRightPanel" owl="1">
<div class="o_rightpanel">
<t t-set="tasksAnalysis" t-value="state.data.tasks_analysis"/>
<div class="o_rightpanel_section">
<div class="o_rightpanel_header">
<div class="o_rightpanel_title">
Tasks Analysis
</div>
</div>
<div class="o_rightpanel_data">
<div t-foreach="tasksAnalysis.data" t-as="analysis" t-key="analysis.name" class="o_rightpanel_data_row">
<a t-if="state.data.user.is_project_manager" class="btn btn-link o_rightpanel_left_col o_rightpanel_left_button" t-on-click="onProjectActionClick"
t-att-data-action="analysis.action.action"
t-att-data-additional_context="analysis.action.additional_context"
data-type="action">
<t t-esc="analysis.name"/>
</a>
<span t-else="" class="o_rightpanel_left_col o_rightpanel_left_text"><t t-esc="analysis.name"/></span>
<span class="o_rightpanel_right_col"><t t-esc="analysis.value"/></span>
</div>
</div>
</div>
<t t-set="milestones" t-value="state.data.milestones"/>
<div class="o_rightpanel_section">
<div class="o_rightpanel_header">
<div class="o_rightpanel_title">
Milestones
</div>
<span class="o_rightpanel_button">
<AddMilestone context="context"/>
</span>
</div>
<div class="o_rightpanel_data">
<div t-foreach="milestones.data" t-as="milestone" t-key="milestone.id" class="o_rightpanel_data_row">
<OpenMilestone context="context" milestone="milestone" t-key="milestone.id"/>
</div>
<span t-if="milestones.data.length === 0" class="text-muted font-italic">
Track major progress points that must be reached to achieve success.
</span>
</div>
</div>
</div>
</t>
<t t-name="project.AddMilestone" owl="1">
<div class="o_add_milestone">
<a t-on-click="onAddMilestoneClick">Add Milestone</a>
<span t-if="state.openDialog" class="o_rightpanel_hidden">
<FormViewDialogComponentAdapter
Component="FormViewDialog"
params="{
context: context,
on_saved: _onDialogSaved,
res_id: false,
res_model: 'project.milestone',
title: NEW_PROJECT_MILESTONE,
disable_multiple_selection: true,
}"
t-ref="dialog"
/>
</span>
</div>
</t>
<t t-name="project.OpenMilestone" owl="1">
<div class="o_open_milestone" t-att-class="state.colorClass">
<span class="o_milestone_checkbox" t-on-click="onMilestoneClick">
<i class="fa" t-att-class="state.checkboxIcon"></i>
</span>
<div class="o_milestone_detail" t-on-click="onOpenMilestone">
<t t-esc="milestone.name"/>
<span class="o_rightpanel_right_col">
<t t-esc="deadline"/>
</span>
</div>
<span class="o_rightpanel_right_col">
<a class="o_delete_icon" t-on-click="onDeleteMilestone" title="Delete Milestone"><i class="fa fa-trash"></i></a>
</span>
<span t-if="state.openDialog" class="o_rightpanel_hidden">
<FormViewDialogComponentAdapter
Component="FormViewDialog"
params="{
context: context,
on_saved: _onDialogSaved,
res_id: milestone.id,
res_model: 'project.milestone',
title: OPEN_PROJECT_MILESTONE,
disable_multiple_selection: true,
deletable: true
}"
t-ref="dialog"
/>
</span>
</div>
</t>
</templates>
+16 -1
View File
@@ -50,7 +50,7 @@
<field name="name">project.update.view.kanban</field>
<field name="model">project.update</field>
<field name="arch" type="xml">
<kanban class="o_pupdate_kanban" sample="1">
<kanban class="o_pupdate_kanban" sample="1" js_class="project_update_kanban">
<field name="color"/>
<templates>
<t t-name="kanban-box">
@@ -84,6 +84,21 @@
</field>
</record>
<record id="project_update_view_tree" model="ir.ui.view">
<field name="name">project.update.view.tree</field>
<field name="model">project.update</field>
<field name="arch" type="xml">
<tree sample="1" js_class="project_update_list">
<field name="name"/>
<field name="user_id" widget="many2one_avatar_user" optional="show"/>
<field name="progress_percentage" string="Progress" widget="percentage" optional="show"/>
<field name="date" optional="show"/>
<field name="color" invisible="1"/>
<field name="status" widget="status_with_color" options="{'color_field': 'color'}"/>
</tree>
</field>
</record>
<record id="project_update_all_action" model="ir.actions.act_window">
<field name="name">Project Updates</field>
<field name="type">ir.actions.act_window</field>