[IMP] mail: add new field StatusBarDurationField

This commit implements a new widget in mail, `StatusBarDurationField`. Purpose:
seeing how long a record has spent in each stage in a form view.

It is implemented for records with a `many2one` status field and gets the data
from the `duration_tracking` field of the record. `duration_tracking` is computed
by adding the `mail.tracking.duration.mixin` to a model.

Time is shown using a luxon Duration object. "narrow" specifies that time is
expressed with one character and luxon takes care of translations.

Task-3032773

Part-of: odoo/odoo#108554
This commit is contained in:
miad-odoo
2023-07-18 13:07:41 +02:00
parent d31b3ecb84
commit eabc74b6fd
6 changed files with 257 additions and 2 deletions
@@ -0,0 +1,60 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { formatDuration } from "@web/views/fields/formatters";
import {
preloadStatusBar,
statusBarField,
StatusBarField,
} from "@web/views/fields/statusbar/statusbar_field";
export class StatusBarDurationField extends StatusBarField {
static template = "mail.StatusBarDurationField";
computeItems(grouped = true) {
const items = super.computeItems(grouped);
if (!grouped) {
return items;
}
if (this.props.record.data.duration_tracking) {
for (const property in items) {
this.setStatusbarDuration(items[property]);
}
}
return items;
}
setStatusbarDuration(stages) {
const durationTracking = this.props.record.data.duration_tracking;
if (!Object.keys(durationTracking).length) {
return;
}
for (const stage of stages) {
if (stage.id in durationTracking && durationTracking[stage.id] > 0) {
stage.shortTimeInStage = formatDuration(durationTracking[stage.id], false);
stage.fullTimeInStage = formatDuration(durationTracking[stage.id], true);
} else {
stage.shortTimeInStage = false;
}
}
}
}
export const statusBarDurationField = {
...statusBarField,
component: StatusBarDurationField,
displayName: _lt("Status with time"),
supportedTypes: ["many2one"],
fieldDependencies: [{ name: "duration_tracking", type: "JSON" }],
};
registry.category("fields").add("statusbar_duration", statusBarDurationField);
registry.category("preloadedData").add("statusbar_duration", {
loadOnTypes: ["many2one"],
extraMemoizationKey: (record, fieldName) => {
return record.data[fieldName];
},
preload: preloadStatusBar,
});
@@ -0,0 +1,3 @@
.o_field_statusbar_duration {
@extend .o_field_statusbar;
}
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.StatusBarDurationField" t-inherit="web.StatusBarField" t-inherit-mode="primary" owl="1">
<!-- Add time to the stage buttons -->
<xpath expr="//DropdownItem/t[1]" position="replace">
<span t-esc="item.name" />
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
<t t-esc="item.shortTimeInStage"/>
</span>
</xpath>
<xpath expr="//t[@t-if='item.isSelected']/button" position="attributes">
<attribute name="t-esc"/>
</xpath>
<xpath expr="//t[@t-if='item.isSelected']/button" position="inside">
<span t-esc="item.name"/>
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
<t t-esc="item.shortTimeInStage"/>
</span>
</xpath>
<xpath expr="//t[@t-elif='props.isDisabled']/button" position="attributes">
<attribute name="t-esc"/>
</xpath>
<xpath expr="//t[@t-elif='props.isDisabled']/button" position="inside">
<span t-esc="item.name"/>
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
<t t-esc="item.shortTimeInStage"/>
</span>
</xpath>
<xpath expr="//t[@t-else='']/button" position="attributes">
<attribute name="t-esc"/>
</xpath>
<xpath expr="//t[@t-else='']/button" position="inside">
<span t-esc="item.name"/>
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
<t t-esc="item.shortTimeInStage"/>
</span>
</xpath>
</t>
</templates>
@@ -0,0 +1,100 @@
/** @odoo-module **/
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
stage_id: { string: "Stage", type: "many2one", relation: "stage_model" },
duration_tracking: {
string: "Time per stage",
type: "char",
default: "{}",
},
},
records: [
{
id: 1,
display_name: "first record",
stage_id: 30,
// 7 days, 30 minutes - 3 hours - 2 days, 5 hours
duration_tracking: {
10: 7 * 24 * 60 * 60 + 30 * 60,
20: 3 * 60 * 60,
40: 24 * 2 * 60 * 60 + 5 * 60 * 60,
},
},
],
},
stage_model: {
fields: {
name: { string: "Stage Name", type: "char" },
},
records: [
{
id: 10,
display_name: "New",
},
{
id: 20,
display_name: "Qualified",
},
{
id: 30,
display_name: "Proposition",
},
{
id: 40,
display_name: "Won",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("StatusBarDurationField");
QUnit.test("StatusBarDurationField in a form view", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<header>
<field name="stage_id" widget="statusbar_duration" />
</header>
</form>`,
resId: 1,
});
// Check that time is put in the stages
const firstButton = target.querySelector("button[data-value='10'");
assert.strictEqual(firstButton.innerText, "New7d");
assert.strictEqual(firstButton.querySelectorAll("span")[1].title, "7 days, 30 minutes");
const secondButton = target.querySelector("button[data-value='20']");
assert.strictEqual(secondButton.innerText, "Qualified3h");
assert.strictEqual(secondButton.querySelectorAll("span")[1].title, "3 hours");
const thirdButton = target.querySelector("button[data-value='30']");
assert.strictEqual(thirdButton.innerText, "Proposition");
assert.strictEqual(thirdButton.title, "");
const fourthButton = target.querySelector("button[data-value='40']");
assert.strictEqual(fourthButton.innerText, "Won2d");
assert.strictEqual(fourthButton.querySelectorAll("span")[1].title, "2 days, 5 hours");
});
});
@@ -154,6 +154,51 @@ export function formatChar(value, options) {
return value;
}
/**
* Converts a given duration in seconds into a human-readable format.
*
* The function takes a duration in seconds and converts it into a human-readable form,
* such as "1h" or "1 hour, 30 minutes", depending on the value of the `showFullDuration` parameter.
* If the `showFullDuration` is set to true, the function will display up to two non-zero duration
* components in long form (e.g: hours, minutes).
* Otherwise, it will show just the largest non-zero duration component in narrow form (e.g: y or h).
* Luxon takes care of translations given the current locale.
*
* @param {number} seconds - The duration in seconds to be converted.
* @param {boolean} showFullDuration - If true, the output will have two components in long form.
* Otherwise, just one component will be displayed in narrow form.
*
* @returns {string} A human-readable string representation of the duration.
*
* @example
* // Sample usage
* const durationInSeconds = 7320; // 2 hours and 2 minutes (2 * 3600 + 2 * 60)
* const fullDuration = humanizeDuration(durationInSeconds, true);
* console.log(fullDuration); // Output: "2 hours, 2 minutes"
*
* const shortDuration = humanizeDuration(durationInSeconds, false);
* console.log(shortDuration); // Output: "2h"
*/
export function formatDuration(seconds, showFullDuration) {
const displayStyle = showFullDuration ? "long" : "narrow";
const numberOfValuesToDisplay = showFullDuration ? 2 : 1;
const durationKeys = ["years", "months", "days", "hours", "minutes"];
if (seconds < 60) {
seconds = 60;
}
seconds -= seconds % 60;
let duration = luxon.Duration.fromObject({ seconds: seconds }).shiftTo(...durationKeys);
duration = duration.shiftTo(...durationKeys.filter((key) => duration.get(key)));
const durationSplit = duration.toHuman({ unitDisplay: displayStyle }).split(",");
if (!showFullDuration && duration.loc.locale.includes("en") && duration.months > 0) {
durationSplit[0] = durationSplit[0].replace("m", "M");
}
return durationSplit.slice(0, numberOfValuesToDisplay).join(",");
}
/**
* Returns a string representing a float. The result takes into account the
* user settings (to display the correct decimal separator).
@@ -50,7 +50,8 @@
&.disabled {
opacity: 1;
pointer-events: none;
pointer-events: auto;
cursor: default;
&:not(.o_arrow_button_current) {
&, &:hover, &:focus {
@@ -76,4 +77,4 @@
}
}
}
}
}