[IMP] project: improve the list view by adding personal stage

This will improve the following generic UX

-  rename 'personal user stage' into 'personal stage' for private task
- added the 'personal stage' field to the right of the stage optional=hide
- earlier gantt view open with the user now it will open with groupby  project
- removed groupby stage in pivot view
- rename 'undefined' into 'private' for graph view of private task

task-3251648

closes odoo/odoo#120094

Related: odoo/upgrade#4607
Related: odoo/enterprise#40515
Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
This commit is contained in:
Pratik Awasthi
2023-08-17 19:50:59 +02:00
committed by Xavier Bol (xbo)
parent 6a60b7df35
commit 496c0a275a
11 changed files with 342 additions and 22 deletions
@@ -1,13 +1,13 @@
/** @odoo-module **/
import { GraphModel } from "@web/views/graph/graph_model";
import { ProjectTaskGraphModel } from "@project/views/project_task_graph/project_task_graph_model";
const FIELDS = [
'unit_amount', 'effective_hours', 'planned_hours', 'remaining_hours', 'total_hours_spent', 'subtask_effective_hours',
'overtime', 'number_hours', 'difference', 'timesheet_unit_amount'
];
export class hrTimesheetGraphModel extends GraphModel {
export class hrTimesheetGraphModel extends ProjectTaskGraphModel {
/**
* @override
*/
@@ -36,4 +36,4 @@ export class hrTimesheetGraphModel extends GraphModel {
return super._getProcessedDataPoints(...arguments);
}
}
hrTimesheetGraphModel.services = [...GraphModel.services, "company"];
hrTimesheetGraphModel.services = [...ProjectTaskGraphModel.services, "company"];
@@ -1,13 +1,13 @@
/** @odoo-module **/
import { projectGraphView } from "@project/views/project_task_graph/project_graph_view";
import { projectTaskGraphView } from "@project/views/project_task_graph/project_task_graph_view";
import { hrTimesheetGraphModel } from "./timesheet_graph_model";
import { registry } from "@web/core/registry";
const viewRegistry = registry.category("views");
export const hrTimesheetGraphView = {
...projectGraphView,
...projectTaskGraphView,
Model: hrTimesheetGraphModel,
};
@@ -1,9 +1,14 @@
/** @odoo-module **/
import { companyService } from "@web/webclient/company_service";
import { getGraphRenderer } from "@web/../tests/views/graph_view_tests";
import {
checkLabels,
checkLegend,
getGraphRenderer,
selectMode
} from "@web/../tests/views/graph_view_tests";
import { makeView } from "@web/../tests/views/helpers";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { session } from "@web/session";
import { registry } from "@web/core/registry";
import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
@@ -11,16 +16,65 @@ import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
const serviceRegistry = registry.category("services");
QUnit.module('hr_timesheet', function (hooks) {
let serverData;
let serverData, target;
hooks.beforeEach(() => {
serverData = {
models: {
'account.analytic.line': {
fields: {
unit_amount: { string: "Unit Amount", type: "float", group_operator: "sum", store: true },
project_id: {
string: "Project",
type: "many2one",
relation: "project.project",
store: true,
sortable: true,
},
},
records: [
{ id: 1, unit_amount: 8 }
{ id: 1, unit_amount: 8, project_id: false },
],
},
"project.task": {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "Name", type: "char" },
milestone_id: {
string: "Milestone",
type: "many2one",
relation: "project.milestone",
store: true,
sortable: true,
},
project_id: {
string: "Project",
type: "many2one",
relation: "project.project",
store: true,
sortable: true,
},
},
records: [
{ id: 1, name: "Task 1", project_id: 1, milestone_id: 1 },
{ id: 2, name: "Task 2", project_id: false, milestone_id: false },
],
},
"project.milestone": {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "Name", "type": "char" },
},
records: [
{ id: 1, name: "Milestone 1" },
]
},
"project.project": {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "Name", "type": "char" },
},
records: [
{ id: 1, name: "Project 1" },
],
},
},
@@ -35,6 +89,7 @@ QUnit.module('hr_timesheet', function (hooks) {
}
}
setupControlPanelServiceRegistry();
target = getFixture();
serviceRegistry.add("company", companyService, { force: true });
});
@@ -73,4 +128,63 @@ QUnit.module('hr_timesheet', function (hooks) {
const renderedData = getGraphRenderer(graph).chart.data.datasets[0].data;
assert.deepEqual(renderedData, [1], 'The timesheet graph view is taking the timesheet_uom_factor into account (factor !== 1)');
});
QUnit.test("check custom default label", async function (assert) {
const graph = await makeView({
serverData,
type: "graph",
resModel: "project.task",
arch: `
<graph js_class="hr_timesheet_graphview">
<field name="project_id"/>
</graph>
`,
});
checkLabels(assert, graph, ["🔒 Private", "Project 1"]);
checkLegend(assert, graph, ["Count"]);
await selectMode(target, "line");
checkLabels(assert, graph, ["", "Project 1", ""]);
checkLegend(assert, graph, ["Count"]);
await selectMode(target, "pie");
checkLabels(assert, graph, ["🔒 Private", "Project 1"]);
checkLegend(assert, graph, ["🔒 Private", "Project 1"]);
});
QUnit.test("check default label with 2 fields in groupby", async function (assert) {
const graph = await makeView({
serverData,
type: "graph",
resModel: "project.task",
arch: `
<graph js_class="hr_timesheet_graphview">
<field name="project_id"/>
<field name="milestone_id"/>
</graph>
`,
});
checkLabels(assert, graph, ["🔒 Private", "Project 1"]);
checkLegend(assert, graph, ["Undefined", "Milestone 1", "Sum"]);
await selectMode(target, "line");
checkLabels(assert, graph, ["", "Project 1", ""]);
checkLegend(assert, graph, ["Milestone 1"]);
await selectMode(target, "pie");
checkLabels(assert, graph, [
"🔒 Private / Undefined",
"Project 1 / Milestone 1"
]);
checkLegend(assert, graph, [
"🔒 Private / Undefined",
"Project 1 / Milestone 1"
]);
});
});
@@ -243,7 +243,7 @@
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_project_task_pivot"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='stage_id']" position='after'>
<xpath expr="//field[@name='planned_hours']" position='after'>
<field name="planned_hours" widget="timesheet_uom"/>
<field name="remaining_hours" widget="timesheet_uom"/>
<field name="effective_hours" widget="timesheet_uom"/>
+3 -3
View File
@@ -167,17 +167,17 @@ class Task(models.Model):
# See project_task_stage_personal.py for the model defininition
personal_stage_type_ids = fields.Many2many('project.task.type', 'project_task_user_rel', column1='task_id', column2='stage_id',
ondelete='restrict', group_expand='_read_group_personal_stage_type_ids', copy=False,
domain="[('user_id', '=', user.id)]", depends=['user_ids'], string='Personal Stage')
domain="[('user_id', '=', user.id)]", depends=['user_ids'], string='Personal Stages')
# Personal Stage computed from the user
personal_stage_id = fields.Many2one('project.task.stage.personal', string='Personal Stage State', compute_sudo=False,
compute='_compute_personal_stage_id', help="The current user's personal stage.")
# This field is actually a related field on personal_stage_id.stage_id
# However due to the fact that personal_stage_id is computed, the orm throws out errors
# saying the field cannot be searched.
personal_stage_type_id = fields.Many2one('project.task.type', string='Personal User Stage',
personal_stage_type_id = fields.Many2one('project.task.type', string='Personal Stage',
compute='_compute_personal_stage_type_id', inverse='_inverse_personal_stage_type_id', store=False,
search='_search_personal_stage_type_id', default=_default_personal_stage_type_id,
help="The current user's personal task stage.")
help="The current user's personal task stage.", domain="[('user_id', '=', uid)]")
partner_id = fields.Many2one('res.partner',
string='Customer', recursive=True, tracking=True, compute='_compute_partner_id', store=True, readonly=False,
domain="['|', ('company_id', '=?', company_id), ('company_id', '=', False)]", )
@@ -0,0 +1,12 @@
/** @odoo-module **/
import { GraphModel } from "@web/views/graph/graph_model";
export class ProjectTaskGraphModel extends GraphModel {
_getDefaultFilterLabel(field) {
if (field.fieldName === "project_id") {
return this.env._t("🔒 Private");
}
return super._getDefaultFilterLabel(field);
}
}
@@ -3,9 +3,14 @@
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";
import { ProjectTaskGraphModel } from "./project_task_graph_model";
const viewRegistry = registry.category("views");
export const projectGraphView = {...graphView, ControlPanel: ProjectControlPanel};
export const projectTaskGraphView = {
...graphView,
ControlPanel: ProjectControlPanel,
Model: ProjectTaskGraphModel,
};
viewRegistry.add("project_graph", projectGraphView);
viewRegistry.add("project_task_graph", projectTaskGraphView);
@@ -0,0 +1,127 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { fakeCookieService } from "@web/../tests/helpers/mock_services";
import { getFixture } from "@web/../tests/helpers/utils";
import { checkLabels, checkLegend, selectMode } from "@web/../tests/views/graph_view_tests";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData, target;
QUnit.module("Project", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
"project.task": {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "Name", type: "char" },
milestone_id: {
string: "Milestone",
type: "many2one",
relation: "project.milestone",
store: true,
sortable: true,
},
project_id: {
string: "Project",
type: "many2one",
relation: "project.project",
store: true,
sortable: true,
},
},
records: [
{ id: 1, name: "Task 1", project_id: 1, milestone_id: 1 },
{ id: 2, name: "Task 2", project_id: false, milestone_id: false },
],
},
"project.milestone": {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "Name", "type": "char" },
},
records: [
{ id: 1, name: "Milestone 1" },
]
},
"project.project": {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "Name", "type": "char" },
},
records: [
{ id: 1, name: "Project 1" },
],
},
},
views: {},
};
setupViewRegistries();
target = getFixture();
registry.category("services").add("cookie", fakeCookieService);
});
QUnit.module("ProjectTaskGraphView");
QUnit.test("check custom default label", async function (assert) {
const graph = await makeView({
serverData,
type: "graph",
resModel: "project.task",
arch: `
<graph js_class="project_task_graph">
<field name="project_id"/>
</graph>
`,
});
checkLabels(assert, graph, ["🔒 Private", "Project 1"]);
checkLegend(assert, graph, ["Count"]);
await selectMode(target, "line");
checkLabels(assert, graph, ["", "Project 1", ""]);
checkLegend(assert, graph, ["Count"]);
await selectMode(target, "pie");
checkLabels(assert, graph, ["🔒 Private", "Project 1"]);
checkLegend(assert, graph, ["🔒 Private", "Project 1"]);
});
QUnit.test("check default label with 2 fields in groupby", async function (assert) {
const graph = await makeView({
serverData,
type: "graph",
resModel: "project.task",
arch: `
<graph js_class="project_task_graph">
<field name="project_id"/>
<field name="milestone_id"/>
</graph>
`,
});
checkLabels(assert, graph, ["🔒 Private", "Project 1"]);
checkLegend(assert, graph, ["Undefined", "Milestone 1", "Sum"]);
await selectMode(target, "line");
checkLabels(assert, graph, ["", "Project 1", ""]);
checkLegend(assert, graph, ["Milestone 1"]);
await selectMode(target, "pie");
checkLabels(assert, graph, [
"🔒 Private / Undefined",
"Project 1 / Milestone 1"
]);
checkLegend(assert, graph, [
"🔒 Private / Undefined",
"Project 1 / Milestone 1"
]);
});
});
+10 -2
View File
@@ -119,7 +119,6 @@
<field name="arch" type="xml">
<pivot string="Tasks" sample="1" js_class="project_pivot">
<field name="project_id" type="row"/>
<field name="stage_id" type="col"/>
<field name="color" invisible="1"/>
<field name="sequence" invisible="1"/>
<field name="planned_hours" widget="float_time"/>
@@ -138,6 +137,12 @@
<xpath expr="/pivot" position="inside">
<field name="user_ids" type="row"/>
</xpath>
<xpath expr="//field[@name='project_id']" position="after">
<field name="stage_id" type="col"/>
</xpath>
<xpath expr="//field[@name='project_id']" position="attributes">
<attribute name="invisible">True</attribute>
</xpath>
</field>
</record>
@@ -683,6 +688,9 @@
<xpath expr="//field[@name='partner_id']" position="after">
<field name="parent_id" optional="hide" context="{'search_view_ref': 'project.view_task_search_form', 'search_default_project_id': project_id}" groups="base.group_no_one"/>
</xpath>
<xpath expr="//field[@name='stage_id']" position="after">
<field name="personal_stage_type_id" string="Personal Stage" optional="hide"/>
</xpath>
</field>
</record>
@@ -746,7 +754,7 @@
<field name="name">project.task.graph</field>
<field name="model">project.task</field>
<field name="arch" type="xml">
<graph string="Tasks" sample="1" js_class="project_graph">
<graph string="Tasks" sample="1" js_class="project_task_graph">
<field name="project_id" invisible="context.get('default_project_id', False)"/>
<field name="stage_id"/>
<field name="color" invisible="1"/>
@@ -373,6 +373,14 @@ export class GraphModel extends Model {
return new DateClasses(arrays);
}
/**
* @protected
* @returns {string}
*/
_getDefaultFilterLabel(field) {
return this.env._t("Undefined");
}
/**
* Eventually filters and sort data points.
* @protected
@@ -383,7 +391,7 @@ export class GraphModel extends Model {
let processedDataPoints = [];
if (mode === "line") {
processedDataPoints = this.dataPoints.filter(
(dataPoint) => dataPoint.labels[0] !== this.env._t("Undefined")
(dataPoint) => dataPoint.labels[0] !== this._getDefaultFilterLabel(groupBy[0]),
);
} else if (mode === "pie") {
processedDataPoints = this.dataPoints.filter(
@@ -505,7 +513,7 @@ export class GraphModel extends Model {
if (type === "boolean") {
label = `${val}`; // toUpperCase?
} else if (val === false) {
label = this.env._t("Undefined");
label = this._getDefaultFilterLabel(gb);
} else if (["many2many", "many2one"].includes(type)) {
const [id, name] = val;
const key = JSON.stringify([fieldName, name]);
@@ -65,12 +65,12 @@ function checkDatasets(assert, graph, keys, expectedDatasets) {
assert.deepEqual(actualValues, expectedDatasets);
}
function checkLabels(assert, graph, expectedLabels) {
export function checkLabels(assert, graph, expectedLabels) {
const labels = getChart(graph).data.labels.map((l) => l.toString());
assert.deepEqual(labels, expectedLabels);
}
function checkLegend(assert, graph, expectedLegendLabels) {
export function checkLegend(assert, graph, expectedLegendLabels) {
expectedLegendLabels =
expectedLegendLabels instanceof Array ? expectedLegendLabels : [expectedLegendLabels];
const chart = getChart(graph);
@@ -124,7 +124,7 @@ function getModeButton(el, mode) {
return el.querySelector(`.o_graph_button[data-mode="${mode}"`);
}
async function selectMode(el, mode) {
export async function selectMode(el, mode) {
await click(getModeButton(el, mode));
}
@@ -4239,4 +4239,50 @@ QUnit.module("Views", (hooks) => {
await validateSearch(target);
assert.verifySteps(["rendering"]);
});
QUnit.test("apply default filter label", async function (assert) {
const graphView = registry.category("views").get("graph");
class CustomGraphModel extends graphView.Model {
_getDefaultFilterLabel(fields) {
return "None";
}
}
registry.category("views").add("custom_graph", {
...graphView,
Model: CustomGraphModel,
});
const graph = await makeView({
serverData,
type: "graph",
resModel: "foo",
arch: `
<graph js_class="custom_graph">
<field name="product_id"/>
<field name="color_id"/>
</graph>
`,
});
checkLabels(assert, graph, ["xphone", "xpad"]);
checkLegend(assert, graph, ["None", "red", "Sum"]);
await selectMode(target, "line");
checkLabels(assert, graph, ["xphone", "xpad"]);
checkLegend(assert, graph, ["None", "red"]);
await selectMode(target, "pie");
checkLabels(assert, graph, [
"xphone / None",
"xphone / red",
"xpad / None",
]);
checkLegend(assert, graph, [
"xphone / None",
"xphone / red",
"xpad / None",
]);
});
});