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 @@
+
+