[IMP] project: change the way we work with subtasks

This commit changes some of the way that users will
work with subtasks in the project app.
Initially, it changes subtasks from a many2many to
a one2many field.
We also add the ability for subtasks to be viewed
straight from the kanban view by drawing a list of
them inside of the kanban box of the parent task.

task-3085016

closes odoo/odoo#112279

Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
This commit is contained in:
Panagiotis Kyriakou
2023-03-01 17:00:56 +01:00
parent ee00f84c21
commit 80e9dc746a
12 changed files with 391 additions and 53 deletions
@@ -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);
@@ -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));
}
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="project.SubtaskKanbanList" owl="1">
<Record resModel="'project.task'" resId="props.record.resId" fields="fieldsInfo" activeFields="fieldsInfo" t-slot-scope="data">
<div class="subtask_list">
<t t-foreach="data.record.data.child_ids.records" t-as="subTask" t-key="subTask.resId">
<div class="subtask_list_row">
<Field name="'kanban_state'"
class="`subtask_state_widget_col d-flex justify-content-center align-items-center`"
record="subTask"
fieldInfo="subTask.fields.kanban_state"
type="'state_selection'"/>
<a class="subtask_name_col"
t-att-title="subTask.data.display_name"
style="color: inherit;"
t-esc="subTask.data.display_name"
t-on-click.prevent="() => this.goToSubtask(subTask.resId)"/>
<Field value="subTask.data.user_ids"
class="`subtask_user_widget_col justify-content-end align-items-center`"
name="'user_ids'"
record="subTask"
fieldInfo="subTask.fields.user_ids"
readonly="true"
type="'many2many_avatar_user'"/>
</div>
</t>
</div>
</Record>
</t>
</templates>
@@ -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: () => {},
});
}
}
@@ -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);
@@ -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;
}
}
@@ -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() {
@@ -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,
};
@@ -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,
};
@@ -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":
`<kanban js_class="project_task_kanban">
<field name="subtask_count"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="name" widget="name_with_subtask_count"/>
<field name="user_ids" invisible="1"/>
<field name="kanban_state" invisible="1"/>
<a t-if="record.subtask_count.raw_value" class="subtask_list_button" title="See Subtasks" style="width: 29px;"/>
</div>
<div class="kanban_bottom_subtasks_section"/>
</t>
</templates>
</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");
});
});
+24 -32
View File
@@ -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()
+6 -4
View File
@@ -1191,10 +1191,9 @@
</page>
<page name="sub_tasks_page" string="Sub-tasks" attrs="{'invisible': [('allow_subtasks', '=', False)]}">
<field name="child_ids"
context="{'search_view_ref' : 'project.view_task_search_form', '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, 'search_default_display_project_id': project_id, 'tree_view_ref': 'project.open_view_all_tasks_list_view'}"
widget="many2many"
domain="['!', ('id', 'parent_of', id), ('recurring_task', '=', False)]">
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">
<tree editable="bottom" decoration-muted="is_closed == True">
<field name="legend_normal" invisible="1"/>
<field name="legend_done" invisible="1"/>
@@ -1417,6 +1416,7 @@
<field name="has_late_and_unreached_milestone" />
<field name="allow_milestones" />
<field name="recurrence_id" />
<field name="subtask_count"/>
<progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger", "normal": "200"}'/>
<templates>
<t t-name="kanban-menu" t-if="!selection_mode" groups="base.group_user">
@@ -1464,12 +1464,14 @@
<span style="font-weight:bold;" class="fa fa-fw mt4 fa-meh-o text-warning" t-elif="record.rating_avg.raw_value &gt;= 2.33" title="Average Rating: Okay" role="img" aria-label="Neutral face"/>
<span style="font-weight:bold;" class="fa fa-fw mt4 fa-frown-o text-danger" t-else="" title="Average Rating: Dissatisfied" role="img" aria-label="Sad face"/>
</b>
<a t-if="record.subtask_count.raw_value and record.allow_subtasks.raw_value" class="subtask_list_button" title="See Subtasks" style="padding: 0px 5px 0px 0px; box-sizing: border-box; font-size: 20px;"/>
</div>
<div class="oe_kanban_bottom_right" t-if="!selection_mode">
<field name="kanban_state" widget="state_selection" groups="base.group_user"/>
<t t-if="record.user_ids.raw_value"><field name="user_ids" widget="many2many_avatar_user"/></t>
</div>
</div>
<div class="kanban_bottom_subtasks_section"/>
</div>
<div class="clearfix"></div>
</div>