[REF] web, crm: adapt Kanban progressbar to be a component

This commit moves some parts of the code used by the progress bar of kanban
to its own component. It is now clearer to distinguished its code from the
renderer code (see crm_kanban_renderer). Also, it allows to re-use this
component in other views without making a direct use of 'kanban' elements.

The KanbanAnimatedNumber component has simply be renamed to AnimatedNumber,
as this naming suggested the element was only used as a Kanban element. This
more generic name now indicates that the element can be imported and used in
other places.

Part-of: odoo/odoo#107916
Co-authored-by: Florent Dardenne <dafl@odoo.com>
This commit is contained in:
luvi
2022-12-23 16:07:05 +01:00
co-authored by Florent Dardenne
parent 29fc7bdc4d
commit 28927c2eeb
16 changed files with 188 additions and 156 deletions
@@ -0,0 +1,36 @@
/** @odoo-module */
import { onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { ColumnProgress } from "@web/views/view_components/column_progress";
import { session } from "@web/session";
export class CrmColumnProgress extends ColumnProgress {
static props = {
...ColumnProgress.props,
progressAttributes: true,
};
static template = "crm.ColumnProgress";
setup() {
super.setup();
this.user = useService("user");
this.showRecurringRevenue = false;
onWillStart(async () => {
if (this.props.progressAttributes.recurring_revenue_sum_field) {
this.showRecurringRevenue = await this.user.hasGroup("crm.group_use_recurring_revenues");
}
});
}
getRecurringRevenueGroupAggregate(group) {
const rrField = this.props.progressAttributes.recurring_revenue_sum_field;
const value = group.getAggregates(rrField.name);
const title = rrField.string || this.env._t("Count");
let currency = false;
if (value && rrField.currency_field) {
currency = session.currencies[session.company_currency_id];
}
return { value, currency, title };
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="crm.ColumnProgress" t-inherit="web.ColumnProgress" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_column_progress')]" position="attributes">
<attribute name="class" remove="w-75" add="w-50" separator=" "/>
</xpath>
<AnimatedNumber position="after">
<t t-if="showRecurringRevenue">
<t t-set="rrmAggregate" t-value="getRecurringRevenueGroupAggregate(props.group)"/>
<AnimatedNumber
value="rrmAggregate.value"
title="rrmAggregate.title"
duration="1000"
currency="props.aggregate.currency"
animationClass="'o_animated_grow_huge'"
>
<t t-set-slot="prefix">
<strong>+</strong>
</t>
</AnimatedNumber>
</t>
</AnimatedNumber>
</t>
</templates>
@@ -1,33 +1,11 @@
/** @odoo-module **/
import { CrmColumnProgress } from "./crm_column_progress";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { session } from "@web/session";
import { useService } from "@web/core/utils/hooks";
const { onWillStart } = owl;
export class CrmKanbanRenderer extends KanbanRenderer {
setup() {
super.setup();
this.user = useService("user");
this.showRecurringRevenue = false;
onWillStart(async () => {
this.showRecurringRevenue =
this.props.list.model.progressAttributes.recurring_revenue_sum_field &&
(await this.user.hasGroup("crm.group_use_recurring_revenues"));
});
}
getRecurringRevenueGroupAggregate(group) {
const rrField = this.props.list.model.progressAttributes.recurring_revenue_sum_field;
const value = group.getAggregates(rrField.name);
const title = rrField.string || this.env._t("Count");
let currency = false;
if (value && rrField.currency_field) {
currency = session.currencies[session.company_currency_id];
}
return { value, currency, title };
}
}
export class CrmKanbanRenderer extends KanbanRenderer {}
CrmKanbanRenderer.template = "crm.CrmKanbanRenderer";
CrmKanbanRenderer.components = {
...KanbanRenderer.components,
CrmColumnProgress,
};
@@ -1,24 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="crm.CrmKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_kanban_counter_progress')]" position="attributes">
<attribute name="class" remove="w-75" add="w-50" separator=" "/>
<xpath expr="//ColumnProgress" position="replace">
<CrmColumnProgress group="group" aggregate="getGroupAggregate(group)" progressAttributes="props.list.model.progressAttributes"/>
</xpath>
<KanbanAnimatedNumber position="after">
<t t-if="showRecurringRevenue">
<t t-set="rrmAggregate" t-value="getRecurringRevenueGroupAggregate(group)"/>
<KanbanAnimatedNumber
value="rrmAggregate.value"
title="rrmAggregate.title"
duration="1000"
currency="aggregate.currency"
animationClass="'o_kanban_grow_huge'"
>
<t t-set-slot="prefix">
<strong>+</strong>
</t>
</KanbanAnimatedNumber>
</t>
</KanbanAnimatedNumber>
</t>
</templates>
@@ -9,7 +9,7 @@ import {
getFixture,
patchWithCleanup,
} from '@web/../tests/helpers/utils';
import { KanbanAnimatedNumber } from "@web/views/kanban/kanban_animated_number";
import { AnimatedNumber } from "@web/views/view_components/animated_number";
const serviceRegistry = registry.category("services");
@@ -18,7 +18,7 @@ let serverData;
QUnit.module('Crm Kanban Progressbar', {
beforeEach: function () {
patchWithCleanup(KanbanAnimatedNumber, { enableAnimations: false });
patchWithCleanup(AnimatedNumber, { enableAnimations: false });
serverData = {
models: {
'res.users': {
@@ -124,7 +124,7 @@ QUnit.module('Crm Kanban Progressbar', {
</kanban>`,
});
const reccurringRevenueValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
const reccurringRevenueValues = [...target.querySelectorAll('.o_animated_number:nth-child(3)')].map((elem) => elem.textContent);
// When no values are given in column it should return 0 and counts value if given.
assert.deepEqual(reccurringRevenueValues, ["+20", "+0", "+45"],
@@ -157,7 +157,7 @@ QUnit.module('Crm Kanban Progressbar', {
});
//MRR before state change
let reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
let reccurringRevenueNoValues = [...target.querySelectorAll('.o_animated_number:nth-child(3)')].map((elem) => elem.textContent);
assert.deepEqual(reccurringRevenueNoValues, ['+30','+35'],
"counter should display the sum of recurring_revenue_monthly values");
@@ -169,7 +169,7 @@ QUnit.module('Crm Kanban Progressbar', {
);
//check MRR after drag&drop
reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
reccurringRevenueNoValues = [...target.querySelectorAll('.o_animated_number:nth-child(3)')].map((elem) => elem.textContent);
assert.deepEqual(reccurringRevenueNoValues, ['+25', '+40'],
"counter should display the sum of recurring_revenue_monthly correctly after drag and drop");
@@ -177,7 +177,7 @@ QUnit.module('Crm Kanban Progressbar', {
await click(target.querySelector('.o_kanban_group:nth-child(2) .progress-bar[aria-valuenow="2"]'), null);
//check MRR after applying filter
reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
reccurringRevenueNoValues = [...target.querySelectorAll('.o_animated_number:nth-child(3)')].map((elem) => elem.textContent);
assert.deepEqual(reccurringRevenueNoValues, ['+25','+25'],
"counter should display the sum of recurring_revenue_monthly only of overdue filter in 1st column");
});
+1
View File
@@ -111,6 +111,7 @@
'web/static/src/views/kanban/**/*',
'web/static/src/views/list/**/*',
'web/static/src/views/view_button/**/*',
'web/static/src/views/view_components/**/*',
'web/static/src/views/view_dialogs/**/*',
'web/static/src/views/widgets/**/*',
'web/static/src/webclient/**/*',
@@ -1,15 +1,3 @@
@keyframes kanban-counter-grow {
30% {
transform: scale3d(1.1, 1.1, 1.1);
}
}
@keyframes kanban-counter-grow-huge {
30% {
transform: scale3d(1.3, 1.3, 1.3);
}
}
@mixin o-kanban-css-filter($class, $accent-color) {
// Provide CSS reordering and highlight
&.o_kanban_group_show_#{$class} {
@@ -44,21 +32,9 @@
.o_kanban_counter {
transition: opacity 0.3s ease 0s;
> .o_kanban_counter_progress {
> .o_column_progress {
height: $font-size-sm;
}
> .o_kanban_counter_side {
transform-origin: right center;
&.o_kanban_grow {
animation: kanban-counter-grow 1s ease 0s 1 normal none running;
}
&.o_kanban_grow_huge {
animation: kanban-counter-grow-huge 1s ease 0s 1 normal none running;
}
}
}
.o_kanban_group {
@@ -69,7 +69,7 @@ function getColorName(value) {
* exist
* @returns {string}
*/
function getImageSrcFromRecordInfo(record, model, field, idOrIds, placeholder) {
export function getImageSrcFromRecordInfo(record, model, field, idOrIds, placeholder) {
const id = (Array.isArray(idOrIds) ? idOrIds[0] : idOrIds) || null;
const isCurrentRecord =
record.resModel === model && (record.resId === id || (!record.resId && !id));
@@ -99,7 +99,7 @@ function getImageSrcFromRecordInfo(record, model, field, idOrIds, placeholder) {
* @param {string} fieldName
* @returns {any}
*/
function getRawValue(record, fieldName) {
export function getRawValue(record, fieldName) {
const field = record.fields[fieldName];
const value = record.data[fieldName];
switch (field.type) {
@@ -127,7 +127,7 @@ function getRawValue(record, fieldName) {
* @param {string} fieldName
* @returns {string}
*/
function getValue(record, fieldName) {
export function getValue(record, fieldName) {
const field = record.fields[fieldName];
const value = record.data[fieldName];
const formatter = formatters.get(field.type, String);
@@ -149,7 +149,7 @@ function isBinSize(value) {
* @param {string} innerHTML
* @returns {boolean} true if no content found or if containing only formatting tags
*/
function isHtmlEmpty(innerHTML = "") {
export function isHtmlEmpty(innerHTML = "") {
const div = Object.assign(document.createElement("div"), { innerHTML });
return div.innerText.trim() === "";
}
@@ -14,7 +14,7 @@ import { isAllowedDateField } from "@web/views/relational_model";
import { isNull, isRelational } from "@web/views/utils";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { useBounceButton } from "@web/views/view_hook";
import { KanbanAnimatedNumber } from "./kanban_animated_number";
import { ColumnProgress } from "@web/views/view_components/column_progress";
import { KanbanColumnQuickCreate } from "./kanban_column_quick_create";
import { KanbanRecord } from "./kanban_record";
import { KanbanRecordQuickCreate } from "./kanban_record_quick_create";
@@ -616,7 +616,7 @@ KanbanRenderer.props = [
KanbanRenderer.components = {
Dropdown,
DropdownItem,
KanbanAnimatedNumber,
ColumnProgress,
KanbanColumnQuickCreate,
KanbanRecord,
KanbanRecordQuickCreate,
@@ -56,31 +56,7 @@
</button>
</div>
<div t-if="(env.isSmall or !group.isFolded) and group.progressBars.length" class="o_kanban_counter position-relative d-flex align-items-center justify-content-between" t-att-class="{ 'opacity-25': !group.count }">
<div class="o_kanban_counter_progress progress w-75 bg-300">
<t t-foreach="group.progressBars" t-as="progressBar" t-key="progressBar.value">
<div t-if="progressBar.count"
role="progressbar"
class="progress-bar o_bar_has_records cursor-pointer"
t-att-class="{ 'progress-bar-animated progress-bar-striped': group.progressValue.active === progressBar.value, 'border border-white': !group.isFolded and group.hasActiveProgressValue }"
t-attf-class="bg-{{ progressBar.color }}"
t-attf-style="width: {{ progressBar.count / (group.count or 1) * 100 }}%;"
aria-valuemin="0"
t-att-aria-valuemax="group.count"
t-att-aria-valuenow="progressBar.count"
t-attf-data-tooltip="{{ progressBar.count }} {{ progressBar.string }}"
data-tooltip-delay="0"
t-on-click="() => group.filterProgressValue(progressBar.value)"
/>
</t>
</div>
<t t-set="aggregate" t-value="getGroupAggregate(group)" />
<KanbanAnimatedNumber
value="aggregate.value"
title="aggregate.title"
duration="1000"
currency="aggregate.currency"
animationClass="aggregate.value > 999 ? 'o_kanban_grow' : 'o_kanban_grow_huge'"
/>
<ColumnProgress group="group" aggregate="getGroupAggregate(group)" />
</div>
</div>
<t t-if="!group.isFolded">
@@ -5,7 +5,24 @@ import { formatInteger } from "@web/views/fields/formatters";
import { Component, onWillUpdateProps, onWillUnmount, useState } from "@odoo/owl";
export class KanbanAnimatedNumber extends Component {
export class AnimatedNumber extends Component {
static template = "web.AnimatedNumber";
static props = {
value: Number,
duration: Number,
animationClass: { type: String, optional: true },
currency: { type: [Object, Boolean], optional: true },
title: { type: String, optional: true },
slots: {
type: Object,
shape: {
prefix: { type: Object, optional: true },
},
optional: true,
},
};
static enableAnimations = true;
setup() {
this.formatInteger = formatInteger;
this.state = useState({ value: this.props.value });
@@ -37,20 +54,3 @@ export class KanbanAnimatedNumber extends Component {
return this.formatInteger(value, { humanReadable: true, decimals: 0, minDigits: 3 });
}
}
KanbanAnimatedNumber.template = "web.KanbanAnimatedNumber";
KanbanAnimatedNumber.props = {
value: Number,
duration: Number,
animationClass: { type: String, optional: true },
currency: { type: [Object, Boolean], optional: true },
title: { type: String, optional: true },
slots: {
type: Object,
shape: {
prefix: { type: Object, optional: true },
},
optional: true,
},
};
KanbanAnimatedNumber.enableAnimations = true;
@@ -0,0 +1,23 @@
@keyframes animated-counter-grow {
30% {
transform: scale3d(1.1, 1.1, 1.1);
}
}
@keyframes animated-counter-grow-huge {
30% {
transform: scale3d(1.3, 1.3, 1.3);
}
}
.o_animated_number {
transform-origin: right center;
&.o_animated_grow {
animation: animated-counter-grow 1s ease 0s 1 normal none running;
}
&.o_animated_grow_huge {
animation: animated-counter-grow-huge 1s ease 0s 1 normal none running;
}
}
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.KanbanAnimatedNumber" owl="1">
<div class="o_kanban_counter_side ms-2 text-900 text-nowrap" t-att-class="state.value !== props.value and props.animationClass" t-att-title="props.title">
<t t-name="web.AnimatedNumber" owl="1">
<div class="o_animated_number ms-2 text-900 text-nowrap" t-att-class="state.value !== props.value and props.animationClass" t-att-title="props.title">
<t t-slot="prefix"/>
<span t-if="props.currency and props.currency.position === 'before'" t-esc="props.currency.symbol" class="ms-1"/>
<b t-esc="format(state.value)" />
@@ -0,0 +1,15 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { AnimatedNumber } from "./animated_number";
export class ColumnProgress extends Component {
static components = {
AnimatedNumber,
};
static template = "web.ColumnProgress";
static props = {
aggregate: { type: Object },
group: { type: Object },
};
}
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColumnProgress" owl="1">
<div class="o_column_progress progress bg-300 w-75">
<t t-foreach="props.group.progressBars" t-as="progressBar" t-key="progressBar.value">
<div t-if="progressBar.count"
role="progressbar"
class="progress-bar o_bar_has_records cursor-pointer"
t-att-class="{ 'progress-bar-animated progress-bar-striped': props.group.progressValue.active === progressBar.value, 'border border-white': !props.group.isFolded and props.group.hasActiveProgressValue }"
t-attf-class="bg-{{ progressBar.color }}"
t-attf-style="width: {{ progressBar.count / (props.group.count or 1) * 100 }}%;"
aria-valuemin="0"
t-att-aria-valuemax="props.group.count"
t-att-aria-valuenow="progressBar.count"
t-attf-data-tooltip="{{ progressBar.count }} {{ progressBar.string }}"
data-tooltip-delay="0"
t-on-click="() => props.group.filterProgressValue(progressBar.value)"
/>
</t>
</div>
<AnimatedNumber
value="props.aggregate.value"
title="props.aggregate.title"
duration="1000"
currency="props.aggregate.currency"
animationClass="props.aggregate.value > 999 ? 'o_animated_grow' : 'o_animated_grow_huge'"
/>
</t>
</templates>
@@ -34,7 +34,7 @@ import { tooltipService } from "@web/core/tooltip/tooltip_service";
import { nbsp } from "@web/core/utils/strings";
import { getNextTabableElement } from "@web/core/utils/ui";
import { session } from "@web/session";
import { KanbanAnimatedNumber } from "@web/views/kanban/kanban_animated_number";
import { AnimatedNumber } from "@web/views/view_components/animated_number";
import { KanbanController } from "@web/views/kanban/kanban_controller";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { DynamicRecordList } from "@web/views/relational_model";
@@ -82,19 +82,17 @@ function getCardTexts(groupIndex) {
}
function getCounters() {
return [...target.querySelectorAll(".o_kanban_counter_side")].map(
(counter) => counter.innerText
);
return [...target.querySelectorAll(".o_animated_number")].map((counter) => counter.innerText);
}
function getProgressBars(columnIndex) {
const column = getColumn(columnIndex);
return [...column.querySelectorAll(".o_kanban_counter_progress .progress-bar")];
return [...column.querySelectorAll(".o_column_progress .progress-bar")];
}
function getTooltips(groupIndex) {
const root = groupIndex >= 0 ? getColumn(groupIndex) : target;
return [...root.querySelectorAll(".o_kanban_counter_progress .progress-bar")]
return [...root.querySelectorAll(".o_column_progress .progress-bar")]
.map((card) => card.dataset.tooltip)
.filter(Boolean);
}
@@ -162,7 +160,7 @@ let target;
QUnit.module("Views", (hooks) => {
hooks.beforeEach(() => {
patchWithCleanup(KanbanAnimatedNumber, { enableAnimations: false });
patchWithCleanup(AnimatedNumber, { enableAnimations: false });
serverData = {
models: {
partner: {
@@ -8715,31 +8713,24 @@ QUnit.module("Views", (hooks) => {
assert.containsN(target, ".o_kanban_group", 2);
assert.deepEqual(getCounters(), ["1", "4"]);
assert.containsN(
target,
".o_kanban_group:last-child .o_kanban_counter_progress .progress-bar",
4
);
assert.containsN(target, ".o_kanban_group:last-child .o_column_progress .progress-bar", 4);
assert.containsOnce(
target,
".o_kanban_group:last-child .o_kanban_counter_progress .progress-bar.bg-200",
".o_kanban_group:last-child .o_column_progress .progress-bar.bg-200",
"should have false kanban color"
);
assert.hasClass(
target.querySelector(
".o_kanban_group:last-child .o_kanban_counter_progress .progress-bar.bg-200"
".o_kanban_group:last-child .o_column_progress .progress-bar.bg-200"
),
"bg-200"
);
await click(
target,
".o_kanban_group:last-child .o_kanban_counter_progress .progress-bar.bg-200"
);
await click(target, ".o_kanban_group:last-child .o_column_progress .progress-bar.bg-200");
assert.hasClass(
target.querySelector(
".o_kanban_group:last-child .o_kanban_counter_progress .progress-bar.bg-200"
".o_kanban_group:last-child .o_column_progress .progress-bar.bg-200"
),
"progress-bar-animated"
);
@@ -8781,14 +8772,11 @@ QUnit.module("Views", (hooks) => {
assert.containsN(target, ".o_kanban_group", 2);
assert.deepEqual(getCounters(), ["-4", "51"]);
await click(
target,
".o_kanban_group:last-child .o_kanban_counter_progress .progress-bar.bg-200"
);
await click(target, ".o_kanban_group:last-child .o_column_progress .progress-bar.bg-200");
assert.hasClass(
target.querySelector(
".o_kanban_group:last-child .o_kanban_counter_progress .progress-bar.bg-200"
".o_kanban_group:last-child .o_column_progress .progress-bar.bg-200"
),
"progress-bar-animated"
);
@@ -8942,7 +8930,7 @@ QUnit.module("Views", (hooks) => {
groupBy: ["bar"],
});
await click(target, ".o_kanban_counter_progress .progress-bar.bg-success");
await click(target, ".o_column_progress .progress-bar.bg-success");
assert.deepEqual(getCardTexts(), ["5"], "we should have 1 record shown");
@@ -11363,7 +11351,7 @@ QUnit.module("Views", (hooks) => {
// Initial state: 2 columns, the "Yes" column contains 2 records "abc", 1 "def" and 1 "ghi"
assert.deepEqual(getCounters(), ["1", "4"]);
assert.containsN(getColumn(1), ".o_kanban_record", 4);
assert.containsN(getColumn(1), ".o_kanban_counter_progress .progress-bar", 3);
assert.containsN(getColumn(1), ".o_column_progress .progress-bar", 3);
assert.strictEqual(getProgressBars(1)[0].style.width, "50%"); // abc: 2
assert.strictEqual(getProgressBars(1)[1].style.width, "25%"); // def: 1
assert.strictEqual(getProgressBars(1)[2].style.width, "25%"); // ghi: 1
@@ -11373,7 +11361,7 @@ QUnit.module("Views", (hooks) => {
assert.deepEqual(getCounters(), ["1", "2"]);
assert.containsN(getColumn(1), ".o_kanban_record", 2);
assert.containsN(getColumn(1), ".o_kanban_counter_progress .progress-bar", 3);
assert.containsN(getColumn(1), ".o_column_progress .progress-bar", 3);
assert.strictEqual(getProgressBars(1)[0].style.width, "50%"); // abc: 2
assert.strictEqual(getProgressBars(1)[1].style.width, "25%"); // def: 1
assert.strictEqual(getProgressBars(1)[2].style.width, "25%"); // ghi: 1
@@ -11385,7 +11373,7 @@ QUnit.module("Views", (hooks) => {
assert.deepEqual(getCounters(), ["1", "1"]);
assert.containsN(getColumn(1), ".o_kanban_record", 2);
assert.containsN(getColumn(1), ".o_kanban_counter_progress .progress-bar", 3);
assert.containsN(getColumn(1), ".o_column_progress .progress-bar", 3);
assert.strictEqual(getProgressBars(1)[0].style.width, "25%"); // abc: 1
assert.strictEqual(getProgressBars(1)[1].style.width, "50%"); // def: 2
assert.strictEqual(getProgressBars(1)[2].style.width, "25%"); // ghi: 1