From b8ef0cc4f22827f684cf3361ee9fd526d27cb72a Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 30 Aug 2022 07:51:59 +0000 Subject: [PATCH] [REF] project,*: convert kanban, list and form views in OWL Before this commit, all the custom code for the widgets, form, list and kanban views are always in OWL and have to be migrate to the new JS framework. This commit converts all the widgets, list, kanban and form views used in the project app in OWL. Some JS tours has been adapted according to the OWL views, the project right side panel has been reviewed since it was LegacyComponent (in old component in OWL) task-2944742 Part-of: odoo/odoo#98380 --- addons/project/__manifest__.py | 15 +- addons/project/models/project.py | 5 + .../project_control_panel.js | 0 .../project_control_panel.xml | 0 .../project_private_task_many2one_field.js | 9 + .../project_private_task_many2one_field.scss | 4 + .../project_private_task_many2one_field.xml | 21 + .../components/project_milestone.js | 90 +++++ .../components/project_milestone.xml | 23 ++ .../components/project_profitability.js | 31 ++ .../components/project_profitability.xml | 89 +++++ .../project_right_side_panel_section.js | 26 ++ .../project_right_side_panel_section.xml | 18 + .../project_right_side_panel.js | 165 ++++++++ .../project_right_side_panel.scss} | 40 +- .../project_right_side_panel.xml | 77 ++++ .../project_state_selection.js | 20 + .../project_state_selection.scss | 3 + ...oject_status_with_color_selection_field.js | 25 ++ ...ject_status_with_color_selection_field.xml | 13 + ...ect_stop_recurrence_confirmation_dialog.js | 14 + ...ct_stop_recurrence_confirmation_dialog.xml | 17 + ...task_name_with_subtask_count_char_field.js | 15 + ...ask_name_with_subtask_count_char_field.xml | 14 + .../subtask_list_renderer.js | 20 + .../subtask_one2many_field.js | 19 + addons/project/static/src/css/project.css | 42 -- addons/project/static/src/js/project_form.js | 2 +- .../static/src/js/project_graph_view.js | 2 +- .../project/static/src/js/project_kanban.js | 371 ------------------ addons/project/static/src/js/project_list.js | 145 ------- .../static/src/js/project_pivot_view.js | 2 +- .../src/js/right_panel/project_right_panel.js | 120 ------ .../right_panel/project_right_panel_mixin.js | 71 ---- .../src/js/right_panel/project_utils.js | 129 ------ addons/project/static/src/js/tours/project.js | 2 +- .../js/update/project_update_view_kanban.js | 26 -- .../src/js/widgets/project_state_select.js | 81 ---- .../src/project_sharing/project_sharing.js | 44 ++- .../static/src/scss/project_dashboard.scss | 2 +- .../project/static/src/scss/project_form.scss | 5 - .../static/src/scss/project_widgets.scss | 64 --- .../src/services/project_task_recurrence.js | 107 +++++ .../project/static/src/utils/project_utils.js | 13 + .../burndown_chart/burndown_chart_model.js | 0 .../burndown_chart_search_model.js | 0 .../burndown_chart/burndown_chart_view.js | 0 .../burndown_chart/burndown_chart_view.xml | 0 .../form_renderer_with_html_expander.js | 33 ++ .../form_view_with_html_expander.js | 12 + .../project_form/project_form_renderer.js | 9 + .../views/project_form/project_form_view.js | 12 + .../project_task_form_controller.js | 45 +++ .../project_task_form_view.js | 12 + .../project_task_form_view.scss | 27 ++ .../project_task_kanban_dynamic_group_list.js | 36 ++ .../project_task_kanban_model.js | 26 ++ .../project_task_kanban_record.js | 14 + .../project_task_kanban_renderer.js | 91 +++++ .../project_task_kanban_view.js | 16 + .../project_task_kanban_view.scss | 5 + .../project_task_list_controller.js | 58 +++ .../project_task_list_view.js | 14 + .../project_update_kanban.scss | 75 ++++ .../project_update_kanban_controller.js | 16 + .../project_update_kanban_controller.xml | 13 + .../project_update_kanban_view.js | 12 + .../project_update_list_controller.js | 16 + .../project_update_list_controller.xml | 13 + .../project_update_list_view.js | 12 + .../static/src/xml/project_templates.xml | 217 ---------- .../static/tests/project_form_tests.js | 60 ++- .../static/tests/tours/personal_stage_tour.js | 2 +- .../tests/tours/project_sharing_tour.js | 10 +- .../tests/tours/project_update_tour_tests.js | 44 +-- addons/project/views/project_update_views.xml | 23 +- addons/project/views/project_views.xml | 68 ++-- addons/project_hr_expense/models/project.py | 5 +- addons/project_purchase/models/project.py | 5 +- .../models/project_project.py | 6 +- addons/sale_project/__manifest__.py | 5 +- addons/sale_project/models/project.py | 2 +- .../components/project_milestone.xml} | 4 +- .../project_right_side_panel.js} | 22 +- .../project_right_side_panel.xml | 51 +++ .../src/right_panel/project_right_panel.xml | 63 --- addons/sale_timesheet/__manifest__.py | 1 + addons/sale_timesheet/models/project.py | 6 +- .../components/so_line_field/so_line_field.js | 21 + .../static/src/js/so_line_one2many.js | 2 +- .../static/tests/tours/sale_timesheet_tour.js | 169 +++----- .../views/hr_timesheet_views.xml | 4 +- .../static/tests/tours/main_flow.js | 15 +- 93 files changed, 1729 insertions(+), 1644 deletions(-) rename addons/project/static/src/{ => components}/project_control_panel/project_control_panel.js (100%) rename addons/project/static/src/{ => components}/project_control_panel/project_control_panel.xml (100%) create mode 100644 addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js create mode 100644 addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss create mode 100644 addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.xml create mode 100644 addons/project/static/src/components/project_right_side_panel/components/project_milestone.js create mode 100644 addons/project/static/src/components/project_right_side_panel/components/project_milestone.xml create mode 100644 addons/project/static/src/components/project_right_side_panel/components/project_profitability.js create mode 100644 addons/project/static/src/components/project_right_side_panel/components/project_profitability.xml create mode 100644 addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.js create mode 100644 addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.xml create mode 100644 addons/project/static/src/components/project_right_side_panel/project_right_side_panel.js rename addons/project/static/src/{scss/project_rightpanel.scss => components/project_right_side_panel/project_right_side_panel.scss} (88%) create mode 100644 addons/project/static/src/components/project_right_side_panel/project_right_side_panel.xml create mode 100644 addons/project/static/src/components/project_state_selection/project_state_selection.js create mode 100644 addons/project/static/src/components/project_state_selection/project_state_selection.scss create mode 100644 addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.js create mode 100644 addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.xml create mode 100644 addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js create mode 100644 addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml create mode 100644 addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.js create mode 100644 addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.xml create mode 100644 addons/project/static/src/components/subtask_one2many_field/subtask_list_renderer.js create mode 100644 addons/project/static/src/components/subtask_one2many_field/subtask_one2many_field.js delete mode 100644 addons/project/static/src/js/project_kanban.js delete mode 100644 addons/project/static/src/js/project_list.js delete mode 100644 addons/project/static/src/js/right_panel/project_right_panel.js delete mode 100644 addons/project/static/src/js/right_panel/project_right_panel_mixin.js delete mode 100644 addons/project/static/src/js/right_panel/project_utils.js delete mode 100644 addons/project/static/src/js/update/project_update_view_kanban.js delete mode 100644 addons/project/static/src/js/widgets/project_state_select.js create mode 100644 addons/project/static/src/services/project_task_recurrence.js create mode 100644 addons/project/static/src/utils/project_utils.js rename addons/project/static/src/{ => views}/burndown_chart/burndown_chart_model.js (100%) rename addons/project/static/src/{ => views}/burndown_chart/burndown_chart_search_model.js (100%) rename addons/project/static/src/{ => views}/burndown_chart/burndown_chart_view.js (100%) rename addons/project/static/src/{ => views}/burndown_chart/burndown_chart_view.xml (100%) create mode 100644 addons/project/static/src/views/form_with_html_expander/form_renderer_with_html_expander.js create mode 100644 addons/project/static/src/views/form_with_html_expander/form_view_with_html_expander.js create mode 100644 addons/project/static/src/views/project_form/project_form_renderer.js create mode 100644 addons/project/static/src/views/project_form/project_form_view.js create mode 100644 addons/project/static/src/views/project_task_form/project_task_form_controller.js create mode 100644 addons/project/static/src/views/project_task_form/project_task_form_view.js create mode 100644 addons/project/static/src/views/project_task_form/project_task_form_view.scss create mode 100644 addons/project/static/src/views/project_task_kanban/project_task_kanban_dynamic_group_list.js create mode 100644 addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js create mode 100644 addons/project/static/src/views/project_task_kanban/project_task_kanban_record.js create mode 100644 addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js create mode 100644 addons/project/static/src/views/project_task_kanban/project_task_kanban_view.js create mode 100644 addons/project/static/src/views/project_task_kanban/project_task_kanban_view.scss create mode 100644 addons/project/static/src/views/project_task_list/project_task_list_controller.js create mode 100644 addons/project/static/src/views/project_task_list/project_task_list_view.js create mode 100644 addons/project/static/src/views/project_update_kanban/project_update_kanban.scss create mode 100644 addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.js create mode 100644 addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.xml create mode 100644 addons/project/static/src/views/project_update_kanban/project_update_kanban_view.js create mode 100644 addons/project/static/src/views/project_update_list/project_update_list_controller.js create mode 100644 addons/project/static/src/views/project_update_list/project_update_list_controller.xml create mode 100644 addons/project/static/src/views/project_update_list/project_update_list_view.js rename addons/sale_project/static/src/{xml/project_templates.xml => components/project_right_side_panel/components/project_milestone.xml} (90%) rename addons/sale_project/static/src/{right_panel/project_right_panel.js => components/project_right_side_panel/project_right_side_panel.js} (61%) create mode 100644 addons/sale_project/static/src/components/project_right_side_panel/project_right_side_panel.xml delete mode 100644 addons/sale_project/static/src/right_panel/project_right_panel.xml create mode 100644 addons/sale_timesheet/static/src/components/so_line_field/so_line_field.js diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 9b53444816e..dd1fdad5989 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -59,16 +59,16 @@ '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/utils/**/*', + 'project/static/src/services/**/*', + 'project/static/src/components/**/*', + 'project/static/src/views/**/*', '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_form_html_field_expander_mixin.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_view.js', 'project/static/src/js/project_rating_graph_view.js', 'project/static/src/js/project_rating_pivot_view.js', @@ -76,12 +76,9 @@ 'project/static/src/js/subtasks_list_renderer.js', 'project/static/src/js/tours/project.js', 'project/static/src/js/project_calendar.js', - '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_backend_legacy_lazy": [ @@ -93,8 +90,8 @@ ], 'web.assets_qweb': [ 'project/static/src/xml/**/*', - 'project/static/src/burndown_chart/**/*.xml', - 'project/static/src/project_control_panel/**/*.xml', + 'project/static/src/components/**/*.xml', + 'project/static/src/views/**/*.xml', ], 'web.qunit_suite_tests': [ 'project/static/tests/project_test_utils.js', diff --git a/addons/project/models/project.py b/addons/project/models/project.py index a23f9e4ad76..15ea7710213 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -850,6 +850,11 @@ class Project(models.Model): panel_data['profitability_labels'] = self._get_profitability_labels() return panel_data + def get_milestones(self): + if self.user_has_groups('project.group_project_user'): + return self._get_milestones() + return {} + def _get_profitability_labels(self): return {} diff --git a/addons/project/static/src/project_control_panel/project_control_panel.js b/addons/project/static/src/components/project_control_panel/project_control_panel.js similarity index 100% rename from addons/project/static/src/project_control_panel/project_control_panel.js rename to addons/project/static/src/components/project_control_panel/project_control_panel.js diff --git a/addons/project/static/src/project_control_panel/project_control_panel.xml b/addons/project/static/src/components/project_control_panel/project_control_panel.xml similarity index 100% rename from addons/project/static/src/project_control_panel/project_control_panel.xml rename to addons/project/static/src/components/project_control_panel/project_control_panel.xml diff --git a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js new file mode 100644 index 00000000000..9dea8a35ca3 --- /dev/null +++ b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js @@ -0,0 +1,9 @@ +/** @odoo-module */ + +import { registry } from '@web/core/registry'; +import { Many2OneField } from '@web/views/fields/many2one/many2one_field'; + +export class ProjectPrivateTaskMany2OneField extends Many2OneField { } +ProjectPrivateTaskMany2OneField.template = 'project.ProjectPrivateTaskMany2OneField'; + +registry.category('fields').add('project_private_task', ProjectPrivateTaskMany2OneField); diff --git a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss new file mode 100644 index 00000000000..bf6ff0d1270 --- /dev/null +++ b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss @@ -0,0 +1,4 @@ +.project_private_task_many2one_field input::placeholder { + color: $red; + font-style: italic; +} diff --git a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.xml b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.xml new file mode 100644 index 00000000000..0c3dde402b9 --- /dev/null +++ b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.xml @@ -0,0 +1,21 @@ + + + + + props.value + + + Private + + + displayName + + + Private + + + + + + + diff --git a/addons/project/static/src/components/project_right_side_panel/components/project_milestone.js b/addons/project/static/src/components/project_right_side_panel/components/project_milestone.js new file mode 100644 index 00000000000..97b41e4c778 --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/components/project_milestone.js @@ -0,0 +1,90 @@ +/** @odoo-module */ + +import { formatDate } from "@web/core/l10n/dates"; +import { useService } from '@web/core/utils/hooks'; + +const { Component, useState, onWillUpdateProps } = owl; +const { DateTime } = luxon; + +export class ProjectMilestone extends Component { + setup() { + this.orm = useService('orm'); + this.milestone = useState(this.props.milestone); + this.state = useState({ + colorClass: this._getColorClass(), + checkboxIcon: this._getCheckBoxIcon(), + }); + onWillUpdateProps(this.onWillUpdateProps); + } + + get resModel() { + return 'project.milestone'; + } + + get deadline() { + if (!this.milestone.deadline) return; + return formatDate(DateTime.fromISO(this.milestone.deadline)); + } + + _getColorClass() { + return this.milestone.is_deadline_exceeded && !this.milestone.can_be_marked_as_done ? "o_milestone_danger" : this.milestone.can_be_marked_as_done ? "o_color_green" : ""; + } + + _getCheckBoxIcon() { + return this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o"; + } + + onWillUpdateProps(nextProps) { + if (nextProps.milestone) { + this.milestone = nextProps.milestone; + this.state.colorClass = this._getColorClass(); + this.state.checkboxIcon = this._getCheckBoxIcon(); + } + if (nextProps.context) { + this.contextValue = nextProps.context; + } + } + + async onDeleteMilestone() { + await this.orm.call('project.milestone', 'unlink', [this.milestone.id]); + await this.props.load(); + } + + async onOpenMilestone() { + if (!this.write_mutex) { + this.write_mutex = true; + this.props.open({ + resModel: this.resModel, + resId: this.milestone.id, + title: this.env._t("Milestone"), + }, { + onClose: async () => { + await this.props.load(); + this.write_mutex = false; + }, + }); + } + } + + async toggleIsReached() { + if (!this.write_mutex) { + this.write_mutex = true; + this.milestone = await this.orm.call( + this.resModel, + 'toggle_is_reached', + [[this.milestone.id], !this.milestone.is_reached], + ); + this.state.colorClass = this._getColorClass(); + this.state.checkboxIcon = this._getCheckBoxIcon(); + this.write_mutex = false; + } + } +} + +ProjectMilestone.props = { + context: Object, + milestone: Object, + open: Function, + load: Function, +}; +ProjectMilestone.template = 'project.ProjectMilestone'; diff --git a/addons/project/static/src/components/project_right_side_panel/components/project_milestone.xml b/addons/project/static/src/components/project_right_side_panel/components/project_milestone.xml new file mode 100644 index 00000000000..6e572adb2c2 --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/components/project_milestone.xml @@ -0,0 +1,23 @@ + + + + +
+ + + +
+
+ +
+ + + +
+ + + +
+
+ +
diff --git a/addons/project/static/src/components/project_right_side_panel/components/project_profitability.js b/addons/project/static/src/components/project_right_side_panel/components/project_profitability.js new file mode 100644 index 00000000000..d29a62cca78 --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/components/project_profitability.js @@ -0,0 +1,31 @@ +/** @odoo-module */ + +const { Component } = owl; + +export class ProjectProfitability extends Component { + get revenues() { + return this.props.data.revenues; + } + + get costs() { + return this.props.data.costs; + } + + get margin() { + const invoiced_billed = this.revenues.total.invoiced - this.costs.total.billed; + const to_invoice_to_bill = this.revenues.total.to_invoice - this.costs.total.to_bill; + return { + invoiced_billed, + to_invoice_to_bill, + total: invoiced_billed + to_invoice_to_bill, + }; + } +} + +ProjectProfitability.props = { + data: Object, + labels: Object, + formatMonetary: Function, + onClick: Function, +}; +ProjectProfitability.template = 'project.ProjectProfitability'; diff --git a/addons/project/static/src/components/project_right_side_panel/components/project_profitability.xml b/addons/project/static/src/components/project_right_side_panel/components/project_profitability.xml new file mode 100644 index 00000000000..a9ab5592146 --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/components/project_profitability.xml @@ -0,0 +1,89 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
RevenuesInvoicedTo InvoiceExpected
+ + + + +
Total
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
CostsBilledTo BillExpected
+ + + + +
Total
+
+
+ + + + + + + + + +
Margin
+
+
+
diff --git a/addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.js b/addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.js new file mode 100644 index 00000000000..d903a1faea8 --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.js @@ -0,0 +1,26 @@ +/** @odoo-module */ + +const { Component } = owl; + +export class ProjectRightSidePanelSection extends Component { } + +ProjectRightSidePanelSection.props = { + name: { type: String, optional: true }, + header: { type: Boolean, optional: true }, + show: Boolean, + showData: { type: Boolean, optional: true }, + slots: { + type: Object, + shape: { + default: Object, // Content is not optional + header: { type: Object, optional: true }, + title: { type: Object, optional: true }, + }, + }, +}; +ProjectRightSidePanelSection.defaultProps = { + header: true, + showData: true, +}; + +ProjectRightSidePanelSection.template = 'project.ProjectRightSidePanelSection'; diff --git a/addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.xml b/addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.xml new file mode 100644 index 00000000000..867ed43f248 --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/components/project_right_side_panel_section.xml @@ -0,0 +1,18 @@ + + + + +
+
+
+ +
+ +
+
+ +
+
+
+ +
diff --git a/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.js b/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.js new file mode 100644 index 00000000000..4737090703b --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.js @@ -0,0 +1,165 @@ +/** @odoo-module */ + +import { useService } from '@web/core/utils/hooks'; +import { formatFloat, formatMonetary } from '@web/views/fields/formatters'; +import { session } from '@web/session'; +import { ViewButton } from '@web/views/view_button/view_button'; +import { FormViewDialog } from '@web/views/view_dialogs/form_view_dialog'; + +import { ProjectRightSidePanelSection } from './components/project_right_side_panel_section'; +import { ProjectMilestone } from './components/project_milestone'; +import { ProjectProfitability } from './components/project_profitability'; + +const { Component, onWillStart, useState } = owl; + +export class ProjectRightSidePanel extends Component { + setup() { + this.orm = useService('orm'); + this.actionService = useService('action'); + this.dialog = useService('dialog'); + this.state = useState({ + data: { + milestones: { + data: [], + }, + profitability_items: { + costs: { data: [], total: { billed: 0.0, to_bill: 0.0 } }, + revenues: { data: [], total: { invoiced: 0.0, to_invoice: 0.0 } }, + }, + user: {}, + currency_id: false, + } + }); + + onWillStart(() => this.loadData()); + } + + get context() { + return this.props.context; + } + + get domain() { + return this.props.domain; + } + + get projectId() { + return this.context.active_id; + } + + get currencyId() { + return this.state.data.currency_id; + } + + get sectionNames() { + return { + 'milestones': this.env._t('Milestones'), + 'profitability': this.env._t('Profitability'), + }; + } + + get showProjectProfitability() { + return !!this.state.data.profitability_items + && ( + this.state.data.profitability_items.revenues.data.length > 0 + || this.state.data.profitability_items.costs.data.length > 0 + ); + } + + formatFloat(value) { + return formatFloat(value, { digits: [false, 1] }); + } + + formatMonetary(value, options = {}) { + const valueFormatted = formatMonetary(value, { + currencyId: this.currencyId, + ...options, + 'noSymbol': true, + }); + const currency = session.currencies[this.currencyId]; + if (!currency) { + return valueFormatted; + } + if (currency.position === "after") { + return `${valueFormatted}\u00A0${currency.symbol}`; + } else { + return `${currency.symbol}\u00A0${valueFormatted}`; + } + } + + async loadData() { + if (!this.projectId) { // If this is called from notif, multiples updates but no specific project + return {}; + } + const data = await this.orm.call( + 'project.project', + 'get_panel_data', + [[this.projectId]], + { context: this.context }, + ); + this.state.data = data; + return data; + } + + async loadMilestones() { + const milestones = await this.orm.call( + 'project.project', + 'get_milestones', + [[this.projectId]], + { context: this.context }, + ); + this.state.data.milestones = milestones; + return milestones; + } + + addMilestone() { + const context = { + ...this.context, + 'default_project_id': this.projectId, + }; + this.openFormViewDialog({ + context, + title: this.env._t('New Milestone'), + resModel: 'project.milestone', + onRecordSaved: async () => { + await this.loadMilestones(); + }, + }); + } + + async openFormViewDialog(params, options = {}) { + this.dialog.add(FormViewDialog, params, options); + } + + async onProjectActionClick(params) { + this.actionService.doActionButton({ + type: 'action', + resId: this.projectId, + context: this.context, + resModel: 'project.project', + ...params, + }); + } + + _getStatButtonClickParams(statButton) { + return { + type: statButton.action_type, + name: statButton.action, + context: statButton.additional_context || '{}', + }; + } + + _getStatButtonRecordParams() { + return { + resId: this.projectId, + context: JSON.stringify(this.context), + resModel: 'project.project', + }; + } +} + +ProjectRightSidePanel.components = { ProjectRightSidePanelSection, ProjectMilestone, ViewButton, ProjectProfitability }; +ProjectRightSidePanel.template = 'project.ProjectRightSidePanel'; +ProjectRightSidePanel.props = { + context: Object, + domain: Array, +}; diff --git a/addons/project/static/src/scss/project_rightpanel.scss b/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.scss similarity index 88% rename from addons/project/static/src/scss/project_rightpanel.scss rename to addons/project/static/src/components/project_right_side_panel/project_right_side_panel.scss index 03d54a179c0..e2aa6548b72 100644 --- a/addons/project/static/src/scss/project_rightpanel.scss +++ b/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.scss @@ -1,4 +1,3 @@ -// ------- 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; @@ -6,30 +5,37 @@ $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 { +.o_controller_with_rightpanel .o_content { + overflow: hidden; display: flex; - align-items: flex-start; + justify-content: space-between; - .o_renderer_with_rightpanel { - flex: 1 1 100%; - overflow: auto; // make the renderer and search panel scroll individually + .o_renderer { + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; // make the renderer and search panel scroll individually max-height: 100%; position: relative; + padding: 0; + + .o_kanban_record { + width: 100%; + margin: 0; + border-top: 0; + border-right: 0; + } } } -html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_with_rightpanel { - overflow: hidden; -} - .o_rightpanel { - flex: 0 0 25%; + flex: 0 0 37%; 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; - min-width: 380px; + min-width: 400px; + max-width: 1140px; .o_rightpanel_section { padding-bottom: $o-rightpanel-p; @@ -118,7 +124,7 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_ align-items: center; } - .o_open_milestone { + .o_rightpanel_milestone { margin-bottom: $o-rightpanel-p-small; display: flex; &.o_milestone_danger { @@ -185,13 +191,13 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_ } .oe_stat_button { - flex-basis: 33.333333%; + flex-basis: 25%; border: 1px solid $border-color; margin-top: -1px; margin-left: -1px; height: 57.5px; - &:nth-child(3n):not(:last-child){ + &:nth-child(4n):not(:last-child){ border-right-width: 0px; } @@ -202,9 +208,9 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_ @media screen and (max-width: 1600px) { .oe_stat_button { - flex-basis: 50%; + flex-basis: 33.33%; - &:nth-child(2n){ + &:nth-child(3n):not(:last-child) { border-right-width: 0px; } } diff --git a/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.xml b/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.xml new file mode 100644 index 00000000000..1bf86644bdc --- /dev/null +++ b/addons/project/static/src/components/project_right_side_panel/project_right_side_panel.xml @@ -0,0 +1,77 @@ + + + + +
+ +
+
+ + + +
+ + + + + + +
+
+
+
+
+
+
+ + + Profitability + + + + + + + + + + + Milestones + +
+ +
+ + Track major progress points that must be reached to achieve success. + +
+
+ +
+ + + diff --git a/addons/project/static/src/components/project_state_selection/project_state_selection.js b/addons/project/static/src/components/project_state_selection/project_state_selection.js new file mode 100644 index 00000000000..2fb5668e92b --- /dev/null +++ b/addons/project/static/src/components/project_state_selection/project_state_selection.js @@ -0,0 +1,20 @@ +/** @odoo-module */ + +import { registry } from '@web/core/registry'; +import { StateSelectionField } from '@web/views/fields/state_selection/state_selection_field'; + +import { STATUS_COLORS, STATUS_COLOR_PREFIX } from '../../utils/project_utils'; + +export class ProjectStateSelectionField extends StateSelectionField { + setup() { + super.setup(); + this.colorPrefix = STATUS_COLOR_PREFIX; + this.colors = STATUS_COLORS; + } + + get showLabel() { + return !this.props.hideLabel; + } +} + +registry.category('fields').add('project_state_selection', ProjectStateSelectionField); diff --git a/addons/project/static/src/components/project_state_selection/project_state_selection.scss b/addons/project/static/src/components/project_state_selection/project_state_selection.scss new file mode 100644 index 00000000000..e7de33570e2 --- /dev/null +++ b/addons/project/static/src/components/project_state_selection/project_state_selection.scss @@ -0,0 +1,3 @@ +.o_field_status_with_color button.disabled { + color: black; +} diff --git a/addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.js b/addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.js new file mode 100644 index 00000000000..7a28e722805 --- /dev/null +++ b/addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.js @@ -0,0 +1,25 @@ +/** @odoo-module */ + +import { SelectionField } from '@web/views/fields/selection/selection_field'; +import { registry } from '@web/core/registry'; + +import { STATUS_COLORS, STATUS_COLOR_PREFIX } from '../../utils/project_utils'; + +export class ProjectStatusWithColorSelectionField extends SelectionField { + setup() { + super.setup(); + this.colorPrefix = STATUS_COLOR_PREFIX; + this.colors = STATUS_COLORS; + } + + get currentValue() { + return this.props.value || this.options[0][0]; + } + + statusColor(value) { + return this.colors[value] ? this.colorPrefix + this.colors[value] : ""; + } +} +ProjectStatusWithColorSelectionField.template = 'project.ProjectStatusWithColorSelectionField'; + +registry.category('fields').add('status_with_color', ProjectStatusWithColorSelectionField); diff --git a/addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.xml b/addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.xml new file mode 100644 index 00000000000..e6c848648e8 --- /dev/null +++ b/addons/project/static/src/components/project_status_with_color_selection/project_status_with_color_selection_field.xml @@ -0,0 +1,13 @@ + + + + + +
+ + +
+
+
+ +
diff --git a/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js b/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js new file mode 100644 index 00000000000..af15fa8c851 --- /dev/null +++ b/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js @@ -0,0 +1,14 @@ +/** @odoo-module */ + +import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog'; + +export class ProjectStopRecurrenceConfirmationDialog extends ConfirmationDialog { + _continueRecurrence() { + if (this.props.continueRecurrence) { + this.props.continueRecurrence(); + } + this.props.close(); + } +} +ProjectStopRecurrenceConfirmationDialog.template = 'project.ProjectStopRecurrenceConfirmationDialog'; +ProjectStopRecurrenceConfirmationDialog.props.continueRecurrence = { type: Function, optional: true }; diff --git a/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml b/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml new file mode 100644 index 00000000000..b28565f2dd4 --- /dev/null +++ b/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.js b/addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.js new file mode 100644 index 00000000000..5c73a2961bb --- /dev/null +++ b/addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.js @@ -0,0 +1,15 @@ +/** @odoo-module */ + +import { registry } from '@web/core/registry'; +import { CharField } from '@web/views/fields/char/char_field'; +import { formatChar } from '@web/views/fields/formatters'; + +class ProjectTaskNameWithSubtaskCountCharField extends CharField { + get formattedSubtaskCount() { + return formatChar(this.props.record.data.allow_subtasks && this.props.record.data.child_text || ''); + } +} + +ProjectTaskNameWithSubtaskCountCharField.template = 'project.ProjectTaskNameWithSubtaskCountCharField'; + +registry.category('fields').add('name_with_subtask_count', ProjectTaskNameWithSubtaskCountCharField); diff --git a/addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.xml b/addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.xml new file mode 100644 index 00000000000..89ded871ce4 --- /dev/null +++ b/addons/project/static/src/components/project_task_name_with_subtask_count_char_field/project_task_name_with_subtask_count_char_field.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/addons/project/static/src/components/subtask_one2many_field/subtask_list_renderer.js b/addons/project/static/src/components/subtask_one2many_field/subtask_list_renderer.js new file mode 100644 index 00000000000..519a008693a --- /dev/null +++ b/addons/project/static/src/components/subtask_one2many_field/subtask_list_renderer.js @@ -0,0 +1,20 @@ +/** @odoo-module */ + +import { useService } from "@web/core/utils/hooks"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { ListRenderer } from '@web/views/list/list_renderer'; + +export class SubtaskListRenderer extends ListRenderer { + setup() { + super.setup(); + this.dialog = useService("dialog"); + } + + async onDeleteRecord(record) { + this.dialog.add(ConfirmationDialog, { + body: this.env._t("Are you sure you want to delete this record?"), + confirm: () => super.onDeleteRecord(record), + cancel: () => {}, + }); + } +} diff --git a/addons/project/static/src/components/subtask_one2many_field/subtask_one2many_field.js b/addons/project/static/src/components/subtask_one2many_field/subtask_one2many_field.js new file mode 100644 index 00000000000..5a60b32be68 --- /dev/null +++ b/addons/project/static/src/components/subtask_one2many_field/subtask_one2many_field.js @@ -0,0 +1,19 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { X2ManyField } from '@web/views/fields/x2many/x2many_field'; + +import { SubtaskListRenderer } from './subtask_list_renderer'; + +export class SubtaskOne2ManyField extends X2ManyField { + get Renderer() { + return this.viewMode === 'list' ? SubtaskListRenderer : super.Renderer; + } +} + +SubtaskOne2ManyField.components = { + ...X2ManyField, + ListRenderer: SubtaskListRenderer, +} + +registry.category("fields").add("subtasks_one2many", SubtaskOne2ManyField); diff --git a/addons/project/static/src/css/project.css b/addons/project/static/src/css/project.css index 42f5be82671..449318cb6e7 100644 --- a/addons/project/static/src/css/project.css +++ b/addons/project/static/src/css/project.css @@ -1,22 +1,3 @@ -.o_form_project_tasks.o_form_editable .oe_title { - max-width: initial; -} - -.o_form_project_tasks.o_form_editable .oe_title .o_task_name { - /* - * should be (coming from addons/web/static/src/legacy/scss/form_view.scss): - * max-width: map-get($container-max-widths, md) - (2 * $o-horizontal-padding); - */ - max-width: 688px; -} - -.o_form_project_tasks .note-editable { - border: 0; -} -.o_form_project_tasks .o_wysiwyg_resizer { - border: 0; -} - .o_kanban_project_tasks .oe_kanban_align.badge { background: inherit; color: inherit; @@ -27,26 +8,3 @@ margin-top:2px; margin-right: 6px; } - -.o_form_project_tasks .ribbon:not(.o_invisible_modifier) + div > h1 > div[name="kanban_state"] { - margin-right: 150px; - padding-left: 1rem; -} - -.o_form_project_recurrence_message *:last-child { - margin-bottom: 0; -} - -.o_project_portal_warning_dl { - width: 100%; - padding: 0; - margin: 0 -} -.o_project_portal_warning_dt { - width: 4%; - float: left; -} -.o_project_portal_warning_dd { - width: 96%; - float: left; -} diff --git a/addons/project/static/src/js/project_form.js b/addons/project/static/src/js/project_form.js index cb14939f109..692ffa768d4 100644 --- a/addons/project/static/src/js/project_form.js +++ b/addons/project/static/src/js/project_form.js @@ -188,6 +188,6 @@ export const ProjectFormView = FormDescriptionExpanderView.extend({ }), }); -viewRegistry.add('project_form', ProjectFormView); +viewRegistry.add('project_task_form', ProjectFormView); viewRegistry.add('form_description_expander', FormDescriptionExpanderView) diff --git a/addons/project/static/src/js/project_graph_view.js b/addons/project/static/src/js/project_graph_view.js index 7c3b7eda969..5cd8faa1ca8 100644 --- a/addons/project/static/src/js/project_graph_view.js +++ b/addons/project/static/src/js/project_graph_view.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel"; +import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel"; import { registry } from "@web/core/registry"; import { graphView } from "@web/views/graph/graph_view"; diff --git a/addons/project/static/src/js/project_kanban.js b/addons/project/static/src/js/project_kanban.js deleted file mode 100644 index ff8b14d23e7..00000000000 --- a/addons/project/static/src/js/project_kanban.js +++ /dev/null @@ -1,371 +0,0 @@ -/** @odoo-module **/ - -import KanbanController from 'web.KanbanController'; -import KanbanRenderer from 'web.KanbanRenderer'; -import KanbanView from 'web.KanbanView'; -import KanbanColumn from 'web.KanbanColumn'; -import KanbanRecord from 'web.KanbanRecord'; -import KanbanModel from 'web.KanbanModel'; -import viewRegistry from 'web.view_registry'; -import { ProjectControlPanel } from '@project/js/project_control_panel'; -import viewUtils from 'web.viewUtils'; -import { Domain } from '@web/core/domain'; -import view_dialogs from 'web.view_dialogs'; -import core from 'web.core'; - -const _t = core._t; - -// PROJECTS - -const ProjectProjectKanbanRecord = KanbanRecord.extend({ - /** - * @override - * @private - */ - _openRecord: function () { - const kanbanBoxesElement = this.el.querySelectorAll('.o_project_kanban_boxes a'); - if (this.selectionMode !== true && kanbanBoxesElement.length) { - kanbanBoxesElement[0].click(); - } else { - this._super.apply(this, arguments); - } - }, - /** - * @override - * @private - */ - _onManageTogglerClicked: function (event) { - this._super.apply(this, arguments); - const thisSettingToggle = this.el.querySelector('.o_kanban_manage_toggle_button'); - this.el.parentNode.querySelectorAll('.o_kanban_manage_toggle_button.show').forEach(el => { - if (el !== thisSettingToggle) { - el.classList.remove('show'); - } - }); - thisSettingToggle.classList.toggle('show'); - }, -}); - -const ProjectProjectKanbanRenderer = KanbanRenderer.extend({ - config: _.extend({}, KanbanRenderer.prototype.config, { - KanbanRecord: ProjectProjectKanbanRecord, - }), -}); - -const ProjectProjectKanbanView = KanbanView.extend({ - config: Object.assign({}, KanbanView.prototype.config, { - Renderer: ProjectProjectKanbanRenderer, - }) -}); - -viewRegistry.add('project_project_kanban', ProjectProjectKanbanView); - -// TASKS - -const ProjectTaskKanbanColumn = KanbanColumn.extend({ - /** - * @override - * @private - */ - _onDeleteColumn: function (event) { - if (this.groupedBy === 'stage_id') { - event.preventDefault(); - this.trigger_up('kanban_column_delete_wizard'); - } else { - this._super(...arguments); - } - }, - - /** - * Open alternative view when editing personal stages. - * - * @private - * @override - */ - _onEditColumn: function (event) { - if (this.groupedBy !== 'personal_stage_type_ids') { - this._super(...arguments); - return; - } - event.preventDefault(); - const context = Object.assign({}, this.getSession().user_context, { - form_view_ref: 'project.personal_task_type_edit', - }); - new view_dialogs.FormViewDialog(this, { - res_model: this.relation, - res_id: this.id, - context: context, - title: _t("Edit Personal Stage"), - on_saved: this.trigger_up.bind(this, 'reload'), - }).open(); - }, -}); - -const ProjectTaskKanbanRenderer = KanbanRenderer.extend({ - config: Object.assign({}, KanbanRenderer.prototype.config, { - KanbanColumn: ProjectTaskKanbanColumn, - }), - - init: function () { - this._super.apply(this, arguments); - this.isProjectManager = false; - }, - - willStart: function () { - const superPromise = this._super.apply(this, arguments); - - const isProjectManager = this.getSession().user_has_group('project.group_project_manager').then((hasGroup) => { - this.isProjectManager = hasGroup; - this._setState(); - return Promise.resolve(); - }); - - return Promise.all([superPromise, isProjectManager]); - }, - - /** - * Allows record drag when grouping by `personal_stage_type_ids` - * - * @override - */ - _setState() { - this._super(...arguments); - const groupedBy = this.state.groupedBy[0]; - const groupByFieldName = viewUtils.getGroupByField(groupedBy); - const field = this.state.fields[groupByFieldName] || {}; - const fieldInfo = this.state.fieldsInfo.kanban[groupByFieldName] || {}; - - const grouped_by_date = ["date", "datetime"].includes(field.type); - const grouped_by_m2m = field.type === "many2many"; - const readonly = !!field.readonly || !!fieldInfo.readonly; - const groupedByPersonalStage = (groupByFieldName === 'personal_stage_type_ids'); - - const draggable = !readonly && (!grouped_by_m2m || groupedByPersonalStage) && - (!grouped_by_date || fieldInfo.allowGroupRangeValue); - - // When grouping by personal stage we allow any project user to create - let editable = this.columnOptions.editable; - let deletable = this.columnOptions.deletable; - if (['stage_id', 'personal_stage_type_ids'].includes(groupByFieldName)) { - this.groupedByM2O = groupedByPersonalStage || this.groupedByM2O; - const allow_crud = this.isProjectManager || groupedByPersonalStage; - this.createColumnEnabled = editable = deletable = allow_crud; - } - - Object.assign(this.columnOptions, { - draggable, - grouped_by_m2o: this.groupedByM2O, - editable: editable, - deletable: deletable, - }); - } -}); - -export const ProjectKanbanController = KanbanController.extend({ - custom_events: Object.assign({}, KanbanController.prototype.custom_events, { - 'kanban_column_delete_wizard': '_onDeleteColumnWizard', - }), - - _onDeleteColumnWizard: function (ev) { - ev.stopPropagation(); - const self = this; - const columnId = ev.target.id; - const state = this.model.get(this.handle, {raw: true}); - this._rpc({ - model: 'project.task.type', - method: 'unlink_wizard', - args: [columnId], - context: state.getContext(), - }).then(function (res) { - self.do_action(res); - }); - }, - - /** - * @override - */ - _onDeleteColumn: function (ev) { - const state = this.model.get(this.handle, {raw: true}); - const groupedByFieldname = state.groupedBy[0]; - if (groupedByFieldname !== 'personal_stage_type_ids') { - this._super(...arguments); - return; - } - const column = ev.target; - this._rpc({ - model: 'project.task.type', - method: 'remove_personal_stage', - args: [[column.id]], - }).then(this.update.bind(this, {}, {})); - }, -}); - -const ProjectTaskKanbanModel = KanbanModel.extend({ - - /** - * Upon updating `personal_stage_type_ids` we actually want to update the `personal_stage_type_id` field. - * - * @override - * @private - */ - async moveRecord(recordID, groupID, parentID) { - const self = this; - const parent = this.localData[parentID]; - const new_group = this.localData[groupID]; - const changes = {}; - const groupedFieldName = viewUtils.getGroupByField(parent.groupedBy[0]); - const groupedField = parent.fields[groupedFieldName]; - // for a date/datetime field, we take the last moment of the group as the group value - if (['date', 'datetime'].includes(groupedField.type)) { - changes[groupedFieldName] = viewUtils.getGroupValue(new_group, parent.groupedBy[0]); - } else if (groupedField.type === 'many2one') { - changes[groupedFieldName] = { - id: new_group.res_id, - display_name: new_group.value, - }; - } else if (groupedField.type === 'selection') { - const value = _.findWhere(groupedField.selection, {1: new_group.value}); - changes[groupedFieldName] = value && value[0] || false; - } else if (groupedField.type == 'many2many' && groupedFieldName == 'personal_stage_type_ids') { - changes['personal_stage_type_id'] = { - id: new_group.res_id, - display_name: new_group.value, - } - } else { - changes[groupedFieldName] = new_group.value; - } - - // Manually updates groups data. Note: this is done before the actual - // save as it might need to perform a read group in some cases so those - // updated data might be overridden again. - const record = self.localData[recordID]; - const resID = record.res_id; - // Remove record from its current group - let old_group; - for (let i = 0; i < parent.data.length; i++) { - old_group = self.localData[parent.data[i]]; - const index = _.indexOf(old_group.data, recordID); - if (index >= 0) { - old_group.data.splice(index, 1); - old_group.count--; - if (!old_group.activeFilter || old_group.activeFilter.value === record.data[parent.progressBar.field]) { - // Here, the record leaving the old group matches its domain, - // so we must decrease the domainCount too. - old_group.domainCount--; - } - old_group.res_ids = _.without(old_group.res_ids, resID); - self._updateParentResIDs(old_group); - break; - } - } - // Add record to its new group - new_group.data.push(recordID); - new_group.res_ids.push(resID); - new_group.count++; - - await this.notifyChanges(recordID, changes); - await self.save(recordID); - if (groupedFieldName === 'stage_id') { - const action = await this._rpc({ - model: 'project.task', - method: 'get_milestone_to_mark_as_reached_action', - args: [[resID]], - }); - if (action) { - this.trigger_up('do-action', { - action, - }); - } - } - record.parentID = new_group.id; - return [old_group.id, new_group.id]; - }, - - /** - * When grouped by personal stage create a new personal stage instead of - * a regular stage. - * Meaning setting `user_id` on the stage. - * - * @override - */ - createGroup: function (name, parentID) { - const parent = this.localData[parentID]; - const groupedFieldName = viewUtils.getGroupByField(parent.groupedBy[0]); - if (groupedFieldName !== 'personal_stage_type_ids') { - return this._super(...arguments); - } - const groupBy = parent.groupedBy[0]; - const context = Object.assign({}, parent.context, { - default_user_id: this.getSession().uid, - }); - // In case it's a personal stage we don't want to assign it to the project. - delete context.default_project_id; - return this._rpc({ - model: 'project.task.type', - method: 'name_create', - args: [name], - context: context, - }) - .then((result) => { - const createGroupDataPoint = (model, parent) => { - const newGroup = model._makeDataPoint({ - modelName: parent.model, - context: parent.context, - domain: parent.domain.concat([[groupBy, "=", result[0]]]), - fields: parent.fields, - fieldsInfo: parent.fieldsInfo, - isOpen: true, - limit: parent.limit, - parentID: parent.id, - openGroupByDefault: true, - orderedBy: parent.orderedBy, - value: result, - viewType: parent.viewType, - }); - if (parent.progressBar) { - newGroup.progressBarValues = _.extend({ - counts: {}, - }, parent.progressBar); - } - return newGroup; - }; - const newGroup = createGroupDataPoint(this, parent); - parent.data.push(newGroup.id); - if (this.isInSampleMode()) { - // in sample mode, create the new group in both models (main + sample) - const sampleParent = this.sampleModel.localData[parentID]; - const newSampleGroup = createGroupDataPoint(this.sampleModel, sampleParent); - sampleParent.data.push(newSampleGroup.id); - } - return newGroup.id; - }); - }, - - /** - * Force tasks assigned to the user when grouping by personal stage. - * - * @override - * @private - */ - _readGroup: function (list) { - const groupedBy = list.groupedBy[0]; - if (groupedBy === 'personal_stage_type_ids') { - list.domain = Domain.and([ - [['user_ids', 'in', this.getSession().uid]], - JSON.stringify(list.domain), - ]).toList(); - } - return this._super(...arguments); - }, -}) - -const ProjectKanbanView = KanbanView.extend({ - config: _.extend({}, KanbanView.prototype.config, { - Model: ProjectTaskKanbanModel, - Controller: ProjectKanbanController, - Renderer: ProjectTaskKanbanRenderer, - ControlPanel: ProjectControlPanel, - }), -}); - -viewRegistry.add('project_task_kanban', ProjectKanbanView); diff --git a/addons/project/static/src/js/project_list.js b/addons/project/static/src/js/project_list.js deleted file mode 100644 index f169fca3629..00000000000 --- a/addons/project/static/src/js/project_list.js +++ /dev/null @@ -1,145 +0,0 @@ -/** @odoo-module **/ - -import { _t } from 'web.core'; -import Dialog from 'web.Dialog'; -import ListView from 'web.ListView'; -import ListController from 'web.ListController'; -import viewRegistry from 'web.view_registry'; -import { ProjectControlPanel } from '@project/js/project_control_panel'; - -const ProjectListController = ListController.extend({ - _getActionMenuItems(state) { - if (!this.archiveEnabled) { - return this._super(...arguments); - } - - const recurringRecords = this.getSelectedRecords().filter(rec => rec.data.recurrence_id).map(rec => rec.data.id); - this.archiveEnabled = recurringRecords.length == 0; - let actions = this._super(...arguments); - this.archiveEnabled = true; - - if (actions && recurringRecords.length > 0) { - actions.items.other.unshift({ - description: _t('Archive'), - callback: () => this._stopRecurrence(recurringRecords, this.selectedRecords, 'archive'), - }, { - description: _t('Unarchive'), - callback: () => this._toggleArchiveState(false) - }); - } - return actions; - }, - - _onDeleteSelectedRecords() { - const recurringRecords = this.getSelectedRecords().filter(rec => rec.data.recurrence_id).map(rec => rec.data.id); - if (recurringRecords.length > 0) { - return this._stopRecurrence(recurringRecords, this.selectedRecords, 'delete'); - } - - return this._super(...arguments); - }, - - _countRecordsPerReccurence(recurrenceIds, resIds) { - return this._rpc({ - model: 'project.task', - method: 'read_group', - args: [ - [['recurrence_id', 'in', recurrenceIds], ['id', 'not in', resIds]], - ['recurrence_id'], - ['recurrence_id'], - ], - }); - }, - - async _stopRecurrence(recurringResIds, resIds, mode) { - const recurrenceIdsSet = new Set(); - for (const record of this.getSelectedRecords()) { - const recurrenceId = record.data.recurrence_id; - if (recurrenceId) { - recurrenceIdsSet.add(recurrenceId); - } - } - const recurrenceIds = Array.from(recurrenceIdsSet); - // list recurrences that have tasks left after deleting/archiving - let countsLeft = await this._countRecordsPerReccurence(recurrenceIds, recurringResIds); - countsLeft = countsLeft.map(rec => rec.recurrence_id[0]); - // so we check that no recurrence is absent, as it would mean no task is left - const allowContinue = recurrenceIds.every(rec => countsLeft.includes(rec)); - - let warning; - if (resIds.length > 1) { - warning = allowContinue - ? _t('It seems that some tasks are part of a recurrence.') - : _t('It seems that some tasks are part of a recurrence. At least one of them must be kept as a model to create the next occurences.'); - } else { - warning = allowContinue - ? _t('It seems that this task is part of a recurrence.') - : _t('It seems that this task is part of a recurrence. You must keep it as a model to create the next occurences.'); - } - - const dialog = new Dialog(this, { - buttons: [ - { - classes: 'btn-primary', - click: () => { - this._rpc({ - model: 'project.task', - method: 'action_stop_recurrence', - args: [recurringResIds], - }).then(() => { - if (mode === 'archive') { - this._toggleArchiveState(true); - } else if (mode === 'delete') { - this._deleteRecords(resIds); - } - }); - }, - close: true, - text: _t('Stop Recurrence'), - }, - { - close: true, - text: _t('Discard'), - } - ], - size: 'medium', - title: _t('Confirmation'), - $content: $('
', { - role: 'alert', - text: warning, - }), - }); - - if (allowContinue) { - Dialog.buttons.splice(1, 0, - { - click: () => { - this._rpc({ - model: 'project.task', - method: 'action_continue_recurrence', - args: [recurringResIds], - }).then(() => { - if (mode === 'archive') { - this._toggleArchiveState(true); - } else if (mode === 'delete') { - this._deleteRecords(resIds); - } - }); - }, - close: true, - text: _t('Continue Recurrence'), - }); - } - - dialog.open(); - } -}); - -export const ProjectListView = ListView.extend({ - config: Object.assign({}, ListView.prototype.config, { - Controller: ProjectListController, - ControlPanel: ProjectControlPanel, - }), -}); - -viewRegistry.add('project_list', ProjectListView); diff --git a/addons/project/static/src/js/project_pivot_view.js b/addons/project/static/src/js/project_pivot_view.js index 5699d007ac8..e821b6fe0c2 100644 --- a/addons/project/static/src/js/project_pivot_view.js +++ b/addons/project/static/src/js/project_pivot_view.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { ProjectControlPanel } from "@project/project_control_panel/project_control_panel"; +import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel"; import { registry } from "@web/core/registry"; import { pivotView } from "@web/views/pivot/pivot_view"; 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 deleted file mode 100644 index ccb3ba5901c..00000000000 --- a/addons/project/static/src/js/right_panel/project_right_panel.js +++ /dev/null @@ -1,120 +0,0 @@ -/** @odoo-module **/ - -import { AddMilestone, OpenMilestone } from '@project/js/right_panel/project_utils'; -import { formatFloat, formatMonetary } from "@web/views/fields/formatters"; -import { LegacyComponent } from "@web/legacy/legacy_component"; -import { session } from "@web/session"; - -const { onWillStart, onWillUpdateProps, useState } = owl; - -export default class ProjectRightPanel extends LegacyComponent { - setup() { - this.context = this.props.action.context; - this.domain = this.props.action.domain; - this.project_id = this.context.active_id; - this.state = useState({ - data: { - milestones: { - data: [] - }, - profitability_items: { - costs: { data: [], total: { billed: 0.0, to_bill: 0.0 } }, - revenues: { data: [], total: { invoiced: 0.0, to_invoice: 0.0 } }, - }, - user: {}, - } - }); - - onWillStart(() => this._loadQwebContext()); - onWillUpdateProps(() => this._loadQwebContext()); - } - - formatFloat(value) { - return formatFloat(value, { digits: [false, 1] }); - } - - formatMonetary(value, options = {}) { - const valueFormatted = formatMonetary(value, { - currencyId: this.state.data.currency_id, - ...options, - 'noSymbol': true, - }); - const currency = session.currencies[this.state.data.currency_id]; - if (!currency) { - return valueFormatted; - } - if (currency.position === "after") { - return `${valueFormatted}\u00A0${currency.symbol}`; - } else { - return `${currency.symbol}\u00A0${valueFormatted}`; - } - } - - async _loadQwebContext() { - if (!this.project_id){ // If this is called from notif, multiples updates but no specific project - return {}; - } - const data = await this.rpc({ - model: 'project.project', - method: 'get_panel_data', - args: [this.project_id], - kwargs: { - context: this.context - } - }); - this.state.data = data; - return data; - } - - async onProjectActionClick(event) { - event.stopPropagation(); - const dataset = event.currentTarget.dataset; - let action = dataset.action; - const additionalContext = JSON.parse(dataset.additional_context || "{}"); - if (dataset.type === "object") { - const args = [this.project_id]; - if (dataset.section) { - args.push(dataset.section); - } - if (dataset.domain) { - args.push(JSON.parse(dataset.domain || '[]')); - } - if (dataset.resId) { - args.push(Number(dataset.resId)); - } - const context = dataset.context ? JSON.parse(dataset.context) : {}; - action = await this.rpc({ - // Use the call_button method in order to have an action - // with the correct view naming, i.e. list view is named - // 'list' rather than 'tree'. - route: '/web/dataset/call_button', - params: { - model: 'project.project', - method: action, - args, - kwargs: { - context: Object.assign(this.context, context), - } - } - }); - } - const options = { - additional_context: additionalContext, - }; - if (dataset.res_id) { - options.res_id = Number(dataset.res_id); - options.view_type = 'form'; - } - this._doAction(action, options); - } - - _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 deleted file mode 100644 index 20397baad8a..00000000000 --- a/addons/project/static/src/js/right_panel/project_right_panel_mixin.js +++ /dev/null @@ -1,71 +0,0 @@ -/** @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 = { - rightPanelPosition: 'last-child', - /** - * 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: this.rightPanelPosition })); - 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 deleted file mode 100644 index 2cea12fb658..00000000000 --- a/addons/project/static/src/js/right_panel/project_utils.js +++ /dev/null @@ -1,129 +0,0 @@ -/** @odoo-module **/ - -import fieldUtils from 'web.field_utils'; -import { ComponentAdapter, standaloneAdapter } from 'web.OwlCompatibility'; -import { FormViewDialog } from 'web.view_dialogs'; -import { useService } from "@web/core/utils/hooks"; - -const { Component, onWillUpdateProps, useState } = owl; - -export class ProjectRightSidePanelComponent extends Component { - setup() { - super.setup(); - this.contextValue = Object.assign({}, { - 'default_project_id': this.props.context.active_id, - }, this.props.context); - } - - async openLegacyFormDialog(params) { - const adapterParent = standaloneAdapter({ Component }); - const dialog = new FormViewDialog(adapterParent, { - context: this.contextValue, - res_id: false, - ...params, - }); - await dialog.open(); - return dialog; - } -} -ProjectRightSidePanelComponent.components = { ComponentAdapter }; - -class MilestoneComponent extends ProjectRightSidePanelComponent { - async openLegacyFormDialog(params) { - return super.openLegacyFormDialog({ - res_model: "project.milestone", - on_saved: this.props.onMilestoneUpdate, - ...params, - }); - } -} - -export class AddMilestone extends MilestoneComponent { - onAddMilestoneClick(event) { - event.stopPropagation(); - this.openLegacyFormDialog({ - _createContext: () => { - return this.contextValue; - }, - title: this.env._t("New Milestone"), - multi_select: true - }); - } -} -AddMilestone.template = 'project.AddMilestone'; - -export class OpenMilestone extends MilestoneComponent { - - setup() { - super.setup(); - this.rpc = useService("rpc"); - this.milestone = useState(this.props.milestone); - this.state = useState({ - colorClass: this._getColorClass(), - checkboxIcon: this._getCheckBoxIcon(), - }); - onWillUpdateProps(this.onWillUpdateProps); - } - - get deadline() { - return fieldUtils.format.date(moment(this.milestone.deadline)); - } - - _getColorClass() { - return this.milestone.is_deadline_exceeded && !this.milestone.can_be_marked_as_done ? "o_milestone_danger" : this.milestone.can_be_marked_as_done ? "o_color_green" : ""; - } - - _getCheckBoxIcon() { - return this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o"; - } - - onWillUpdateProps(nextProps) { - if (nextProps.milestone) { - this.milestone = nextProps.milestone; - this.state.colorClass = this._getColorClass(); - this.state.checkboxIcon = this._getCheckBoxIcon(); - } - if (nextProps.context) { - this.contextValue = nextProps.context; - } - } - - async onDeleteMilestone() { - await this.rpc({ - model: 'project.milestone', - method: 'unlink', - args: [this.milestone.id] - }); - await this.props.onMilestoneUpdate(); - } - - async onOpenMilestone() { - if (!this.write_mutex) { - this.write_mutex = true; - const dialog = await this.openLegacyFormDialog({ - res_id: this.milestone.id, - title: this.env._t("Milestone"), - disable_multiple_selection: true, - deletable: true - }); - dialog.on('closed', this, () => { - this.write_mutex = false; - }); - } - } - - 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._getColorClass(); - this.state.checkboxIcon = this._getCheckBoxIcon(); - this.write_mutex = false; - } - } -} -OpenMilestone.template = 'project.OpenMilestone'; diff --git a/addons/project/static/src/js/tours/project.js b/addons/project/static/src/js/tours/project.js index 93fcde75094..f48d87025c8 100644 --- a/addons/project/static/src/js/tours/project.js +++ b/addons/project/static/src/js/tours/project.js @@ -65,7 +65,7 @@ tour.register('project_tour', { position: 'bottom', width: 200, }, { - trigger: '.o_kanban_quick_create input.o_field_char[name=name]', + trigger: '.o_kanban_quick_create div.o_field_char[name=name] input', extra_trigger: '.o_kanban_project_tasks', content: Markup(_t('Choose a task name (e.g. Website Design, Purchase Goods...)')), position: 'right', 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 deleted file mode 100644 index d208e2b7337..00000000000 --- a/addons/project/static/src/js/update/project_update_view_kanban.js +++ /dev/null @@ -1,26 +0,0 @@ -/** @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/widgets/project_state_select.js b/addons/project/static/src/js/widgets/project_state_select.js deleted file mode 100644 index 4e0a03bbd87..00000000000 --- a/addons/project/static/src/js/widgets/project_state_select.js +++ /dev/null @@ -1,81 +0,0 @@ -/** @odoo-module **/ - -import {qweb} from 'web.core'; -import fieldRegistry from 'web.field_registry'; -import {StateSelectionWidget} from 'web.basic_fields'; - - -/** - * List of colors according to the selection value, see `project_update.py` - */ -const STATUS_COLORS = { - 'on_track': 10, - 'at_risk': 2, - 'off_track': 1, - 'on_hold': 4, -}; - -export const ProjectStateSelectionWidget = StateSelectionWidget.extend({ - - /** - * @override - */ - init() { - this._super.apply(this, arguments); - // The dropdown never disappears without `edit` mode on. - this.mode = 'edit'; - }, - - /** - * @override - * @private - */ - _prepareDropdownValues() { - const self = this; - const _data = []; - const current_state_id = self.recordData.last_update_status; - _.map(this.field.selection || [], function(selection_item) { - // Exclude `to_define` from the dropdown list - if (selection_item[0] === 'to_define' && current_state_id !== 'to_define') { - return; - } - const value = { - 'name': selection_item[0], - 'tooltip': selection_item[1], - }; - value.state_class = 'o_status_bubble mx-0 o_color_bubble_' + STATUS_COLORS[selection_item[0]]; - value.state_name = selection_item[1]; - _data.push(value); - }); - return _data; - }, - - /** - * @override - * @private - */ - _render() { - // Complete override since most of `StateSelectionWidget` is hardcoded. - // See `_render` from `StateSelectionWidget` - const states = this._prepareDropdownValues(); - const currentState = _.findWhere(states, {name: this.value}) || states[0]; - this.$('.o_status') - .removeClass('o_status_bubble mx-0 o_color_bubble_10 o_color_bubble_2 o_color_bubble_1 o_color_bubble_4') - .addClass(currentState.state_class) - .prop('special_click', true) - .parent().attr('title', currentState.state_name) - .attr('aria-label', this.string + ": " + currentState.state_name); - - const $items = $(qweb.render('FormSelection.items', { - states: _.without(states, currentState) - })); - const $dropdown = this.$('.dropdown-menu'); - $dropdown.children().remove(); - $items.appendTo($dropdown); - - const isReadonly = this.record.evalModifiers(this.attrs.modifiers).readonly; - this.$('a[data-bs-toggle=dropdown]').toggleClass('disabled', isReadonly || false); - } -}); - -fieldRegistry.add('project_state_selection', ProjectStateSelectionWidget); diff --git a/addons/project/static/src/project_sharing/project_sharing.js b/addons/project/static/src/project_sharing/project_sharing.js index bce34f00c6f..0983f1388fb 100644 --- a/addons/project/static/src/project_sharing/project_sharing.js +++ b/addons/project/static/src/project_sharing/project_sharing.js @@ -4,22 +4,23 @@ import { useBus, useService } from '@web/core/utils/hooks'; import { ActionContainer } from '@web/webclient/actions/action_container'; import { MainComponentsContainer } from "@web/core/main_components_container"; import { useOwnDebugContext } from "@web/core/debug/debug_context"; -import { ErrorHandler } from "@web/core/utils/components"; import { session } from '@web/session'; -import { LegacyComponent } from "@web/legacy/legacy_component"; -const { useEffect } = owl; +const { Component, useEffect, useExternalListener, useState } = owl; -export class ProjectSharingWebClient extends LegacyComponent { +export class ProjectSharingWebClient extends Component { setup() { window.parent.document.body.style.margin = "0"; // remove the margin in the parent body this.actionService = useService('action'); this.user = useService("user"); useService("legacy_service_provider"); useOwnDebugContext({ categories: ["default"] }); + this.state = useState({ + fullscreen: false, + }); useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", (mode) => { if (mode !== "new") { - this.el.classList.toggle("o_fullscreen", mode === "fullscreen"); + this.state.fullscreen = mode === "fullscreen"; } }); useEffect( @@ -28,19 +29,7 @@ export class ProjectSharingWebClient extends LegacyComponent { }, () => [] ); - } - - handleComponentError(error, C) { - // remove the faulty component - this.Components.splice(this.Components.indexOf(C), 1); - /** - * we rethrow the error to notify the user something bad happened. - * We do it after a tick to make sure owl can properly finish its - * rendering - */ - Promise.resolve().then(() => { - throw error; - }); + useExternalListener(window, "click", this.onGlobalClick, { capture: true }); } async _showView() { @@ -58,7 +47,24 @@ export class ProjectSharingWebClient extends LegacyComponent { await this.actionService.doAction(open_task_action); } } + + /** + * @param {MouseEvent} ev + */ + onGlobalClick(ev) { + // When a ctrl-click occurs inside an element + // we let the browser do the default behavior and + // we do not want any other listener to execute. + if ( + ev.ctrlKey && + ((ev.target instanceof HTMLAnchorElement && ev.target.href) || + (ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])"))) + ) { + ev.stopImmediatePropagation(); + return; + } + } } -ProjectSharingWebClient.components = { ActionContainer, ErrorHandler, MainComponentsContainer }; +ProjectSharingWebClient.components = { ActionContainer, MainComponentsContainer }; ProjectSharingWebClient.template = 'project.ProjectSharingWebClient'; diff --git a/addons/project/static/src/scss/project_dashboard.scss b/addons/project/static/src/scss/project_dashboard.scss index 335f9165f44..27dc321dad2 100644 --- a/addons/project/static/src/scss/project_dashboard.scss +++ b/addons/project/static/src/scss/project_dashboard.scss @@ -1,4 +1,4 @@ -.o_legacy_kanban_view.o_kanban_dashboard.o_project_kanban { +.o_kanban_renderer.o_kanban_dashboard.o_project_kanban { .o_project_kanban_main { max-width: 300px; diff --git a/addons/project/static/src/scss/project_form.scss b/addons/project/static/src/scss/project_form.scss index 7b1f5507fa2..1534ad9c2f8 100644 --- a/addons/project/static/src/scss/project_form.scss +++ b/addons/project/static/src/scss/project_form.scss @@ -30,11 +30,6 @@ } } -.o_form_project_tasks .o_project_task_project_field input::placeholder { - color: darkred; - font-style: italic; -} - .oe_title .o_favorite i.fa { font-size: inherit; } diff --git a/addons/project/static/src/scss/project_widgets.scss b/addons/project/static/src/scss/project_widgets.scss index 27b86bc792b..bf48a53122c 100644 --- a/addons/project/static/src/scss/project_widgets.scss +++ b/addons/project/static/src/scss/project_widgets.scss @@ -37,70 +37,6 @@ cursor: pointer; } -.o_legacy_kanban_view.o_pupdate_kanban { - overflow: auto; - &.o_kanban_ungrouped { - padding:0px; - .o_pupdate_kanban_card { - width: 100%; - margin: 0px; - border-right: 0px; - border-top: 0px; - overflow: hidden; - .o_kanban_detail_ungrouped { - padding-left: $o-horizontal-padding - $o-kanban-color-border-width - $o-kanban-record-margin; - > div { - display: grid; - align-items: center; - } - .o_pupdate_kanban_image { - width: 56px; - height: 56px; - top: 0px; - position: absolute; - } - .o_pupdate_name { - overflow-wrap: break-word; - } - .o_pupdate_kanban_actions_ungrouped { - button { - float: right; - margin: 4px; - } - } - .o_country_flag { - margin-right: 8px; - } - } - } - } - - .oe_kanban_color_20 { - border-left-color: $o-success; - &:after { - background-color: $o-success; - } - } - .oe_kanban_color_21 { - border-left-color: $o-info; - &:after { - background-color: $o-info; - } - } - .oe_kanban_color_22 { - border-left-color: $o-warning; - &:after { - background-color: $o-warning; - } - } - .oe_kanban_color_23 { - border-left-color: $o-danger; - &:after { - background-color: $o-danger; - } - } -} - .o_kanban_project_tasks .o_field_many2manytags, .o_kanban_tags{ margin: 0px; } diff --git a/addons/project/static/src/services/project_task_recurrence.js b/addons/project/static/src/services/project_task_recurrence.js new file mode 100644 index 00000000000..3d357afd7a4 --- /dev/null +++ b/addons/project/static/src/services/project_task_recurrence.js @@ -0,0 +1,107 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; + +import { ProjectStopRecurrenceConfirmationDialog } from '../components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog'; + +class ProjectTaskRecurrence { + constructor(env, dialog, orm) { + this.env = env; + this.dialog = dialog; + this.orm = orm; + this.resModel = 'project.task'; + } + + async stopRecurrence(tasks, callback = () => {}) { + const taskIdsWithRecurrence = []; + const tasksPerRecurrence = {}; + const recurrenceIds = []; + for (const task of tasks) { + if (task.data.recurrence_id) { + taskIdsWithRecurrence.push(task.resId); + const recurrenceId = task.data.recurrence_id && task.data.recurrence_id[0]; + if (!(recurrenceId in tasksPerRecurrence)) { + tasksPerRecurrence[recurrenceId] = [task.resId]; + recurrenceIds.push(recurrenceId); + } else { + tasksPerRecurrence[recurrenceId].push(task.resId); + } + } + } + + if (!recurrenceIds.length) { + callback(); + return; + } + + let allowContinue = false; + if (recurrenceIds.length === 1) { + const count = await this.orm.searchCount( + this.resModel, + [['recurrence_id', '=', recurrenceIds[0]]], + ); + allowContinue = count != 1; + } else { + const taskReadGroup = await this.orm.readGroup( + this.resModel, + [['recurrence_id', 'in', recurrenceIds]], + ['recurrence_id'], + ['recurrence_id'], + ); + allowContinue = true; + for (const res of taskReadGroup) { + const taskCount = tasksPerRecurrence[res.recurrence_id[0]].length; + if (taskCount === res.recurrence_id_count) { + allowContinue = false; + break; + } + } + } + let dialogBody; + if (tasks.length > 1) { + dialogBody = allowContinue + ? this.env._t('It seems that some tasks are part of a recurrence.') + : this.env._t('It seems that some tasks are part of a recurrence. At least one of them must be kept as a model to create the next occurences.'); + } else { + dialogBody = allowContinue + ? this.env._t('It seems that this task is part of a recurrence.') + : this.env._t('It seems that this task is recurrent. Would you like to stop its recurrence?'); + } + + const dialogProps = { + body: dialogBody, + confirm: async () => { + await this.orm.call( + this.resModel, + 'action_stop_recurrence', + [taskIdsWithRecurrence], + ); + callback(); + }, + cancel: () => {}, + }; + if (allowContinue) { + dialogProps.continueRecurrence = async () => { + await this.orm.call( + this.resModel, + 'action_continue_recurrence', + [taskIdsWithRecurrence], + ); + callback(); + }; + } + this.dialog.add(ProjectStopRecurrenceConfirmationDialog, dialogProps); + } +} + +export const taskRecurrenceService = { + dependencies: ['dialog', 'orm'], + async: [ + 'stopRecurrence', + ], + start(env, { dialog, orm }) { + return new ProjectTaskRecurrence(env, dialog, orm); + } +}; + +registry.category('services').add('project_task_recurrence', taskRecurrenceService); diff --git a/addons/project/static/src/utils/project_utils.js b/addons/project/static/src/utils/project_utils.js new file mode 100644 index 00000000000..65d91e4a72e --- /dev/null +++ b/addons/project/static/src/utils/project_utils.js @@ -0,0 +1,13 @@ +/** @odoo-module */ + +/** + * List of colors according to the selection value, see `project_update.py` + */ +export const STATUS_COLORS = { + 'on_track': 10, + 'at_risk': 2, + 'off_track': 1, + 'on_hold': 4, +}; + +export const STATUS_COLOR_PREFIX = 'o_status_bubble mx-0 o_color_bubble_'; diff --git a/addons/project/static/src/burndown_chart/burndown_chart_model.js b/addons/project/static/src/views/burndown_chart/burndown_chart_model.js similarity index 100% rename from addons/project/static/src/burndown_chart/burndown_chart_model.js rename to addons/project/static/src/views/burndown_chart/burndown_chart_model.js diff --git a/addons/project/static/src/burndown_chart/burndown_chart_search_model.js b/addons/project/static/src/views/burndown_chart/burndown_chart_search_model.js similarity index 100% rename from addons/project/static/src/burndown_chart/burndown_chart_search_model.js rename to addons/project/static/src/views/burndown_chart/burndown_chart_search_model.js diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.js b/addons/project/static/src/views/burndown_chart/burndown_chart_view.js similarity index 100% rename from addons/project/static/src/burndown_chart/burndown_chart_view.js rename to addons/project/static/src/views/burndown_chart/burndown_chart_view.js diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.xml b/addons/project/static/src/views/burndown_chart/burndown_chart_view.xml similarity index 100% rename from addons/project/static/src/burndown_chart/burndown_chart_view.xml rename to addons/project/static/src/views/burndown_chart/burndown_chart_view.xml diff --git a/addons/project/static/src/views/form_with_html_expander/form_renderer_with_html_expander.js b/addons/project/static/src/views/form_with_html_expander/form_renderer_with_html_expander.js new file mode 100644 index 00000000000..72d46658e89 --- /dev/null +++ b/addons/project/static/src/views/form_with_html_expander/form_renderer_with_html_expander.js @@ -0,0 +1,33 @@ +/** @odoo-module */ + +import { useService } from '@web/core/utils/hooks'; +import { FormRenderer } from '@web/views/form/form_renderer'; + +const { useRef, useEffect } = owl; + +export class FormRendererWithHtmlExpander extends FormRenderer { + setup() { + super.setup(); + this.ui = useService('ui'); + const ref = useRef('compiled_view_root'); + useEffect( + (el, size) => { + if (el && size === 6) { + const descriptionField = el.querySelector(this.htmlFieldQuerySelector); + if (descriptionField) { + const editor = descriptionField.querySelector('.note-editable'); + const elementToResize = editor || descriptionField; + const { bottom, height } = elementToResize.getBoundingClientRect(); + const minHeight = document.documentElement.clientHeight - bottom - height; + elementToResize.style.minHeight = `${minHeight}px`; + } + } + }, + () => [ref.el, this.ui.size, this.props.record.mode], + ); + } + + get htmlFieldQuerySelector() { + return '.oe_form_field.oe_form_field_html'; + } +} diff --git a/addons/project/static/src/views/form_with_html_expander/form_view_with_html_expander.js b/addons/project/static/src/views/form_with_html_expander/form_view_with_html_expander.js new file mode 100644 index 00000000000..58c47d1ce4a --- /dev/null +++ b/addons/project/static/src/views/form_with_html_expander/form_view_with_html_expander.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { formView } from '@web/views/form/form_view'; +import { FormRendererWithHtmlExpander } from './form_renderer_with_html_expander'; + +export const formViewWithHtmlExpander = { + ...formView, + Renderer: FormRendererWithHtmlExpander, +}; + +registry.category('views').add('form_description_expander', formViewWithHtmlExpander); diff --git a/addons/project/static/src/views/project_form/project_form_renderer.js b/addons/project/static/src/views/project_form/project_form_renderer.js new file mode 100644 index 00000000000..b5ace5dab08 --- /dev/null +++ b/addons/project/static/src/views/project_form/project_form_renderer.js @@ -0,0 +1,9 @@ +/** @odoo-module */ + +import { FormRendererWithHtmlExpander } from "../form_with_html_expander/form_renderer_with_html_expander"; + +export class ProjectFormRenderer extends FormRendererWithHtmlExpander { + get htmlFieldQuerySelector() { + return '.o_field_html[name="description"]'; + } +} diff --git a/addons/project/static/src/views/project_form/project_form_view.js b/addons/project/static/src/views/project_form/project_form_view.js new file mode 100644 index 00000000000..0b1527ec316 --- /dev/null +++ b/addons/project/static/src/views/project_form/project_form_view.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { formViewWithHtmlExpander } from '../form_with_html_expander/form_view_with_html_expander'; +import { ProjectFormRenderer } from "./project_form_renderer"; + +export const projectFormView = { + ...formViewWithHtmlExpander, + Renderer: ProjectFormRenderer, +}; + +registry.category("views").add("project_form", projectFormView); diff --git a/addons/project/static/src/views/project_task_form/project_task_form_controller.js b/addons/project/static/src/views/project_task_form/project_task_form_controller.js new file mode 100644 index 00000000000..9f929de9345 --- /dev/null +++ b/addons/project/static/src/views/project_task_form/project_task_form_controller.js @@ -0,0 +1,45 @@ +/** @odoo-module */ + +import { useService } from '@web/core/utils/hooks'; +import { FormController } from '@web/views/form/form_controller'; + +export class ProjectTaskFormController extends FormController { + setup() { + super.setup(); + this.taskRecurrence = useService('project_task_recurrence'); + } + + getActionMenuItems() { + if (!(this.archiveEnabled && this.model.root.isActive) || !this.model.root.data.recurrence_id) { + return super.getActionMenuItems(); + } + this.archiveEnabled = false; + const actionMenuItems = super.getActionMenuItems(); + this.archiveEnabled = true; + if (actionMenuItems) { + actionMenuItems.other.unshift({ + description: this.env._t('Archive'), + callback: () => this.taskRecurrence.stopRecurrence( + [this.model.root], + () => this.model.root.archive(), + ), + }); + } + return actionMenuItems; + } + + deleteRecord() { + if (!this.model.root.data.recurrence_id) { + return super.deleteRecord(); + } + this.taskRecurrence.stopRecurrence( + [this.model.root], + () => { + this.model.root.delete(); + if (!this.model.root.resId) { + this.env.config.historyBack(); + } + } + ); + } +} diff --git a/addons/project/static/src/views/project_task_form/project_task_form_view.js b/addons/project/static/src/views/project_task_form/project_task_form_view.js new file mode 100644 index 00000000000..e7caa8f2533 --- /dev/null +++ b/addons/project/static/src/views/project_task_form/project_task_form_view.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { formViewWithHtmlExpander } from '../form_with_html_expander/form_view_with_html_expander'; +import { ProjectTaskFormController } from './project_task_form_controller'; + +export const projectTaskFormView = { + ...formViewWithHtmlExpander, + Controller: ProjectTaskFormController, +}; + +registry.category("views").add("project_task_form", projectTaskFormView); diff --git a/addons/project/static/src/views/project_task_form/project_task_form_view.scss b/addons/project/static/src/views/project_task_form/project_task_form_view.scss new file mode 100644 index 00000000000..6658c7c8dd0 --- /dev/null +++ b/addons/project/static/src/views/project_task_form/project_task_form_view.scss @@ -0,0 +1,27 @@ +.o_form_project_tasks { + .o_widget_web_ribbon:not(.o_invisible_modifier) + div div[name="kanban_state"] > div { + margin-right: 150px; + padding-left: 1rem; + } + + div[name="kanban_state"] span.o_status { + width: 17px; + height: 17px; + } + + .note-editable { + border: 0; + } + + .o_wysiwyg_resizer { + border: 0; + } + + .o_form_project_recurrence_message *:last-child { + margin-bottom: 0; + } + + &.o_form_editable .oe_title { + max-width: initial; + } +} diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_dynamic_group_list.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_dynamic_group_list.js new file mode 100644 index 00000000000..9d322c693bf --- /dev/null +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_dynamic_group_list.js @@ -0,0 +1,36 @@ +/** @odoo-module */ + +import { KanbanDynamicGroupList } from "@web/views/kanban/kanban_model"; + +export class ProjectTaskKanbanDynamicGroupList extends KanbanDynamicGroupList { + get context() { + const context = super.context; + if (context.createPersonalStageGroup) { + context.default_user_id = context.uid; + delete context.createPersonalStageGroup; + delete context.default_project_id; + } + return context; + } + + get isGroupedByStage() { + return !!this.groupByField && this.groupByField.name === 'stage_id'; + } + + get isGroupedByPersonalStages() { + return !!this.groupByField && this.groupByField.name === 'personal_stage_type_ids'; + } + + async createGroup() { + if (this.isGroupedByPersonalStages) { + this.defaultContext = Object.assign({}, this.defaultContext || {}, { + createPersonalStageGroup: true, + }); + } + const result = await super.createGroup(...arguments); + if (this.isGroupedByPersonalStages) { + delete this.defaultContext.createPersonalStageGroup; + } + return result; + } +} diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js new file mode 100644 index 00000000000..5c224555389 --- /dev/null +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js @@ -0,0 +1,26 @@ +/** @odoo-module */ + +import { KanbanModel } from "@web/views/kanban/kanban_model"; + +import { ProjectTaskKanbanDynamicGroupList } from "./project_task_kanban_dynamic_group_list"; +import { ProjectTaskRecord } from './project_task_kanban_record'; + +export class ProjectTaskKanbanGroup extends KanbanModel.Group { + get isPersonalStageGroup() { + return !!this.groupByField && this.groupByField.name === 'personal_stage_type_ids'; + } + + async delete() { + if (this.isPersonalStageGroup) { + return await this.model.orm.call(this.resModel, 'remove_personal_stage', [this.resId]); + } else { + return super.delete(); + } + } +} + +export class ProjectTaskKanbanModel extends KanbanModel { } + +ProjectTaskKanbanModel.DynamicGroupList = ProjectTaskKanbanDynamicGroupList; +ProjectTaskKanbanModel.Group = ProjectTaskKanbanGroup; +ProjectTaskKanbanModel.Record = ProjectTaskRecord; diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_record.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_record.js new file mode 100644 index 00000000000..cedcaaba6bd --- /dev/null +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_record.js @@ -0,0 +1,14 @@ +/* @odoo-module */ + +import { Record } from '@web/views/relational_model'; + +export class ProjectTaskRecord extends Record { + async _applyChanges(changes) { + const value = changes.personal_stage_type_ids; + if (value && Array.isArray(value) && value.length === 1) { + delete changes.personal_stage_type_ids; + changes.personal_stage_type_id = value; + } + await super._applyChanges(changes); + } +} diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js new file mode 100644 index 00000000000..9e0a9f898db --- /dev/null +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js @@ -0,0 +1,91 @@ +/** @odoo-module */ + +import { useService } from '@web/core/utils/hooks'; +import { KanbanRenderer } from '@web/views/kanban/kanban_renderer'; +import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"; + +const { onWillStart } = owl; + +export class ProjectTaskKanbanRenderer extends KanbanRenderer { + setup() { + super.setup(); + this.userService = useService('user'); + this.action = useService('action'); + + this.isProjectManager = false; + onWillStart(this.onWillStart); + } + + get canMoveRecords() { + let canMoveRecords = super.canMoveRecords; + if (!canMoveRecords && this.canResequenceRecords && this.props.list.isGroupedByPersonalStages) { + const { groupByField } = this.props.list; + const { modifiers } = groupByField; + canMoveRecords = !(modifiers && modifiers.readonly); + } + return canMoveRecords; + } + + get canResequenceGroups() { + let canResequenceGroups = super.canResequenceGroups; + if (!canResequenceGroups && this.props.list.isGroupedByPersonalStages) { + const { modifiers } = this.props.list.groupByField; + const { groupsDraggable } = this.props.archInfo; + canResequenceGroups = groupsDraggable && !(modifiers && modifiers.readonly); + } + return canResequenceGroups; + } + + async onWillStart() { + if (!this.props.list.isGroupedByPersonalStages) { // no need to check it if the group by is personal stages + this.isProjectManager = await this.userService.hasGroup('project.group_project_manager'); + } + } + + canCreateGroup() { + return super.canCreateGroup() && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages; + } + + canDeleteGroup(group) { + return super.canDeleteGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages; + } + + canEditGroup(group) { + return super.canEditGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages; + } + + async deleteGroup(group) { + if (group && group.groupByField.name === 'stage_id') { + const action = await group.model.orm.call( + group.resModel, + 'unlink_wizard', + [group.resId], + { context: group.context }, + ); + this.action.doAction(action); + return; + } + super.deleteGroup(group); + } + + editGroup(group) { + const groupBy = this.props.list.groupBy; + if (groupBy.length !== 1 || groupBy[0] !== 'personal_stage_type_ids') { + super.editGroup(group); + return; + } + const context = Object.assign({}, group.context, { + form_view_ref: 'project.personal_task_type_edit', + }); + this.dialog.add(FormViewDialog, { + context, + resId: group.value, + resModel: group.resModel, + title: this.env._t('Edit Personal Stage'), + onRecordSaved: async () => { + await this.props.list.load(); + this.props.list.model.notify(); + }, + }); + } +} diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_view.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_view.js new file mode 100644 index 00000000000..e028f0b07fe --- /dev/null +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_view.js @@ -0,0 +1,16 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { kanbanView } from '@web/views/kanban/kanban_view'; +import { ProjectTaskKanbanModel } from "./project_task_kanban_model"; +import { ProjectTaskKanbanRenderer } from './project_task_kanban_renderer'; +import { ProjectControlPanel } from "../../components/project_control_panel/project_control_panel"; + +export const projectTaskKanbanView = { + ...kanbanView, + Model: ProjectTaskKanbanModel, + Renderer: ProjectTaskKanbanRenderer, + ControlPanel: ProjectControlPanel, +}; + +registry.category('views').add('project_task_kanban', projectTaskKanbanView); diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_view.scss b/addons/project/static/src/views/project_task_kanban/project_task_kanban_view.scss new file mode 100644 index 00000000000..534ac1c7316 --- /dev/null +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_view.scss @@ -0,0 +1,5 @@ +.o_kanban_project_tasks { + div.o_field_many2many_avatar_user[name="user_ids"] { + margin-left: 6px; + } +} diff --git a/addons/project/static/src/views/project_task_list/project_task_list_controller.js b/addons/project/static/src/views/project_task_list/project_task_list_controller.js new file mode 100644 index 00000000000..069a9dfc018 --- /dev/null +++ b/addons/project/static/src/views/project_task_list/project_task_list_controller.js @@ -0,0 +1,58 @@ +/** @odoo-module */ + +import { useService } from '@web/core/utils/hooks'; +import { ListController } from '@web/views/list/list_controller'; + +export class ProjectTaskListController extends ListController { + setup() { + super.setup(); + this.taskRecurrence = useService('project_task_recurrence'); + } + + getActionMenuItems() { + if (!this.archiveEnabled || this.model.root.isM2MGrouped) { + return super.getActionMenuItems(); + } + const hasAnyRecurrences = this._anySelectedTasksWithRecurrence(); + this.archiveEnabled = !hasAnyRecurrences; + const actionMenuItems = super.getActionMenuItems(); + this.archiveEnabled = true; + if (actionMenuItems && hasAnyRecurrences) { + actionMenuItems.other.splice( + this.isExportEnable ? 1 : 0, + 0, + { + description: this.env._t('Archive'), + callback: () => this.taskRecurrence.stopRecurrence( + this.model.root.selection, + () => this.toggleArchiveState(true), + ), + }, + { + description: this.env._t('Unarchive'), + callback: () => this.toggleArchiveState(false), + }, + ); + } + return actionMenuItems; + } + + onDeleteSelectedRecords() { + if (this._anySelectedTasksWithRecurrence()) { + return this.taskRecurrence.stopRecurrence( + this.model.root.selection, + () => this.model.root.deleteRecords(), + ); + } + return super.onDeleteSelectedRecords(); + } + + _anySelectedTasksWithRecurrence() { + for (const selectedTask of this.model.root.selection) { + if (selectedTask.data.recurrence_id) { + return true; + } + } + return false; + } +} diff --git a/addons/project/static/src/views/project_task_list/project_task_list_view.js b/addons/project/static/src/views/project_task_list/project_task_list_view.js new file mode 100644 index 00000000000..b081f9877b7 --- /dev/null +++ b/addons/project/static/src/views/project_task_list/project_task_list_view.js @@ -0,0 +1,14 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { listView } from '@web/views/list/list_view'; +import { ProjectControlPanel } from "../../components/project_control_panel/project_control_panel"; +import { ProjectTaskListController } from './project_task_list_controller'; + +export const projectTaskListView = { + ...listView, + Controller: ProjectTaskListController, + ControlPanel: ProjectControlPanel, +}; + +registry.category("views").add("project_task_list", projectTaskListView); diff --git a/addons/project/static/src/views/project_update_kanban/project_update_kanban.scss b/addons/project/static/src/views/project_update_kanban/project_update_kanban.scss new file mode 100644 index 00000000000..b67cde7724e --- /dev/null +++ b/addons/project/static/src/views/project_update_kanban/project_update_kanban.scss @@ -0,0 +1,75 @@ +.o_pupdate_kanban { + overflow: auto; + &.o_kanban_ungrouped { + padding:0px; + .o_pupdate_kanban_card { + width: 100%; + margin: 0px; + border-right: 0px; + border-top: 0px; + overflow: hidden; + .o_kanban_detail_ungrouped { + padding-left: $o-horizontal-padding - $o-kanban-color-border-width - $o-kanban-record-margin; + > div { + display: grid; + align-items: center; + } + .o_pupdate_kanban_image { + width: 56px; + height: 56px; + top: 0px; + position: absolute; + } + .o_pupdate_name { + overflow-wrap: break-word; + } + .o_pupdate_kanban_actions_ungrouped { + button { + float: right; + margin: 4px; + } + } + .o_country_flag { + margin-right: 8px; + } + + .o_field_status_with_color { + .o_status_label { + display: none; + } + + @include media-breakpoint-up(sm) { + .o_status_label { + display: block; + } + } + } + } + } + } + + .oe_kanban_color_20 { + border-left-color: $o-success; + &:after { + background-color: $o-success; + } + } + .oe_kanban_color_21 { + border-left-color: $o-info; + &:after { + background-color: $o-info; + } + } + .oe_kanban_color_22 { + border-left-color: $o-warning; + &:after { + background-color: $o-warning; + } + } + .oe_kanban_color_23 { + border-left-color: $o-danger; + &:after { + background-color: $o-danger; + } + } +} diff --git a/addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.js b/addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.js new file mode 100644 index 00000000000..5c9f01da636 --- /dev/null +++ b/addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.js @@ -0,0 +1,16 @@ +/** @odoo-module */ + +import { KanbanController } from '@web/views/kanban/kanban_controller'; +import { ProjectRightSidePanel } from '../../components/project_right_side_panel/project_right_side_panel'; + +export class ProjectUpdateKanbanController extends KanbanController { + get className() { + return super.className + ' o_controller_with_rightpanel'; + } +} + +ProjectUpdateKanbanController.components = { + ...KanbanController.components, + ProjectRightSidePanel, +}; +ProjectUpdateKanbanController.template = 'project.ProjectUpdateKanbanView'; diff --git a/addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.xml b/addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.xml new file mode 100644 index 00000000000..51c87b86975 --- /dev/null +++ b/addons/project/static/src/views/project_update_kanban/project_update_kanban_controller.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/addons/project/static/src/views/project_update_kanban/project_update_kanban_view.js b/addons/project/static/src/views/project_update_kanban/project_update_kanban_view.js new file mode 100644 index 00000000000..eb39356528b --- /dev/null +++ b/addons/project/static/src/views/project_update_kanban/project_update_kanban_view.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { kanbanView } from "@web/views/kanban/kanban_view"; +import { ProjectUpdateKanbanController } from './project_update_kanban_controller'; + +export const projectUpdateKanbanView = { + ...kanbanView, + Controller: ProjectUpdateKanbanController, +}; + +registry.category('views').add('project_update_kanban', projectUpdateKanbanView); diff --git a/addons/project/static/src/views/project_update_list/project_update_list_controller.js b/addons/project/static/src/views/project_update_list/project_update_list_controller.js new file mode 100644 index 00000000000..2c7e9ba000f --- /dev/null +++ b/addons/project/static/src/views/project_update_list/project_update_list_controller.js @@ -0,0 +1,16 @@ +/** @odoo-module */ + +import { ListController } from '@web/views/list/list_controller'; +import { ProjectRightSidePanel } from '../../components/project_right_side_panel/project_right_side_panel'; + +export class ProjectUpdateListController extends ListController { + get className() { + return super.className + ' o_controller_with_rightpanel'; + } +} + +ProjectUpdateListController.components = { + ...ListController.components, + ProjectRightSidePanel, +}; +ProjectUpdateListController.template = 'project.ProjectUpdateListView'; diff --git a/addons/project/static/src/views/project_update_list/project_update_list_controller.xml b/addons/project/static/src/views/project_update_list/project_update_list_controller.xml new file mode 100644 index 00000000000..03c8b43a93c --- /dev/null +++ b/addons/project/static/src/views/project_update_list/project_update_list_controller.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/addons/project/static/src/views/project_update_list/project_update_list_view.js b/addons/project/static/src/views/project_update_list/project_update_list_view.js new file mode 100644 index 00000000000..067ad35fcc9 --- /dev/null +++ b/addons/project/static/src/views/project_update_list/project_update_list_view.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; +import { ProjectUpdateListController } from './project_update_list_controller'; + +export const projectUpdateListView = { + ...listView, + Controller: ProjectUpdateListController, +}; + +registry.category('views').add('project_update_list', projectUpdateListView); diff --git a/addons/project/static/src/xml/project_templates.xml b/addons/project/static/src/xml/project_templates.xml index 1f1e349ed06..8b7866d207e 100644 --- a/addons/project/static/src/xml/project_templates.xml +++ b/addons/project/static/src/xml/project_templates.xml @@ -26,223 +26,6 @@
- -
- - - -
- -
- - - -
-
-
- - - -
-
-
-
- - - - -
-
-
- Profitability -
-
- -
-
- -
-
- -
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RevenuesInvoicedTo InvoiceExpected
- - - - -
Total
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CostsBilledTo BillExpected
- - - - -
Total
-
- - - - - - - - - - - - - - - - -
Margin
-
- - - - -
-
-
- Milestones -
- - - -
-
-
- -
- - Track major progress points that must be reached to achieve success. - -
-
-
- - -
- - - -
- - - -
-
- -
- - - -
- - - -
-
Private diff --git a/addons/project/static/tests/project_form_tests.js b/addons/project/static/tests/project_form_tests.js index 0497d344239..cc66dfdbe8c 100644 --- a/addons/project/static/tests/project_form_tests.js +++ b/addons/project/static/tests/project_form_tests.js @@ -1,43 +1,33 @@ /** @odoo-module */ -import { createView } from 'web.test_utils'; -import {ProjectFormView} from "@project/js/project_form"; -import {patch} from "@web/core/utils/patch"; -import core from "web.core"; -let serverData = null; -const ProjectFormController = ProjectFormView.prototype.config.Controller; -QUnit.module("Project", hooks => { +import { getFixture } from "@web/../tests/helpers/utils"; +import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; + +let makeViewParams, target; + +QUnit.module("Project", (hooks) => { hooks.beforeEach(() => { - serverData = { - project: { - fields: { - id: { string: "Id", type: "integer" }, + makeViewParams = { + type: "form", + resModel: "project.project", + serverData: { + models: { + "project.project": { + fields: { + id: { string: "Id", type: "integer" }, + }, + records: [{ id: 1, display_name: "First record" }], + }, }, - records: [ - { id: 1, display_name: "First record"}, - ], }, - } - }) + arch: `
`, + }; + target = getFixture(); + setupViewRegistries(); + }); QUnit.module("Form"); QUnit.test("project form view", async function (assert) { - /* - This is a test whitebox because the flow cannot be reproduced correctly otherwise. - The idea is to check that the DOM_updated event is not triggered twice to avoid a crash. - */ - patch(ProjectFormController.prototype, "patchedInit", { - init: function () { - this._super(...arguments); - core.bus.trigger("DOM_updated"); - } - }); - const form = await createView({ - View: ProjectFormView, - model: 'project', - data: serverData, - arch: '
', - }); - assert.containsOnce(document.body, form.el); - form.destroy() - }) + await makeView(makeViewParams); + assert.containsOnce(target, ".o_form_view"); + }); }); diff --git a/addons/project/static/tests/tours/personal_stage_tour.js b/addons/project/static/tests/tours/personal_stage_tour.js index d76fe574294..1b90575ef51 100644 --- a/addons/project/static/tests/tours/personal_stage_tour.js +++ b/addons/project/static/tests/tours/personal_stage_tour.js @@ -52,7 +52,7 @@ tour.register('personal_stage_tour', { trigger: ".dropdown-item.o_column_edit", }, { content: "Change title", - trigger: 'input.o_field_char[name="name"]', + trigger: 'div.o_field_char[name="name"] input', run: 'text (Todo)', }, { content: "Save changes", diff --git a/addons/project/static/tests/tours/project_sharing_tour.js b/addons/project/static/tests/tours/project_sharing_tour.js index 8b6334a35d3..dbd3cf50ded 100644 --- a/addons/project/static/tests/tours/project_sharing_tour.js +++ b/addons/project/static/tests/tours/project_sharing_tour.js @@ -3,13 +3,7 @@ import tour from 'web_tour.tour'; const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project App.'), { - // an invisible element cannot be used as a trigger so this small hack is mandatory for the next step - trigger: '.o_kanban_record:contains("Project Sharing")', - run: function () { - this.$anchor.find('.o_dropdown_kanban').css('visibility', 'visible'); - }, -}, { - trigger: '.oe_kanban_global_click :contains("Project Sharing") .o_dropdown_kanban', + trigger: '.oe_kanban_global_click :contains("Project Sharing") button.o_dropdown_kanban', content: 'Open the project dropdown.' }, { trigger: '.o_kanban_record:contains("Project Sharing") .dropdown-menu a:contains("Share")', @@ -24,7 +18,7 @@ const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_p actions.text('Georges', this.$anchor.find('input')); }, }, { - trigger: '.ui-autocomplete a:contains("Georges")', + trigger: '.ui-autocomplete a.dropdown-item:contains("Georges")', in_modal: false, }, { trigger: 'footer > button[name="action_send_mail"]', diff --git a/addons/project/static/tests/tours/project_update_tour_tests.js b/addons/project/static/tests/tours/project_update_tour_tests.js index 3447e426a6c..5dd5daf5e90 100644 --- a/addons/project/static/tests/tours/project_update_tour_tests.js +++ b/addons/project/static/tests/tours/project_update_tour_tests.js @@ -21,7 +21,7 @@ function openProjectUpdateAndReturnToTasks(view, viewClass) { }, { trigger: '.o_back_button', content: 'Go back to the task view : ' + view, - extra_trigger: '.o_list_view, .o_legacy_list_view', + // extra_trigger: '.o_list_view, .o_legacy_list_view', // FIXME: [XBO] uncomment it when the sample data will be displayed after discarding the creation of a project update record. }, ]; } @@ -65,7 +65,7 @@ tour.register('project_update_tour', { trigger: '.o-kanban-button-new', extra_trigger: '.o_kanban_group:eq(0)' }, { - trigger: '.o_kanban_quick_create input.o_field_char[name=name]', + trigger: '.o_kanban_quick_create div.o_field_char[name=name] input', extra_trigger: '.o_kanban_project_tasks', run: 'text New task' }, { @@ -75,7 +75,7 @@ tour.register('project_update_tour', { trigger: '.o-kanban-button-new', extra_trigger: '.o_kanban_group:eq(0)' }, { - trigger: '.o_kanban_quick_create input.o_field_char[name=name]', + trigger: '.o_kanban_quick_create div.o_field_char[name=name] input', extra_trigger: '.o_kanban_project_tasks', run: 'text Second task' }, { @@ -84,7 +84,7 @@ tour.register('project_update_tour', { }, { trigger: '.o_kanban_group:nth-child(2) .o_kanban_header', run: function () { - document.querySelector('.o_kanban_group:nth-child(2) .o_kanban_config.dropdown .dropdown-toggle').dispatchEvent(new Event('click')) + document.querySelector('.o_kanban_group:nth-child(2) .o_kanban_config.dropdown .dropdown-toggle').dispatchEvent(new Event('click')); } }, { trigger: ".dropdown-item.o_column_edit", @@ -103,58 +103,52 @@ tour.register('project_update_tour', { trigger: ".o_add_milestone a", content: "Add a first milestone" }, { - trigger: "input.o_field_widget[name=name]", + trigger: "div.o_field_widget[name=name] input", run: 'text New milestone' }, { - trigger: "input.datetimepicker-input[name=deadline]", + trigger: "div[name=deadline] .datetimepicker-input", run: 'text 12/12/2099' }, { - trigger: ".modal-footer button" + trigger: ".modal-footer .o_form_button_save" }, { trigger: ".o_add_milestone a", }, { - trigger: "input.o_field_widget[name=name]", + trigger: "div.o_field_widget[name=name] input", run: 'text Second milestone' }, { - trigger: "input.datetimepicker-input[name=deadline]", + trigger: "div[name=deadline] .datetimepicker-input", run: 'text 12/12/2022' }, { - trigger: ".modal-footer button" + trigger: ".modal-footer .o_form_button_save" }, { - trigger: ".o_open_milestone:eq(1) .o_milestone_detail span:eq(0)", - extra_trigger: ".o_add_milestone a", - run: function () { - setTimeout(() => { - this.$anchor.click(); - }, 500); - }, + trigger: ".o_rightpanel_milestone:eq(1) .o_milestone_detail", }, { - trigger: "input.datetimepicker-input[name=deadline]", + trigger: "div[name=deadline] .datetimepicker-input", run: 'text 12/12/2100' }, { - trigger: ".modal-footer button" + trigger: ".modal-footer .o_form_button_save" }, { trigger: ".o-kanban-button-new", content: "Create a new update" }, { - trigger: "input.o_field_widget[name=name]", + trigger: "div.o_field_widget[name=name] input", run: 'text New update' }, { trigger: ".o_form_button_save" }, { - trigger: ".o_field_widget[name=description] h1:contains('Activities')", + trigger: ".o_field_widget[name='description'] h1:contains('Activities')", run: function () {}, }, { - trigger: ".o_field_widget[name=description] h3:contains('Milestones')", + trigger: ".o_field_widget[name='description'] h3:contains('Milestones')", run: function () {}, }, { - trigger: ".o_field_widget[name=description] div[name='milestone'] ul li:contains('(12/12/2099 => 12/12/2100)')", + trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li:contains('(12/12/2099 => 12/12/2100)')", run: function () {}, }, { - trigger: ".o_field_widget[name=description] div[name='milestone'] ul li span:contains('(due 12/12/2022)')", + trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li span:contains('(due 12/12/2022)')", run: function () {}, }, { - trigger: ".o_field_widget[name=description] div[name='milestone'] ul li span:contains('(due 12/12/2100)')", + trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li span:contains('(due 12/12/2100)')", run: function () {}, }, { trigger: '.o_back_button', diff --git a/addons/project/views/project_update_views.xml b/addons/project/views/project_update_views.xml index 6f24549985c..0e3627e50e1 100644 --- a/addons/project/views/project_update_views.xml +++ b/addons/project/views/project_update_views.xml @@ -36,7 +36,7 @@ - + @@ -70,26 +70,25 @@
-
+
- - -
-
- -
Author
+
-
- -
Date
+
+ +
-
+
Progress
+
+ +
Date
+
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index ba1e32de8da..8e294ada453 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -433,7 +433,7 @@ project.project.form project.project -
+
@@ -493,7 +493,7 @@
- +
@@ -1059,7 +1064,7 @@ project.task - + @@ -1137,21 +1142,22 @@
-

- - - +

+
+ + +
+

-
diff --git a/addons/project_hr_expense/models/project.py b/addons/project_hr_expense/models/project.py index 227e1db2882..cf3ed2c2933 100644 --- a/addons/project_hr_expense/models/project.py +++ b/addons/project_hr_expense/models/project.py @@ -90,9 +90,10 @@ class Project(models.Model): 'costs': {'id': section_id, 'sequence': self._get_profitability_sequence_per_invoice_type()[section_id], 'billed': -expense_data['untaxed_amount'], 'to_bill': 0.0}, } if can_see_expense: - action = {'name': 'action_profitability_items', 'type': 'object', 'section': section_id, 'domain': json.dumps([('id', 'in', expense_data['ids'])])} + args = [section_id, [('id', 'in', expense_data['ids'])]] if expense_data['ids']: - action['res_id'] = expense_data['ids'] + args.append(expense_data['ids']) + action = {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)} expense_profitability_items['action'] = action return expense_profitability_items diff --git a/addons/project_purchase/models/project.py b/addons/project_purchase/models/project.py index 4efd8485b10..4097301a1f8 100644 --- a/addons/project_purchase/models/project.py +++ b/addons/project_purchase/models/project.py @@ -132,9 +132,10 @@ class Project(models.Model): section_id = 'purchase_order' purchase_order_costs = {'id': section_id, 'sequence': self._get_profitability_sequence_per_invoice_type()[section_id], 'billed': amount_invoiced, 'to_bill': amount_to_invoice} if with_action and purchase_order_line_ids and self.user_has_groups('purchase.group_purchase_user'): - action = {'name': 'action_profitability_items', 'type': 'object', 'section': section_id, 'domain': json.dumps([('id', 'in', purchase_order_line_ids)])} + args = [section_id, [('id', 'in', purchase_order_line_ids)]] if len(purchase_order_line_ids) == 1: - action['res_id'] = purchase_order_line_ids[0] + args.append(purchase_order_line_ids[0]) + action = {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)} purchase_order_costs['action'] = action costs['data'].append(purchase_order_costs) costs['total']['billed'] += amount_invoiced diff --git a/addons/project_sale_expense/models/project_project.py b/addons/project_sale_expense/models/project_project.py index 725d2aa57b4..fc1493e749c 100644 --- a/addons/project_sale_expense/models/project_project.py +++ b/addons/project_sale_expense/models/project_project.py @@ -72,10 +72,10 @@ class Project(models.Model): } if can_see_expense: def get_action(res_ids): - action = {'name': 'action_profitability_items', 'type': 'object', 'section': section_id, 'domain': json.dumps([('id', 'in', res_ids)])} + args = [section_id, [('id', 'in', res_ids)]] if len(res_ids) == 1: - action['res_id'] = res_ids[0] - return action + args.append(res_ids[0]) + return {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)} if reinvoice_expense_ids: expense_data['revenues']['action'] = get_action(reinvoice_expense_ids) diff --git a/addons/sale_project/__manifest__.py b/addons/sale_project/__manifest__.py index 3210d881c68..62e8563dd8b 100644 --- a/addons/sale_project/__manifest__.py +++ b/addons/sale_project/__manifest__.py @@ -23,11 +23,10 @@ This module allows to generate a project/task from sales orders. ], 'assets': { 'web.assets_backend': [ - 'sale_project/static/src/right_panel/*.js', + 'sale_project/static/src/components/project_right_side_panel/**/*', ], 'web.assets_qweb': [ - 'sale_project/static/src/right_panel/*.xml', - 'sale_project/static/src/xml/**/*', + 'sale_project/static/src/components/project_right_side_panel/**/*.xml', ], }, 'auto_install': True, diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 384e56e5031..acd28279f40 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -273,7 +273,7 @@ class Project(models.Model): def get_action(sol_id): """ Return the action vals to call it in frontend if the user can access to the SO related """ action, res_id = action_per_sol.get(sol_id, (None, None)) - return {'action': action, 'res_id': res_id, 'additional_context': json.dumps({'active_id': sol_id, 'default_project_id': self.id})} if action else {} + return {'action': {'name': action, 'resId': res_id, 'buttonContext': json.dumps({'active_id': sol_id, 'default_project_id': self.id})}} if action else {} return [{ **sol_read, diff --git a/addons/sale_project/static/src/xml/project_templates.xml b/addons/sale_project/static/src/components/project_right_side_panel/components/project_milestone.xml similarity index 90% rename from addons/sale_project/static/src/xml/project_templates.xml rename to addons/sale_project/static/src/components/project_right_side_panel/components/project_milestone.xml index d175ae8d99d..8bdca8888cf 100644 --- a/addons/sale_project/static/src/xml/project_templates.xml +++ b/addons/sale_project/static/src/components/project_right_side_panel/components/project_milestone.xml @@ -1,6 +1,6 @@ - + @@ -9,7 +9,7 @@ - + (%) diff --git a/addons/sale_project/static/src/right_panel/project_right_panel.js b/addons/sale_project/static/src/components/project_right_side_panel/project_right_side_panel.js similarity index 61% rename from addons/sale_project/static/src/right_panel/project_right_panel.js rename to addons/sale_project/static/src/components/project_right_side_panel/project_right_side_panel.js index 9f0a312ef47..b3788d30540 100644 --- a/addons/sale_project/static/src/right_panel/project_right_panel.js +++ b/addons/sale_project/static/src/components/project_right_side_panel/project_right_side_panel.js @@ -1,22 +1,22 @@ -/** @odoo-module */ +/** @odoo-module */ -import { patch } from 'web.utils'; +import { patch } from '@web/core/utils/patch'; import { formatFloatTime, formatFloat } from "@web/views/fields/formatters"; -import ProjectRightPanel from '@project/js/right_panel/project_right_panel'; +import { ProjectRightSidePanel } from '@project/components/project_right_side_panel/project_right_side_panel'; -patch(ProjectRightPanel.prototype, '@sale_project/right_panel/project_right_panel', { +patch(ProjectRightSidePanel.prototype, '@sale_project/components/project_right_side_panel/project_right_side_panel', { async _loadAdditionalSalesOrderItems() { const offset = this.state.data.sale_items.data.length; const totalRecords = this.state.data.sale_items.total; const limit = totalRecords - offset <= 5 ? totalRecords - offset : 5; - const saleOrderItems = await this.rpc({ - model: 'project.project', - method: 'get_sale_items_data', - args: [this.project_id, undefined, offset, limit], - kwargs: { + const saleOrderItems = await this.orm.call( + 'project.project', + 'get_sale_items_data', + [this.projectId, undefined, offset, limit], + { context: this.context, - } - }); + }, + ); this.state.data.sale_items.data = [...this.state.data.sale_items.data, ...saleOrderItems]; }, diff --git a/addons/sale_project/static/src/components/project_right_side_panel/project_right_side_panel.xml b/addons/sale_project/static/src/components/project_right_side_panel/project_right_side_panel.xml new file mode 100644 index 00000000000..3950e17f34c --- /dev/null +++ b/addons/sale_project/static/src/components/project_right_side_panel/project_right_side_panel.xml @@ -0,0 +1,51 @@ + + + + + + + + Sales + + + + + + + + + + + + + + + + + + + + + + +
Sales Order ItemsSoldDeliveredInvoiced
+ + + + + +
+ + Load more + +
+
+
+
+ +
diff --git a/addons/sale_project/static/src/right_panel/project_right_panel.xml b/addons/sale_project/static/src/right_panel/project_right_panel.xml deleted file mode 100644 index 99605c98e0a..00000000000 --- a/addons/sale_project/static/src/right_panel/project_right_panel.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - -
-
-
- Sales -
-
- -
- - - - - - - - - - - - - - - - - - - - - -
Sales Order ItemsSoldDeliveredInvoiced
- - - - - -
- - Load more - -
-
-
-
-
- -
diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 12ff16162d1..b825719b507 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -43,6 +43,7 @@ have real delivered quantities in sales orders. 'sale_timesheet/static/src/scss/sale_timesheet_portal.scss', ], 'web.assets_backend': [ + 'sale_timesheet/static/src/components/**/*', 'sale_timesheet/static/src/js/so_line_one2many.js', ], 'web.assets_tests': [ diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 5035f7019a2..1f2de9379ff 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -450,10 +450,10 @@ class Project(models.Model): action_name = 'action_profitability_items' def get_timesheets_action(invoice_type, record_ids): - action_params = {'name': action_name, 'type': 'object', 'section': invoice_type, 'domain': json.dumps([('id', 'in', record_ids)])} + args = [invoice_type, [('id', 'in', record_ids)]] if len(record_ids) == 1: - action_params['res_id'] = record_ids[0] - return action_params + args.append(record_ids[0]) + return {'name': action_name, 'type': 'object', 'args': json.dumps(args)} sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type() diff --git a/addons/sale_timesheet/static/src/components/so_line_field/so_line_field.js b/addons/sale_timesheet/static/src/components/so_line_field/so_line_field.js new file mode 100644 index 00000000000..e26f2e937e7 --- /dev/null +++ b/addons/sale_timesheet/static/src/components/so_line_field/so_line_field.js @@ -0,0 +1,21 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { Many2OneField } from "@web/views/fields/many2one/many2one_field"; + +export class SoLineField extends Many2OneField { + setup() { + super.setup(); + + const update = this.update; + this.update = (value, params = {}) => { + update(value, params); + if (value || this.updateOnEmpty) { + this.props.record.update({ is_so_line_edited: true }); + } + }; + } +} + +registry.category("fields").add('so_line_one2many', registry.category('fields').get("one2many")); // TODO: Remove me when the gantt view is converted in OWL +registry.category("fields").add("so_line_field", SoLineField); diff --git a/addons/sale_timesheet/static/src/js/so_line_one2many.js b/addons/sale_timesheet/static/src/js/so_line_one2many.js index f504087ee4e..a6ba68a0ba3 100644 --- a/addons/sale_timesheet/static/src/js/so_line_one2many.js +++ b/addons/sale_timesheet/static/src/js/so_line_one2many.js @@ -40,7 +40,7 @@ const SoLineMany2one = FieldMany2One.extend({ fieldRegistry.add('so_line_one2many', SoLineOne2Many); -fieldRegistry.add('so_line_many2one', SoLineMany2one); +fieldRegistry.add('so_line_field', SoLineMany2one); return SoLineOne2Many; diff --git a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js index 0a124f3d704..518448675bc 100644 --- a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js +++ b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js @@ -11,32 +11,24 @@ tour.register('sale_timesheet_tour', { { trigger: 'button.o_list_button_add', content: 'Click on CREATE button to create a quotation with service products.', - run: 'click', }, { - trigger: 'div[name="partner_id"]', + trigger: 'div[name="partner_id"] input', content: 'Add the customer for this quotation (e.g. Brandon Freeman)', - run: function (actions) { - actions.text('Brandon Freeman', this.$anchor.find('input')); - }, + run: 'text Brandon Freeman', }, { trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)', content: 'Select the first item on the autocomplete dropdown', - run: 'click', }, { trigger: 'td.o_field_x2many_list_row_add > a:first-child', content: 'Click on "Add a product" to add a new product. We will add a service product.', - run: 'click', }, { - trigger: '.o_field_widget[name="product_id"], .o_field_widget[name="product_template_id"]', + trigger: '.o_field_widget[name="product_id"], .o_field_widget[name="product_template_id"] input', content: Markup('Select a prepaid service product (e.g. Service Product (Prepaid Hours))'), - run: function (actions) { - actions.text('Service Product (Prepaid Hours)', this.$anchor.find('input')); - }, + run: 'text Service Product (Prepaid Hours)', }, { trigger: 'ul.ui-menu.ui-widget.ui-autocomplete > li:first-child > a:contains(Service Product (Prepaid Hours))', content: 'Select the prepaid service product in the autocomplete dropdown', - run: 'click', }, { trigger: 'input[name="product_uom_qty"]', content: "Add 10 hours as ordered quantity for this product.", @@ -44,88 +36,64 @@ tour.register('sale_timesheet_tour', { }, { trigger: 'button[name="action_confirm"]', content: 'Click on Confirm button to create a sale order with this quotation.', - run: 'click', }, { trigger: 'button.o_form_button_save', extra_trigger: '.o_field_widget[name=state] button[data-value=sale].btn-primary', content: 'Click on Save button to save the Sales Order.', - run: 'click', }, { trigger: '.o_form_readonly', content: 'Save is done and form is reloaded.', - run: 'click', }, tour.stepUtils.toggleHomeMenu(), ...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project app.'), { trigger: 'button.o-kanban-button-new', content: 'Add a new project.', - run: 'click', }, { trigger: '.o_field_widget.o_project_name input', content: 'Select your project name (e.g. Project for Freeman)', - run: function (actions) { - actions.text('Project for Freeman', this.$anchor); - }, + run: 'text Project for Freeman', }, { trigger: 'button[name="action_view_tasks"]', content: 'Click on Create button to create and enter to this newest project.', - run: 'click', }, { - trigger: 'div.o_kanban_header > div:first-child', + trigger: 'div.o_kanban_header > div:first-child input', content: 'Select a name of your kanban column (e.g. To Do)', - run: function (actions) { - actions.text('To Do', this.$anchor.find('input')); - }, + run: 'text To Do', }, { trigger: 'button.o_kanban_add', content: 'Click on Add button to create the column.', - run: 'click', }, { trigger: 'button.o-kanban-button-new', content: 'Click on Create button to create a task into your project.', - run: 'click', }, { - trigger: 'input[name="name"]', + trigger: 'div[name="name"] > input', content: 'Select the name of the task (e.g. Onboarding)', - run: function (actions) { - actions.text('Onboarding', this.$anchor); - } + run: 'text Onboarding', }, { trigger: 'button.o_kanban_edit', content: 'Click on Edit button to enter to the form view of the task.', position: 'bottom', - run: 'click', }, { - trigger: 'div[name="partner_id"]', + trigger: 'div[name="partner_id"] input', content: Markup('Select the customer of your Sales Order (e.g. Brandon Freeman). Since we have a Sales Order for this customer with a prepaid service product which the remaining hours to deliver is greater than 0, the Sales Order Item in the task should be contain the Sales Order Item containing this prepaid service product.'), - run: function (actions) { - actions.text('Brandon Freeman', this.$anchor.find('input')); - }, + run: 'text Brandon Freeman', }, { trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)', content: 'Select the customer in the autocomplete dropdown.', - run: 'click', }, { - trigger: 'div.o_notebook_headers', + trigger: 'a.nav-link:contains(Timesheets)', + extra_trigger: 'div.o_notebook_headers', content: 'Click on Timesheets page to log a timesheet', - run: function (actions) { - const notebookId = $('div[name="timesheet_ids"]').closest("div.tab-pane").attr('id'); - actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`)); - }, }, { trigger: 'div[name="timesheet_ids"] td.o_field_x2many_list_row_add a[role="button"]', content: 'Click on Add a line to create a new timesheet into the task.', - run: 'click', }, { - trigger: 'input[name="unit_amount"]', + trigger: 'div[name="unit_amount"] input', content: 'Enter one hour for this timesheet', - run: function (actions) { - actions.text('1', this.$anchor); - }, + run: 'text 1', }, { trigger: 'i.o_optional_columns_dropdown_toggle', content: 'The so_line field should be hidden by default. We check if it is the case by adding this field in the timesheet list view', - run: 'click', }, { trigger: 'input[name="so_line"]', content: 'Check the so_line field to display the column on the list view.', @@ -137,7 +105,6 @@ tour.register('sale_timesheet_tour', { }, { trigger: 'button[name="action_view_so"]', content: 'Click on this stat button to see the SO linked to the SOL of the task.', - run: 'click', }, { trigger: 'div[name="order_line"]', content: 'Check if the quantity delivered is equal to 1 hour.', @@ -159,24 +126,18 @@ tour.register('sale_timesheet_tour', { }, { trigger: 'button[data-menu-xmlid="project.menu_project_config"]', content: 'Click on the Configuration menu.', - run: 'click', }, { trigger: '.dropdown-item[data-menu-xmlid="project.menu_projects_config"]', content: 'Select Configuration > Projects.', - run: 'click', }, { trigger: 'button.o_list_button_add', content: 'Click on Create button to create a new project and see the different configuration available for the project.', - run: 'click', }, { - trigger: 'div.o_notebook_headers', + trigger: 'a.nav-link[name="settings"]', + extra_trigger: 'div.o_notebook_headers', content: 'Click on Settings page to check the allow_billable checkbox', - run: function (actions) { - const notebookId = $('div[name="allow_billable"]').closest("div.tab-pane").attr('id'); - actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`)); - }, }, { - trigger: 'div[name="allow_billable"] > input', + trigger: 'div[name="allow_billable"] input', content: 'Check the allow_billable', run: function (actions) { if (!this.$anchor.prop('checked')) { @@ -184,93 +145,72 @@ tour.register('sale_timesheet_tour', { } } }, { - trigger: 'div[name="partner_id"]', + trigger: 'div[name="partner_id"] input', content: Markup('Add the customer for this project to select an SO and SOL for this customer (e.g. Brandon Freeman).'), - run: function (actions) { - actions.text('Azure Interior, Brandon Freeman', this.$anchor.find('input')); - }, + run: 'text Brandon Freeman', }, { trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)', content: 'Select the customer in the autocomplete dropdown', - run: 'click', }, { - trigger: 'div.o_notebook_headers', + trigger: 'a.nav-link[name="billing_employee_rate"]', + extra_trigger: 'div.o_notebook_headers', content: 'Click on Invoicing tab to configure the invoicing of this project.', - run: function (actions) { - const notebookId = $('div[name="sale_line_id"]').closest("div.tab-pane").attr('id'); - actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`)); - }, }, { - trigger: 'div[name="sale_line_id"]', + trigger: 'div[name="sale_line_id"] input', content: 'Select a Sales Order Item as Default Sales Order Item for each task in this project.', - run: function (actions) { - actions.text('S', this.$anchor.find('input')); - }, + run: 'text S', }, { trigger: 'ul.ui-autocomplete > li:first-child > a', content: 'Select the Sales Order Item in the autocomplete dropdown.', - run: 'click', }, { trigger: 'div[name="sale_line_employee_ids"] td.o_field_x2many_list_row_add > a[role="button"]', content: 'Click on Add a line on the mapping list view.', - run: 'click', }, { trigger: 'div[name="sale_line_employee_ids"] div[name="employee_id"] input', content: 'Select an employee to link a Sales Order Item on his timesheets into this project.', - run: 'click', }, { trigger: 'ul.ui-autocomplete > li:first-child > a', content: 'Select the first employee in the autocomplete dropdown', - run: 'click', }, { - trigger: 'div[name="sale_line_employee_ids"] div[name="sale_line_id"]', + trigger: 'div[name="sale_line_employee_ids"] div[name="sale_line_id"] input', content: 'Select the Sales Order Item to link to the timesheets of this employee.', position: 'bottom', - run: function (actions) { - actions.text('S', this.$anchor.find('input')); - }, + run: 'text S', }, { trigger: 'ul.ui-autocomplete > li:first-child > a', content: 'Select the first Sales Order Item in the autocomplete dropdown.', - run: 'click', }, { - trigger: 'input[name="name"]', + trigger: 'h1 > div[name="name"] > input', content: 'Set Project name', - run: function (actions) { - actions.text('Project with employee mapping', this.$anchor); - }, + run: 'text Project with employee mapping', }, { trigger: '.o_form_button_save', content: 'Save Project', }, { - trigger: '.dropdown-item[data-menu-xmlid="project.menu_projects"]', - content: 'Select Projects', + trigger: '.dropdown-item[data-menu-xmlid="project.menu_main_pm"]', + content: 'Select Project main menu', }, { - // an invisible element cannot be used as a trigger so this small hack is mandatory for the next step - trigger: 'div.o_kanban_primary_left :contains("Project for Freeman")', - content: 'Open the project dropdown', - run: function () { - $('.o_kanban_record:contains("Project for Freeman") .o_dropdown_kanban').css('visibility', 'visible'); - }, -}, { - trigger: '.oe_kanban_global_click :contains("Project for Freeman") .o_dropdown_kanban', + trigger: '.oe_kanban_global_click :contains("Project for Freeman") button.o_dropdown_kanban', content: 'Open the project dropdown', }, { trigger: '.o_kanban_record:contains("Project for Freeman") .dropdown-menu a:contains("Edit")', content: 'Start editing the project', + // timer: 300, }, { - trigger: 'div.o_notebook_headers', + trigger: 'div[name="partner_id"] input', + content: Markup('Add the customer for this project to select an SO and SOL for this customer (e.g. Brandon Freeman).'), + run: 'text Brandon Freeman', +}, { + trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a:contains(Freeman)', + content: 'Select the customer in the autocomplete dropdown', +}, { + trigger: 'a.nav-link[name="billing_employee_rate"]', + extra_trigger: 'div.o_notebook_headers', content: 'Click on Invoicing tab to configure the invoicing of this project.', - run: function (actions) { - const notebookId = $('div[name="sale_line_id"]').closest("div.tab-pane").attr('id'); - actions.click(this.$anchor.find(`a[data-bs-toggle="tab"][href="#${notebookId}"]`)); - }, }, { - trigger: 'div[name="sale_line_id"]', + trigger: 'div[name="sale_line_id"] input', content: 'Select the first sale order of the list', - run: function (actions) { - actions.text('Prepaid', this.$anchor.find('input')); - }, + run: 'text Prepaid', }, { trigger: 'ul.ui-autocomplete > li:first-child > a', content: 'Select the first item on the autocomplete dropdown', @@ -287,45 +227,46 @@ tour.register('sale_timesheet_tour', { trigger: ".o_project_updates_breadcrumb", content: 'Open Updates', }, { - trigger: ".o_rightpanel_title:eq(0):contains('Sales')", + trigger: ".o_rightpanel_section[name='sales'] .o_rightpanel_title:contains('Sales')", content: 'Check the user sees Sales section', run: function () {}, }, { - trigger: ".o_rightpanel_data:eq(0):contains('Prepaid Hours')", + trigger: ".o_rightpanel_section[name='sales'] .o_rightpanel_data:contains('Prepaid Hours')", content: 'Check the user sees a line in the Sales section', + // timer: 300, run: function () {}, }, { - trigger: ".oe_button_box .o_stat_text:contains('Sales Orders')", + trigger: ".o_rightpanel_section .oe_button_box .o_stat_text:contains('Sales Orders')", content: 'Check the user sees Sales Orders Stat Button', run: function () {}, }, { - trigger: ".o_rightpanel_title:eq(1):contains('Profitability')", + trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_title:contains('Profitability')", content: 'Check the user sees Profitability section', run: function () {}, }, { - trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data > .o_rightpanel_subsection:eq(0) > table > thead > tr > th:eq(0):contains('Revenues')", + trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_data > .o_rightpanel_subsection:eq(0) > table > thead > tr > th:eq(0):contains('Revenues')", content: 'Check the user sees Profitability subsection row', run: function () {}, }, { - trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data > .o_rightpanel_subsection:eq(1) > table > thead > tr > th:eq(0):contains('Costs')", + trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_data > .o_rightpanel_subsection:eq(1) > table > thead > tr > th:eq(0):contains('Costs')", content: 'Check the user sees Profitability subsection row', run: function () {}, }, { - trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data > .o_rightpanel_subsection:eq(2) > table > thead > tr > th:eq(0):contains('Margin')", + trigger: ".o_rightpanel_section[name='profitability'] .o_rightpanel_data > .o_rightpanel_subsection:eq(2) > table > thead > tr > th:eq(0):contains('Margin')", content: 'Check the user sees Profitability subsection row', run: function () {}, }, { - trigger: ".o_rightpanel_section .o_rightpanel_title:contains('Milestones')", + trigger: ".o_rightpanel_section[name='milestones'] .o_rightpanel_title:contains('Milestones')", content: 'Check the user sees Milestones section', }, { trigger: ".o_add_milestone a", content: "Add a first milestone", }, { - trigger: "input.o_field_widget[name=name]", + trigger: "div.o_field_widget[name=name] input", content: "Edit new Milestone", run: 'text New milestone', }, { - trigger: "input.datetimepicker-input[name=deadline]", + trigger: "div[name=deadline] .datetimepicker-input", content: "Edit new Milestone", run: 'text 12/12/2099', }, { @@ -335,7 +276,7 @@ tour.register('sale_timesheet_tour', { trigger: ".o-kanban-button-new", content: "Create new Project Update", }, { - trigger: "input.o_field_widget[name=name]", + trigger: "div.o_field_widget[name=name] input", content: "Give a name to Project Update", run: 'text New update', }, { diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml index 35cfc569804..05e412c796e 100644 --- a/addons/sale_timesheet/views/hr_timesheet_views.xml +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -44,7 +44,7 @@ - + @@ -57,7 +57,7 @@ - + diff --git a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js index f638abdf48b..be005c507e2 100644 --- a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js +++ b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js @@ -406,7 +406,7 @@ tour.stepUtils.autoExpandMoreButtons('.o_form_readonly'), position: "left", }, { mobile: true, - trigger: '.o_field_widget[name=name]input', + trigger: '.o_field_widget[name=name] input', extra_trigger: ".modal:not(.o_inactive_modal) .modal-title:contains('Project')", content: _t('Let\'s enter the name.'), position: 'left', @@ -847,14 +847,14 @@ tour.stepUtils.mobileModifier(tour.stepUtils.autoExpandMoreButtons('.o_control_p content: _t('See Tasks'), position: 'bottom', }, { - trigger: '.o_field_widget[name=project_id]', + trigger: '.o_field_widget[name=project_id] a', content: _t('See Project'), position: 'right', }, tour.stepUtils.autoExpandMoreButtons('.o_control_panel .breadcrumb:contains("the_flow.project")'), { - trigger: '.oe_button_box .oe_stat_button:has(span:contains("Recorded"))', - extra_trigger: '.o_form_readonly', + trigger: '.oe_button_box button.oe_stat_button:has(span:contains("Recorded"))', + extra_trigger: '.o_form_readonly .oe_stat_button.dropdown > .dropdown-menu', content: _t('See Timesheets'), position: 'bottom', }, { @@ -923,6 +923,13 @@ tour.stepUtils.autoExpandMoreButtons('.o_control_panel .breadcrumb:contains("the content: _t("Select the the_flow.vendor"), position: "bottom", }, { + edition: 'community', + trigger: '.o_selected_row .o_field_widget[name=unit_amount] input', + content: _t('Set time'), + position: 'bottom', + run: 'text 10', +}, { + edition: 'enterprise', trigger: '.o_selected_row .o_field_widget[name=unit_amount]input', content: _t('Set time'), position: 'bottom',