[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:
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user