diff --git a/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.js b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.js new file mode 100644 index 00000000000..262de420d7e --- /dev/null +++ b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.js @@ -0,0 +1,73 @@ +/** @odoo-module */ + +import { useService } from '@web/core/utils/hooks'; + +import { registry } from "@web/core/registry"; +import { Component } from '@odoo/owl'; +import { Record } from '@web/views/record'; +import { KanbanMany2ManyTagsAvatarUserField } from "@mail/views/fields/many2many_avatar_user_field/many2many_avatar_user_field"; +import { Field } from "@web/views/fields/field"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; + +export class SubtaskKanbanList extends Component { + + setup() { + this.actionService = useService("action"); + } + + async goToSubtask(subtask_id) { + return this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: this.props.record.resModel, + res_id: subtask_id, + views: [[false, "form"]], + target: "current", + context: { + active_id: subtask_id, + }, + }); + } + + get fieldsInfo() { + return { + child_ids: { + type: "one2many", + relation: "project.task", + fieldsToFetch: { + display_name: { type: "char" }, + kanban_state: { selection: [['normal', 'In Progress'], ['done', 'Ready'], ['blocked', 'Blocked']], + string: "Status", + type: "selection", + field: this.props.record.activeFields.kanban_state.field, + attrs: this.props.record.activeFields.kanban_state.attrs, + options: this.props.record.activeFields.kanban_state.options }, + legend_blocked: { type: "char" }, + legend_done: { type: "char" }, + legend_normal: { type: "char" }, + user_ids: { type: "many2many", + relation: "res.users", + field: this.props.record.activeFields.user_ids.field, + fieldsToFetch: this.props.record.activeFields.user_ids.fieldsToFetch, + attrs: this.props.record.activeFields.user_ids.attrs, + options: this.props.record.activeFields.user_ids.options }, + } + } + }; + } +} + +SubtaskKanbanList.components = { + Record, + Field, + KanbanMany2ManyTagsAvatarUserField, +}; +SubtaskKanbanList.props = { + ...standardWidgetProps, +}; +SubtaskKanbanList.template = 'project.SubtaskKanbanList'; +const subtaskKanbanList = { + component: SubtaskKanbanList, + fieldDependencies: [{ name: "child_ids", type: "one2many" }], +}; + +registry.category("view_widgets").add("subtask_kanban_list", subtaskKanbanList); diff --git a/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.scss b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.scss new file mode 100644 index 00000000000..9045e926181 --- /dev/null +++ b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.scss @@ -0,0 +1,21 @@ +.subtask_list { + margin-top: 6px; + + .subtask_list_row { + display: grid; + grid-template-columns: 20px auto auto; + column-gap: 4px; + padding: 4px 0px; + + .subtask_name_col { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + align-self: center; + } + } + + .subtask_list_row:hover { + background-color: mix($table-color, $table-bg, percentage($table-hover-bg-factor + 0.1)); + } +} diff --git a/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.xml b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.xml new file mode 100644 index 00000000000..8766d7414db --- /dev/null +++ b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + 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..f4f2ae0ff65 --- /dev/null +++ b/addons/project/static/src/components/subtask_one2many_field/subtask_one2many_field.js @@ -0,0 +1,20 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { X2ManyField, x2ManyField } from '@web/views/fields/x2many/x2many_field'; + +import { SubtaskListRenderer } from './subtask_list_renderer'; + +export class SubtaskOne2ManyField extends X2ManyField {} + +SubtaskOne2ManyField.components = { + ...X2ManyField.components, + ListRenderer: SubtaskListRenderer, +} + +export const subtaskOne2ManyField = { + ...x2ManyField, + component: SubtaskOne2ManyField, +} + +registry.category("fields").add("subtasks_one2many", subtaskOne2ManyField); diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_compiler.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_compiler.js new file mode 100644 index 00000000000..3421910afe0 --- /dev/null +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_compiler.js @@ -0,0 +1,68 @@ +/** @odoo-module **/ + +import { KanbanCompiler } from "@web/views/kanban/kanban_compiler"; +import { append, createElement } from "@web/core/utils/xml"; + +export class ProjectTaskKanbanCompiler extends KanbanCompiler { + setup() { + super.setup(); + this.subtaskListComponentCompiled = { + button: false, + component: false, + }; + this.compilers.push( + { selector: ".subtask_list_button", fn: this.compileSubtaskListButton }, + { selector: "div.kanban_bottom_subtasks_section", fn: this.compileSubtaskListComponent }, + ); + } + + /** + * @param {Element} el + * @returns {Element} + */ + compileSubtaskListButton(el) { + this.subtaskListComponentCompiled.button = true; + el.setAttribute("t-on-click", `() => __comp__.state.folded = !__comp__.state.folded`); + el.removeAttribute("class"); + el.setAttribute("t-attf-class", "subtask_list_button btn fa {{ state.folded ? 'fa-solid fa-angle-right' : 'fa-solid fa-angle-down' }}"); + const compiled = createElement(el.nodeName); + for (const { name, value } of el.attributes) { + compiled.setAttribute(name, value); + } + + return compiled; + } + + /** + * @param {Element} el + * @returns {Element} + */ + compileSubtaskListComponent(el) { + this.subtaskListComponentCompiled.component = true; + el.setAttribute("t-if", `!__comp__.state.folded and !selection_mode`); + const compiled = createElement(el.nodeName); + for (const { name, value } of el.attributes) { + compiled.setAttribute(name, value); + } + const listContainer = createElement('widget'); + const listElemenent = createElement('SubtaskKanbanList'); + listElemenent.setAttribute("record", '__comp__.props.record'); + + append(listContainer, listElemenent); + append(compiled, listContainer); + + return compiled; + } + + /** + * @override + */ + compile(key, params = {}) { + const newRoot = super.compile(key, params); + if (this.subtaskListComponentCompiled.component !== this.subtaskListComponentCompiled.button) { + // Error since one of them is not compiled + throw new Error("The subtask list component cannot be rendered if the button and the component are not in the view definition."); + } + return newRoot; + } +} 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 index e2302b885bd..7da3d446612 100644 --- 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 @@ -3,7 +3,28 @@ import { KanbanModel } from "@web/views/kanban/kanban_model"; import { ProjectTaskKanbanDynamicGroupList } from "./project_task_kanban_dynamic_group_list"; -import { ProjectTaskRecord } from './project_task_kanban_record'; +import { Record } from '@web/views/relational_model'; + +export class ProjectTaskRecord extends Record { + async _applyChanges(changes) { + const value = changes.personal_stage_type_ids; + if (Array.isArray(value)) { + delete changes.personal_stage_type_ids; + changes.personal_stage_type_id = value; + } + await super._applyChanges(changes); + } + + get context() { + const context = super.context; + const value = context.default_personal_stage_type_ids; + if (Array.isArray(value)) { + context.default_personal_stage_type_id = value[0]; + delete context.default_personal_stage_type_ids; + } + return context; + } +} export class ProjectTaskKanbanGroup extends KanbanModel.Group { get isPersonalStageGroup() { 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 index d1c13c41532..25579a2ce0f 100644 --- 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 @@ -1,24 +1,28 @@ /* @odoo-module */ -import { Record } from '@web/views/relational_model'; +import { KanbanRecord } from "@web/views/kanban/kanban_record"; +import { useState } from "@odoo/owl"; +import { ProjectTaskKanbanCompiler } from "./project_task_kanban_compiler"; +import { SubtaskKanbanList } from "@project/components/subtask_kanban_list/subtask_kanban_list" -export class ProjectTaskRecord extends Record { - async _applyChanges(changes) { - const value = changes.personal_stage_type_ids; - if (value && Array.isArray(value)) { - delete changes.personal_stage_type_ids; - changes.personal_stage_type_id = value; - } - await super._applyChanges(changes); +export class ProjectTaskKanbanRecord extends KanbanRecord { + setup() { + super.setup(); + this.state = useState({folded: true}); } - get context() { - const context = super.context; - const value = context.default_personal_stage_type_ids; - if (value && Array.isArray(value)) { - context.default_personal_stage_type_id = value[0]; - delete context.default_personal_stage_type_ids; - } + /** + * @override + */ + get renderingContext() { + const context = super.renderingContext; + context["state"] = this.state; return context; } } + +ProjectTaskKanbanRecord.Compiler = ProjectTaskKanbanCompiler; +ProjectTaskKanbanRecord.components = { + ...KanbanRecord.components, + SubtaskKanbanList, +}; 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 index 9e0a9f898db..63a00aaa267 100644 --- 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 @@ -3,6 +3,7 @@ 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"; +import { ProjectTaskKanbanRecord } from './project_task_kanban_record'; const { onWillStart } = owl; @@ -89,3 +90,8 @@ export class ProjectTaskKanbanRenderer extends KanbanRenderer { }); } } + +ProjectTaskKanbanRenderer.components = { + ...KanbanRenderer.components, + KanbanRecord: ProjectTaskKanbanRecord, +}; diff --git a/addons/project/static/tests/project_subtask_kanban_list_tests.js b/addons/project/static/tests/project_subtask_kanban_list_tests.js new file mode 100644 index 00000000000..5f28a193372 --- /dev/null +++ b/addons/project/static/tests/project_subtask_kanban_list_tests.js @@ -0,0 +1,78 @@ +/** @odoo-module */ +import { + click, + getFixture, +} from '@web/../tests/helpers/utils'; +import { setupViewRegistries } from "@web/../tests/views/helpers"; +import { start, startServer } from '@mail/../tests/helpers/test_utils'; +import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers'; + +addModelNamesToFetch([ + 'project.project', + 'project.task', +]); + +let target; + +QUnit.module('Subtask Kanban List tests', { + beforeEach: async function () { + const pyEnv = await startServer(); + const projectId = pyEnv['project.project'].create([ + { name: "Project One", allow_subtasks: true }, + ]); + const userId = pyEnv['res.users'].create([ + { name: "User One", login: 'one', password: 'one' }, + ]); + pyEnv['project.task'].create([ + { name: 'task one', project_id: projectId, subtask_count: 2, child_ids: [2, 3], kanban_state: 'normal', user_ids: [userId] }, + { name: 'task two', subtask_count: 0, child_ids: [], kanban_state: 'done' }, + { name: 'task three', subtask_count: 0, child_ids: [], kanban_state: 'blocked' }, + { name: 'task four', project_id: projectId, subtask_count: 0, child_ids: [], kanban_state: 'normal' }, + ]); + this.views = { + "project.task,false,kanban": + ` + + + +
+ + + + +
+
+ + + `, + }; + target = getFixture(); + setupViewRegistries(); + } +}, function () { + QUnit.test("Check whether subtask list functionality works as intended", async function (assert) { + assert.expect(8); + + const views = this.views; + const { openView } = await start({ serverData: { views } }); + await openView({ + res_model: "project.task", + views: [[false, "kanban"]], + }); + + assert.containsOnce(target, '.subtask_list_button', "Only kanban boxes of parent tasks should have the drawdown button, in this case this is 1"); + assert.containsNone(target, '.subtask_list', "If the drawdown button is not clicked, the subtasks list should be hidden"); + + await click(target, '.subtask_list_button'); + + assert.containsOnce(target, '.subtask_list', "Clicking on the button should make the subtask list render, in this case we are expectig 1 list"); + assert.containsN(target, '.subtask_list_row', 2, "The list rendered should show all the subtasks of the task, in this case 2"); + assert.containsN(target, '.subtask_state_widget_col', 2, "Each of the list's rows should have 1 state widget, thus we are looking for 2 in total"); + assert.containsN(target, '.subtask_user_widget_col', 2, "Each of the list's rows should have 1 user widgets, thus we are looking for 2 in total"); + assert.containsN(target, '.subtask_name_col', 2, "Each of the list's rows should display the subtask's name, thus we are looking for 2 in total"); + + await click(target, '.subtask_list_button'); + + assert.containsNone(target, '.subtask_list', "If the drawdown button is clicked again, the subtasks list should be hidden again"); + }); +}); diff --git a/addons/project/tests/test_project_subtasks.py b/addons/project/tests/test_project_subtasks.py index 109a2c5fc9d..b8510c97e25 100644 --- a/addons/project/tests/test_project_subtasks.py +++ b/addons/project/tests/test_project_subtasks.py @@ -108,25 +108,25 @@ class TestProjectSubtasks(TestProjectCommon): - The display project id should follow the project id """ # 1) - test_subtask_1 = self.env['project.task'].create({ - 'name': 'Test Subtask 1', - }) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids.add(test_subtask_1) + with task_form.child_ids.new() as child_task_form: + child_task_form.name = 'Test Subtask 1' self.assertEqual(self.task_1.child_ids.project_id, self.project_pigs, "The project should be assigned from the default project.") self.assertFalse(self.task_1.child_ids.display_project_id, "The display project of a sub task should be false to project_id.") # 2) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids[0].display_project_id = self.project_goats + with task_form.child_ids.edit(0) as child_task_form: + child_task_form.display_project_id = self.project_goats self.assertEqual(self.task_1.project_id, self.project_pigs, "Changing the project of a subtask should not change parent project") self.assertEqual(self.task_1.child_ids.display_project_id, self.project_goats, "Display Project of the task should be well assigned") self.assertEqual(self.task_1.child_ids.project_id, self.project_goats, "Changing display project id on a subtask should change project id") # 3) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids[0].display_project_id = self.env['project.project'] + with task_form.child_ids.edit(0) as child_task_form: + child_task_form.display_project_id = self.env['project.project'] self.assertFalse(self.task_1.child_ids.display_project_id, "Display Project of the task should be well assigned, to False") self.assertEqual(self.task_1.child_ids.project_id, self.project_pigs, "Resetting display project to False on a subtask should change project id to parent project id") @@ -141,7 +141,8 @@ class TestProjectSubtasks(TestProjectCommon): # 5) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids[0].display_project_id = self.project_goats + with task_form.child_ids.edit(0) as child_task_form: + child_task_form.display_project_id = self.project_goats task_form.project_id = self.project_pigs self.assertEqual(self.task_1.project_id, self.project_pigs, "Parent project should change back.") @@ -160,12 +161,10 @@ class TestProjectSubtasks(TestProjectCommon): self.assertFalse(orphan_subtask.parent_id, "Parent should be false") # 7) - test_subtask_1 = self.env['project.task'].create({ - 'name': 'Test Subtask 1', - }) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids.add(test_subtask_1) - task_form.child_ids[0].display_project_id = self.project_goats + with task_form.child_ids.new() as child_task_form: + child_task_form.name = 'Test Subtask 1' + child_task_form.display_project_id = self.project_goats with Form(self.task_1.child_ids.with_context({'tracking_disable': True})) as subtask_form: subtask_form.display_project_id = self.env['project.project'] subtask_form.parent_id = self.env['project.task'] @@ -183,11 +182,9 @@ class TestProjectSubtasks(TestProjectCommon): self.project_pigs.type_ids |= stage_a self.project_pigs.type_ids |= stage_b - test_subtask_1 = self.env['project.task'].create({ - 'name': 'Test Subtask 1', - }) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids.add(test_subtask_1) + with task_form.child_ids.new() as child_task_form: + child_task_form.name = 'Test Subtask 1' self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should be the default one of the project.") @@ -196,17 +193,16 @@ class TestProjectSubtasks(TestProjectCommon): self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should remain the same while changing parent task stage.") - test_subtask_2 = self.env['project.task'].create({ - 'name': 'Test Subtask 2', - }) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids.remove(test_subtask_1.id) - task_form.child_ids.add(test_subtask_2) + task_form.child_ids.remove(index=0) + with task_form.child_ids.new() as child_task_form: + child_task_form.name = 'Test Subtask 2' self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "The stage of the child task should be the default one of the project even if parent stage id is different.") with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids[0].display_project_id = self.project_goats + with task_form.child_ids.edit(0) as child_task_form: + child_task_form.display_project_id = self.project_goats self.assertEqual(self.task_1.child_ids.stage_id.name, "New", "The stage of the child task should be the default one of the display project id, once set.") @@ -245,23 +241,19 @@ class TestProjectSubtasks(TestProjectCommon): 4) check the correct nb of sub-tasks is displayed in the 'sub-tasks' stat button and on the parent task kanban card 5) sub-tasks should be copied when the parent task is duplicated """ - test_subtask_1 = self.env['project.task'].create({ - 'name': 'Test Subtask 1', - }) task_form = Form(self.task_1.with_context({'tracking_disable': True})) - task_form.child_ids.add(test_subtask_1) - task_form.child_ids[0].display_project_id = self.env['project.project'] + with task_form.child_ids.new() as child_task_form: + child_task_form.name = 'Test Subtask 1' + child_task_form.display_project_id = self.env['project.project'] task = task_form.save() child_subtask = self.task_1.child_ids[0] - test_subtask_2 = self.env['project.task'].create({ - 'name': 'Test Subtask 2', - }) with Form(child_subtask.with_context(tracking_disable=True)) as subtask_form: - subtask_form.child_ids.add(test_subtask_2) - subtask_form.child_ids[0].display_project_id = self.env['project.project'] + with subtask_form.child_ids.new() as child_subtask_form: + child_subtask_form.name = 'Test Subtask 2' + child_subtask_form.display_project_id = self.env['project.project'] self.assertEqual(task.subtask_count, 2, "Parent task should have 2 children") task_2 = task.copy() diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 2fe71676061..0e83d731138 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -1191,10 +1191,9 @@ + context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id, 'default_user_ids': user_ids, 'default_parent_id': id, + 'default_partner_id': partner_id, 'default_milestone_id': allow_milestones and milestone_id}" + widget="subtasks_one2many"> @@ -1417,6 +1416,7 @@ + @@ -1464,12 +1464,14 @@ +
+