From e48be257e16bc1069c6916cbae78d560ee3c92c4 Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Wed, 14 Apr 2021 11:00:28 +0200 Subject: [PATCH] [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 --- addons/project/__manifest__.py | 3 + addons/project/models/project.py | 54 ++++++- addons/project/models/project_milestone.py | 5 + .../project/report/project_report_views.xml | 3 +- .../src/js/right_panel/project_right_panel.js | 69 +++++++++ .../right_panel/project_right_panel_mixin.js | 70 +++++++++ .../src/js/right_panel/project_utils.js | 144 ++++++++++++++++++ .../js/update/project_update_view_kanban.js | 26 ++++ .../src/js/update/project_update_view_list.js | 26 ++++ .../static/src/scss/project_rightpanel.scss | 119 +++++++++++++++ .../static/src/xml/project_templates.xml | 97 ++++++++++++ addons/project/views/project_update_views.xml | 17 ++- 12 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 addons/project/static/src/js/right_panel/project_right_panel.js create mode 100644 addons/project/static/src/js/right_panel/project_right_panel_mixin.js create mode 100644 addons/project/static/src/js/right_panel/project_utils.js create mode 100644 addons/project/static/src/js/update/project_update_view_kanban.js create mode 100644 addons/project/static/src/js/update/project_update_view_list.js create mode 100644 addons/project/static/src/scss/project_rightpanel.scss diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 7c13814b73b..82ab8755fa2 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -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': [ diff --git a/addons/project/models/project.py b/addons/project/models/project.py index c241e0232ac..9dc4dde76aa 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -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 diff --git a/addons/project/models/project_milestone.py b/addons/project/models/project_milestone.py index 4c97081ba38..c1da37003ef 100644 --- a/addons/project/models/project_milestone.py +++ b/addons/project/models/project_milestone.py @@ -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'] diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml index 3010a6af12a..36f688b540d 100644 --- a/addons/project/report/project_report_views.xml +++ b/addons/project/report/project_report_views.xml @@ -38,6 +38,7 @@ + @@ -48,8 +49,8 @@ - + diff --git a/addons/project/static/src/js/right_panel/project_right_panel.js b/addons/project/static/src/js/right_panel/project_right_panel.js new file mode 100644 index 00000000000..50f233e71f5 --- /dev/null +++ b/addons/project/static/src/js/right_panel/project_right_panel.js @@ -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}; diff --git a/addons/project/static/src/js/right_panel/project_right_panel_mixin.js b/addons/project/static/src/js/right_panel/project_right_panel_mixin.js new file mode 100644 index 00000000000..863fd64bdff --- /dev/null +++ b/addons/project/static/src/js/right_panel/project_right_panel_mixin.js @@ -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; + } +}; diff --git a/addons/project/static/src/js/right_panel/project_utils.js b/addons/project/static/src/js/right_panel/project_utils.js new file mode 100644 index 00000000000..aa7a2f7fc8c --- /dev/null +++ b/addons/project/static/src/js/right_panel/project_utils.js @@ -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'; diff --git a/addons/project/static/src/js/update/project_update_view_kanban.js b/addons/project/static/src/js/update/project_update_view_kanban.js new file mode 100644 index 00000000000..d208e2b7337 --- /dev/null +++ b/addons/project/static/src/js/update/project_update_view_kanban.js @@ -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); diff --git a/addons/project/static/src/js/update/project_update_view_list.js b/addons/project/static/src/js/update/project_update_view_list.js new file mode 100644 index 00000000000..a1972a2fbad --- /dev/null +++ b/addons/project/static/src/js/update/project_update_view_list.js @@ -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); diff --git a/addons/project/static/src/scss/project_rightpanel.scss b/addons/project/static/src/scss/project_rightpanel.scss new file mode 100644 index 00000000000..88f3e3f9836 --- /dev/null +++ b/addons/project/static/src/scss/project_rightpanel.scss @@ -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; + } + } + +} diff --git a/addons/project/static/src/xml/project_templates.xml b/addons/project/static/src/xml/project_templates.xml index 961ac9cc9f3..79eda9dbb02 100644 --- a/addons/project/static/src/xml/project_templates.xml +++ b/addons/project/static/src/xml/project_templates.xml @@ -36,4 +36,101 @@ + +
+ +
+
+
+ Tasks Analysis +
+
+ +
+
+ + + + + +
+
+
+ +
+
+
+ Milestones +
+ + + +
+
+
+ +
+ + Track major progress points that must be reached to achieve success. + +
+
+
+
+ + +
+ Add Milestone + + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + + +
+
+ diff --git a/addons/project/views/project_update_views.xml b/addons/project/views/project_update_views.xml index 4ec2a652774..1c5e1fc63c1 100644 --- a/addons/project/views/project_update_views.xml +++ b/addons/project/views/project_update_views.xml @@ -50,7 +50,7 @@ project.update.view.kanban project.update - + @@ -84,6 +84,21 @@ + + project.update.view.tree + project.update + + + + + + + + + + + + Project Updates ir.actions.act_window