Initial commit: Crm packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 21a345b5b9
654 changed files with 418312 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
<defs>
<path id="icon-a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/>
<linearGradient id="icon-c" x1="98.162%" x2="0%" y1="1.838%" y2="100%">
<stop offset="0%" stop-color="#797DA5"/>
<stop offset="50.799%" stop-color="#6D7194"/>
<stop offset="100%" stop-color="#626584"/>
</linearGradient>
<path id="icon-d" d="M56.583 23.333h-3.5c-.518 0-.983.226-1.304.584h-1.715l-2.27-2.647-.012-.013a7.581 7.581 0 0 0-5.707-2.59h-3.394c-1.294 0-2.545.36-3.623 1.021a8.19 8.19 0 0 0-3.95-1.021h-2.342c-2.107 0-4.2.818-5.775 2.391l-2.857 2.859H18.22a1.745 1.745 0 0 0-1.304-.584h-3.5a1.75 1.75 0 0 0-1.75 1.75v17.5c0 .967.783 1.75 1.75 1.75h3.5a1.75 1.75 0 0 0 1.65-1.166h1.37l5.495 4.927c1.862 1.928 4.37 3.24 7.042 3.24 1.195 0 2.354-.281 3.362-.798 1.818.037 3.726-.756 5.036-2.29a7.109 7.109 0 0 0 3.698-2.523c1.53-.32 2.97-1.202 3.896-2.556h2.967a1.75 1.75 0 0 0 1.65 1.166h3.5a1.75 1.75 0 0 0 1.75-1.75v-17.5a1.75 1.75 0 0 0-1.75-1.75zM15.167 42a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333zm30.08-.42c-1.12 1.042-2.69.826-2.914.583.103.976-1.331 2.993-3.579 2.835-.404 1.351-2.057 2.467-3.754 1.878-.648.648-1.638.957-2.526.957-1.82 0-3.483-1.06-4.604-2.254l-5.928-5.316a2.332 2.332 0 0 0-1.557-.596h-1.718v-12.25h1.95c.619 0 1.212-.246 1.65-.684l3.2-3.2a4.667 4.667 0 0 1 3.3-1.366h2.34c.424 0 .84.057 1.24.167l-3.155 3.682a5.376 5.376 0 0 0-.048 6.96c2.362 2.833 6.663 2.86 9.077.143l1.894-2.193 5.282 6.99c.98 1.065.799 2.781-.15 3.664zm6.086-1.913H49.55a5.985 5.985 0 0 0-1.441-3.967l-5.693-7.534a1.752 1.752 0 0 0-2.907-1.894l-3.911 4.53a2.49 2.49 0 0 1-3.765-.068 1.885 1.885 0 0 1 .016-2.44l4.224-4.928a3.434 3.434 0 0 1 2.608-1.2h3.394c1.175 0 2.293.507 3.068 1.389l3.312 3.862h2.878v12.25zm3.5 2.333a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="icon-b" fill="#fff">
<use xlink:href="#icon-a"/>
</mask>
<g mask="url(#icon-b)">
<path fill="url(#icon-c)" d="M0 0H70V70H0z"/>
<path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/>
<path fill="#000" d="M33.423 69H4.006C2.003 69 0 68.854 0 64.911V38.065l12.348-13.998 8.686.293L29.375 21h14.023l6.01 4.089L58 24.36v19.558L33.423 69z" opacity=".165"/>
<path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/>
<path fill="#000" fill-rule="nonzero" d="M56.583 25.333h-3.5c-.518 0-.983.226-1.304.584h-1.715l-2.27-2.647-.012-.013a7.581 7.581 0 0 0-5.707-2.59h-3.394c-1.294 0-2.545.36-3.623 1.021a8.19 8.19 0 0 0-3.95-1.021h-2.342c-2.107 0-4.2.818-5.775 2.391l-2.857 2.859H18.22a1.745 1.745 0 0 0-1.304-.584h-3.5a1.75 1.75 0 0 0-1.75 1.75v17.5c0 .967.783 1.75 1.75 1.75h3.5a1.75 1.75 0 0 0 1.65-1.166h1.37l5.495 4.927c1.862 1.928 4.37 3.24 7.042 3.24 1.195 0 2.354-.281 3.362-.798 1.818.037 3.726-.756 5.036-2.29a7.109 7.109 0 0 0 3.698-2.523c1.53-.32 2.97-1.202 3.896-2.556h2.967a1.75 1.75 0 0 0 1.65 1.166h3.5a1.75 1.75 0 0 0 1.75-1.75v-17.5a1.75 1.75 0 0 0-1.75-1.75zM15.167 44a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333zm30.08-.42c-1.12 1.042-2.69.826-2.914.583.103.976-1.331 2.993-3.579 2.835-.404 1.351-2.057 2.467-3.754 1.878-.648.648-1.638.957-2.526.957-1.82 0-3.483-1.06-4.604-2.254l-5.928-5.316a2.332 2.332 0 0 0-1.557-.596h-1.718v-12.25h1.95c.619 0 1.212-.246 1.65-.684l3.2-3.2a4.667 4.667 0 0 1 3.3-1.366h2.34c.424 0 .84.057 1.24.167l-3.155 3.682a5.376 5.376 0 0 0-.048 6.96c2.362 2.833 6.663 2.86 9.077.143l1.894-2.193 5.282 6.99c.98 1.065.799 2.781-.15 3.664zm6.086-1.913H49.55a5.985 5.985 0 0 0-1.441-3.967l-5.693-7.534a1.752 1.752 0 0 0-2.907-1.894l-3.911 4.53a2.49 2.49 0 0 1-3.765-.068 1.885 1.885 0 0 1 .016-2.44l4.224-4.928a3.434 3.434 0 0 1 2.608-1.2h3.394c1.175 0 2.293.507 3.068 1.389l3.312 3.862h2.878v12.25zm3.5 2.333a1.167 1.167 0 1 1 0-2.333 1.167 1.167 0 0 1 0 2.333z" opacity=".372"/>
<use fill="#FFF" fill-rule="nonzero" xlink:href="#icon-d"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,90 @@
/** @odoo-module **/
import { _t } from 'web.core';
import { Markup } from 'web.utils';
import tour from 'web_tour.tour';
tour.register('crm_tour', {
url: "/web",
rainbowManMessage: _t("Congrats, best of luck catching such big fish! :)"),
sequence: 10,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
content: Markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
position: 'bottom',
edition: 'community',
}, {
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
content: Markup(_t('Ready to boost your sales? Let\'s have a look at your <b>Pipeline</b>.')),
position: 'bottom',
edition: 'enterprise',
}, {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_opportunity_kanban',
content: Markup(_t("<b>Create your first opportunity.</b>")),
position: 'bottom',
}, {
trigger: ".o_kanban_quick_create .o_field_widget[name='partner_id']",
content: Markup(_t('<b>Write a few letters</b> to look for a company, or create a new one.')),
position: "top",
run: function (actions) {
actions.text("Brandon Freeman", this.$anchor.find("input"));
},
}, {
trigger: ".ui-menu-item > a",
auto: true,
in_modal: false,
}, {
trigger: ".o_kanban_quick_create .o_kanban_add",
content: Markup(_t("Now, <b>add your Opportunity</b> to your Pipeline.")),
position: "bottom",
}, {
trigger: ".o_opportunity_kanban .o_kanban_group:first-child .o_kanban_record:last-of-type .oe_kanban_content",
extra_trigger: ".o_opportunity_kanban",
content: Markup(_t("<b>Drag &amp; drop opportunities</b> between columns as you progress in your sales cycle.")),
position: "right",
run: "drag_and_drop_native .o_opportunity_kanban .o_kanban_group:eq(2) ",
}, {
trigger: ".o_kanban_record:not(.o_updating) .o_ActivityButtonView",
extra_trigger: ".o_opportunity_kanban",
content: Markup(_t("Looks like nothing is planned. :(<br><br><i>Tip : Schedule activities to keep track of everything you have to do!</i>")),
position: "bottom",
}, {
trigger: ".o_ActivityListView_addActivityButton",
extra_trigger: ".o_opportunity_kanban",
content: Markup(_t("Let's <b>Schedule an Activity.</b>")),
position: "bottom",
width: 200,
}, {
trigger: '.modal-footer button[name="action_close_dialog"]',
content: Markup(_t("All set. Lets <b>Schedule</b> it.")),
position: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
run: function (actions) {
actions.auto('.modal-footer button[special=cancel]');
},
}, {
id: "drag_opportunity_to_won_step",
trigger: ".o_opportunity_kanban .o_kanban_record:last-of-type",
content: Markup(_t("Drag your opportunity to <b>Won</b> when you get the deal. Congrats !")),
position: "bottom",
run: "drag_and_drop_native .o_opportunity_kanban .o_kanban_group:eq(3) ",
}, {
trigger: ".o_kanban_record",
extra_trigger: ".o_opportunity_kanban",
content: _t("Lets have a look at an Opportunity."),
position: "right",
run: function (actions) {
actions.auto(".o_kanban_record");
},
}, {
trigger: ".o_lead_opportunity_form .o_statusbar_status",
content: _t("You can make your opportunity advance through your pipeline from here."),
position: "bottom"
}, {
trigger: ".breadcrumb-item:not(.active):first",
content: _t("Click on the breadcrumb to go back to your Pipeline. Odoo will save all modifications as you navigate."),
position: "bottom",
run: function (actions) {
actions.auto(".breadcrumb-item:not(.active):last");
}
}]);

View file

@ -0,0 +1,35 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
registerPatch({
name: 'ActivityGroupView',
recordMethods: {
/**
* @override
*/
onClickFilterButton(ev) {
// fetch the data from the button otherwise fetch the ones from the parent (.o_ActivityMenuView_activityGroup).
var data = _.extend({}, $(ev.currentTarget).data(), $(ev.target).data());
var context = {};
if (data.res_model === "crm.lead") {
this.activityMenuViewOwner.update({ isOpen: false });
if (data.filter === 'my') {
context['search_default_activities_overdue'] = 1;
context['search_default_activities_today'] = 1;
} else {
context['search_default_activities_' + data.filter] = 1;
}
// Necessary because activity_ids of mail.activity.mixin has auto_join
// So, duplicates are faking the count and "Load more" doesn't show up
context['force_search_count'] = 1;
this.env.services['action'].doAction('crm.crm_lead_action_my_activities', {
additionalContext: context,
clearBreadcrumbs: true,
});
} else {
this._super.apply(this, arguments);
}
},
},
});

View file

@ -0,0 +1,25 @@
/** @odoo-module **/
import { registerPatch } from "@mail/model/model_core";
registerPatch({
name: "ir.model",
fields: {
availableWebViews: {
compute() {
if (this.model === "crm.lead") {
return [
'list',
'kanban',
'form',
'calendar',
'pivot',
'graph',
'activity',
];
}
return this._super();
},
},
},
});

View file

@ -0,0 +1,76 @@
.o_lead_opportunity_form {
// Used to add spacing between fields when placed inline without having
// empty fields take extra space.
div.o_lead_opportunity_form_inline_fields > :not(.o_field_empty) {
margin-right: 1.5em;
}
}
.o_opportunity_kanban {
.ribbon {
&::before, &::after {
display: none;
}
span {
padding: 5px;
font-size: small;
z-index: 0;
}
}
.ribbon-top-right {
margin-top: -9px;
span {
left: 12px;
right: 30px;
height: 25px;
top: 18px;
}
}
.oe_kanban_card_ribbon {
min-height: 105px;
.o_kanban_record_title {
max-width: calc(100% - 65px);
}
.o_kanban_record_subtitle {
max-width: calc(100% - 35px);
}
}
}
.crm_lead_merge_summary {
blockquote {
font-style: normal;
}
// Tag colors
@for $size from 1 through length($o-colors) {
.o_tag_color_#{$size - 1} {
border: transparent;
line-height: normal;
$background-color: white;
// no color selected
@if $size == 1 {
& {
color: black;
background-color: $background-color;
box-shadow: inset 0 0 0 1px nth($o-colors, $size);
}
} @else {
$background-color: nth($o-colors, $size);
& {
color: white;
background-color: $background-color;
}
}
@at-root a#{&} {
&:hover {
color: color-contrast($background-color);
background-color: darken($background-color, 10%);
}
}
}
}
}

View file

@ -0,0 +1,6 @@
.o_crm_team_member_kanban {
.o_member_assignment div.oe_gauge {
width: 100px;
height: 80px;
}
}

View file

@ -0,0 +1,22 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
const { useComponent } = owl;
export async function checkRainbowmanMessage(orm, effect, recordId) {
const message = await orm.call("crm.lead", "get_rainbowman_message", [[recordId]]);
if (message) {
effect.add({
message,
type: "rainbow_man",
});
}
}
export function useCheckRainbowman() {
const component = useComponent();
const orm = useService("orm");
const effect = useService("effect");
return checkRainbowmanMessage.bind(component, orm, effect);
}

View file

@ -0,0 +1,91 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
import { Record, RelationalModel } from "@web/views/basic_relational_model";
/**
* This Form Controller makes sure we display a rainbowman message
* when the stage is won, even when we click on the statusbar.
* When the stage of a lead is changed and data are saved, we check
* if the lead is won and if a message should be displayed to the user
* with a rainbowman like when the user click on the button "Mark Won".
*/
export class CrmFormRecord extends Record {
/**
* Main method used when saving the record hitting the "Save" button.
* We check if the stage_id field was altered and if we need to display a rainbowman
* message.
*
* This method will also simulate a real "force_save" on the email and phone
* when needed. The "force_save" attribute only works on readonly field. For our
* use case, we need to write the email and the phone even if the user didn't
* change them, to synchronize those values with the partner (so the email / phone
* inverse method can be called).
*
* We base this synchronization on the value of "partner_phone_update"
* and "partner_email_update", which are computed fields that hold a value
* whenever we need to synch.
*
* @override
*/
async save() {
const recordID = this.__bm_handle__;
const localData = this.model.__bm__.localData[recordID];
const changes = localData._changes || {};
const needsSynchronizationEmail =
changes.partner_email_update === undefined
? localData.data.partner_email_update // original value
: changes.partner_email_update; // new value
const needsSynchronizationPhone =
changes.partner_phone_update === undefined
? localData.data.partner_phone_update // original value
: changes.partner_phone_update; // new value
if (
needsSynchronizationEmail &&
changes.email_from === undefined &&
localData.data.email_from
) {
changes.email_from = localData.data.email_from;
}
if (needsSynchronizationPhone && changes.phone === undefined && localData.data.phone) {
changes.phone = localData.data.phone;
}
if (!localData._changes && Object.keys(changes).length) {
localData._changes = changes;
}
let changedStage = false;
if ("stage_id" in changes && changes.stage_id) {
const bm = this.model.__bm__;
let oldStageId = false;
if (bm.localData[recordID].data.stage_id) {
oldStageId = bm.get(bm.localData[recordID].data.stage_id).data.id;
}
const newStageId = bm.get(bm.localData[recordID]._changes.stage_id).data.id;
changedStage = oldStageId !== newStageId;
}
const isSaved = await super.save(...arguments);
if (changedStage && isSaved) {
checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
}
return isSaved;
}
}
class CrmFormModel extends RelationalModel {
setup(params, services) {
this.effect = services.effect;
super.setup(...arguments);
}
}
CrmFormModel.Record = CrmFormRecord;
CrmFormModel.services = [...RelationalModel.services, "effect"];
registry.category("views").add("crm_form", {
...formView,
Model: CrmFormModel,
});

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { KanbanArchParser } from "@web/views/kanban/kanban_arch_parser";
import { extractAttributes } from "@web/core/utils/xml";
export class CrmKanbanArchParser extends KanbanArchParser {
/**
* @override
*/
parseProgressBar(progressBar, fields) {
const result = super.parseProgressBar(...arguments);
const attrs = extractAttributes(progressBar, ["recurring_revenue_sum_field"]);
result.recurring_revenue_sum_field = fields[attrs.recurring_revenue_sum_field] || false;
return result;
}
}

View file

@ -0,0 +1,74 @@
/** @odoo-module **/
import { KanbanModel } from "@web/views/kanban/kanban_model";
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
export class CrmKanbanModel extends KanbanModel {
setup(params, { orm, effect }) {
super.setup(...arguments);
this.ormService = orm;
this.effect = effect;
}
}
export class CrmKanbanDynamicGroupList extends CrmKanbanModel.DynamicGroupList {
/**
* @override
*
* Add the RRM field to the sumfields to fetch in read_group calls
*/
get sumFields() {
const result = super.sumFields;
if (this.model.progressAttributes.recurring_revenue_sum_field) {
result.push(this.model.progressAttributes.recurring_revenue_sum_field.name);
}
return result;
}
/**
* @override
*
* If the kanban view is grouped by stage_id check if the lead is won and display
* a rainbowman message if that's the case.
*/
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
const succeeded = await super.moveRecord(...arguments);
if (!succeeded) {
return;
}
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
if (
dataGroupId !== targetGroupId &&
sourceGroup &&
targetGroup &&
sourceGroup.groupByField.name === "stage_id"
) {
const record = targetGroup.list.records.find((r) => r.id === dataRecordId);
await checkRainbowmanMessage(this.model.ormService, this.model.effect, record.resId);
}
}
}
export class CrmKanbanGroup extends CrmKanbanModel.Group {
/**
* This is called whenever the progress bar is changed, for example
* when filtering on a certain stage from the progress bar and is meant
* to update `sum_field` aggregated value.
* We also want to update the recurring revenue aggregate.
*/
updateAggregates(groupData) {
if (this.model.progressAttributes.recurring_revenue_sum_field) {
const rrField = this.model.progressAttributes.recurring_revenue_sum_field;
const group = groupData.find(g => this.valueEquals(g[this.groupByField.name]));
if (rrField) {
this.aggregates[rrField.name] = group ? group[rrField.name] : 0;
}
}
return super.updateAggregates(...arguments);
}
}
CrmKanbanModel.DynamicGroupList = CrmKanbanDynamicGroupList;
CrmKanbanModel.Group = CrmKanbanGroup;
CrmKanbanModel.services = [...KanbanModel.services, "effect", "orm"];

View file

@ -0,0 +1,33 @@
/** @odoo-module **/
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 };
}
}
CrmKanbanRenderer.template = "crm.CrmKanbanRenderer";

View file

@ -0,0 +1,24 @@
<?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>
<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>

View file

@ -0,0 +1,18 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
import { CrmKanbanArchParser } from "@crm/views/crm_kanban/crm_kanban_arch_parser";
import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
export const crmKanbanView = {
...kanbanView,
ArchParser: CrmKanbanArchParser,
// Makes it easier to patch
Controller: class extends kanbanView.Controller {},
Model: CrmKanbanModel,
Renderer: CrmKanbanRenderer,
};
registry.category("views").add("crm_kanban", crmKanbanView);

View file

@ -0,0 +1,316 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
/**
* Configuration depending on the granularity:
* @param {function} startOf function to get the start moment of the period from a moment
* @param {int} cycle amount of 'granularity' periods constituting a cycle. The cycle duration
* is arbitrary for each granularity:
* cycle --- granularity
* ___________________________
* 1 day hour
* 1 week day
* 1 week week # there is no greater time period that takes an integer amount of weeks
* 1 year month
* 1 year quarter
* 1 year year # we are not using a greater time period in Odoo (yet)
* @param {int} cyclePos function to get the position (index) in the cycle from a moment.
* {1} is the first index. {+1} is used for functions which have an index
* starting from 0, to standardize between granularities.
*/
export const GRANULARITY_TABLE = {
hour: {
startOf: (x) => x.startOf("hour"),
cycle: 24,
cyclePos: (x) => x.hour() + 1,
},
day: {
startOf: (x) => x.startOf("day"),
cycle: 7,
cyclePos: (x) => x.isoWeekday(),
},
week: {
startOf: (x) => x.startOf("isoWeek"),
cycle: 1,
cyclePos: (x) => 1,
},
month: {
startOf: (x) => x.startOf("month"),
cycle: 12,
cyclePos: (x) => x.month() + 1,
},
quarter: {
startOf: (x) => x.startOf("quarter"),
cycle: 4,
cyclePos: (x) => x.quarter(),
},
year: {
startOf: (x) => x.startOf("year"),
cycle: 1,
cyclePos: (x) => 1,
},
};
/**
* configuration depending on the time type:
* @param {string} format moment format to display this type as a string
* @param {string} minGranularity granularity of the smallest time interval used in Odoo for this
* type
*/
export const FIELD_TYPE_TABLE = {
date: {
format: "YYYY-MM-DD",
minGranularity: "day",
},
datetime: {
format: "YYYY-MM-DD HH:mm:ss",
minGranularity: "second",
},
};
/**
* fill_temporal period:
* Represents a specific date/time range for a specific model, field and granularity.
*
* It is used to add new domain and context constraints related to a specific date/time
* field, in order to configure the _read_group_fill_temporal (see core models.py)
* method. It will be used when we want to get continuous groups in chronological
* order in a specific date/time range.
*/
export class FillTemporalPeriod {
/**
* This constructor is meant to be used only by the FillTemporalService (see below)
*
* @param {string} modelName directly taken from model.loadParams.modelName.
* this is the `res_model` from the action (i.e. `crm.lead`)
* @param {Object} field a dictionary with keys "name" and "type".
* name: Name of the field on which the fill_temporal should apply
* (i.e. 'date_deadline')
* type: 'date' or 'datetime'
* @param {string} granularity can either be : hour, day, week, month, quarter, year
* @param {integer} minGroups minimum amount of groups to display, regardless of other
* constraints
*/
constructor(modelName, field, granularity, minGroups) {
this.modelName = modelName;
this.field = field;
this.granularity = granularity || "month";
this.setMinGroups(minGroups);
this._computeStart();
this._computeEnd();
}
/**
* Compute the moment for the start of the period containing "now"
*
* @private
*/
_computeStart() {
this.start = GRANULARITY_TABLE[this.granularity].startOf(moment());
}
/**
* Compute the moment for the end of the fill_temporal period. This bound is exclusive.
* The fill_temporal period is the number of [granularity] from [start] to the end of the
* [cycle] reached after adding [minGroups]
* i.e. we are in october 2020 :
* [start] = 2020-10-01
* [granularity] = 'month',
* [cycle] = 12
* [minGroups] = 4,
* => fillTemporalPeriod = 15 months (until end of december 2021)
*
* @private
*/
_computeEnd() {
const cycle = GRANULARITY_TABLE[this.granularity].cycle;
const cyclePos = GRANULARITY_TABLE[this.granularity].cyclePos(this.start);
/**
* fillTemporalPeriod formula explanation :
* We want to know how many steps need to be taken from the current position until the end
* of the cycle reached after guaranteeing minGroups positions. Let's call this cycle (C).
*
* (1) compute the steps needed to reach the last position of the current cycle, from the
* current position:
* {cycle - cyclePos}
*
* (2) ignore {minGroups - 1} steps from the position reached in (1). Now, the current
* position is somewhere in (C). One step from minGroups is reserved to reach the first
* position after (C), hence {-1}
*
* (3) compute the additional steps needed to reach the last position of (C), from the
* position reached in (2):
* {cycle - (minGroups - 1) % cycle}
*
* (4) combine (1) and (3), the sum should not be greater than a full cycle (-> truncate):
* {(2 * cycle - (minGroups - 1) % cycle - cyclePos) % cycle}
*
* (5) add minGroups!
*/
const fillTemporalPeriod = ((2 * cycle - ((this.minGroups - 1) % cycle) - cyclePos) % cycle) + this.minGroups;
this.end = moment(this.start).add(fillTemporalPeriod, `${this.granularity}s`);
this.computedEnd = true;
}
/**
* The server needs a date/time in UTC, but we don't want a day shift in case
* of dates, even if the date is not in UTC
*
* @param {moment} bound the moment to be formatted (this.start or this.end)
*/
_getFormattedServerDate(bound) {
if (bound.isUTC() || this.field.type === "date") {
return bound.clone().locale("en").format(FIELD_TYPE_TABLE[this.field.type].format);
} else {
return moment.utc(bound).locale("en").format(FIELD_TYPE_TABLE[this.field.type].format);
}
}
/**
* @param {Object} configuration
* @param {Array[]} [domain]
* @param {boolean} [forceStartBound=true] whether this.start moment must be used as a domain
* constraint to limit read_group results or not
* @param {boolean} [forceEndBound=true] whether this.end moment must be used as a domain
* constraint to limit read_group results or not
* @returns {Array[]} new domain
*/
getDomain({ domain, forceStartBound = true, forceEndBound = true }) {
if (!forceEndBound && !forceStartBound) {
return domain;
}
const originalDomain = domain.length ? ["&", ...domain] : [];
const defaultDomain = ["|", [this.field.name, "=", false]];
const linkDomain = forceStartBound && forceEndBound ? ["&"] : [];
const startDomain = !forceStartBound ? [] : [[this.field.name, ">=", this._getFormattedServerDate(this.start)]];
const endDomain = !forceEndBound ? [] : [[this.field.name, "<", this._getFormattedServerDate(this.end)]];
return [...originalDomain, ...defaultDomain, ...linkDomain, ...startDomain, ...endDomain];
}
/**
* The default value of forceFillingTo is false when this.end is the
* computed one, and true when it is manually set. This is because the default value of
* this.end is computed without any knowledge of the existing data, and as such, we only
* want to get continuous groups until the last group with data (no need to force until
* this.end). On the contrary, when we set this.end, this means that we want groups until
* that date.
*
* @param {Object} configuration
* @param {Object} [context]
* @param {boolean} [forceFillingFrom=true] fill_temporal must apply from:
* true: this.start
* false: the first group with at least one record
* @param {boolean} [forceFillingTo=!this.computedEnd] fill_temporal must apply until:
* true: this.end
* false: the last group with at least one record
* @returns {Object} new context
*/
getContext({ context, forceFillingFrom = true, forceFillingTo = !this.computedEnd }) {
const fillTemporal = {
min_groups: this.minGroups,
};
if (forceFillingFrom) {
fillTemporal.fill_from = this._getFormattedServerDate(this.start);
}
if (forceFillingTo) {
fillTemporal.fill_to = this._getFormattedServerDate(
moment(this.end).subtract(1, FIELD_TYPE_TABLE[this.field.type].minGranularity)
);
}
context = { ...context, fill_temporal: fillTemporal };
return context;
}
/**
* @param {integer} minGroups minimum amount of groups to display, regardless of other
* constraints
*/
setMinGroups(minGroups) {
this.minGroups = minGroups || 1;
}
/**
* sets the end of the period to the desired moment. It must be greater
* than start. Changes the default behavior of getContext forceFillingTo
* (becomes true instead of false)
*
* @param {moment} end
*/
setEnd(end) {
this.end = moment.max(this.start, end);
this.computedEnd = false;
}
/**
* sets the start of the period to the desired moment. It must be smaller than end
*
* @param {moment} start
*/
setStart(start) {
this.start = moment.min(this.end, start);
}
/**
* Adds one "granularity" period to [this.end], to expand the current fill_temporal period
*/
expand() {
this.setEnd(this.end.add(1, `${this.granularity}s`));
}
}
/**
* fill_temporal Service
*
* This service will be used to generate or retrieve fill_temporal periods
*
* A specific fill_temporal period configuration will always refer to the same instance
* unless forceRecompute is true
*/
export const fillTemporalService = {
start() {
const _fillTemporalPeriods = {};
/**
* Get a fill_temporal period according to the configuration.
* The default initial fill_temporal period is the number of [granularity] from [start]
* to the end of the [cycle] reached after adding [minGroups]
* i.e. we are in october 2020 :
* [start] = 2020-10-01
* [granularity] = 'month',
* [cycle] = 12 (one year)
* [minGroups] = 4,
* => fillTemporalPeriod = 15 months (until the end of december 2021)
* Once created, a fill_temporal period for a specific configuration will be stored
* until requested again. This allows to manipulate the period and store the changes
* to it. This also allows to keep the configuration when switching to another view
*
* @param {Object} configuration
* @param {string} [modelName] directly taken from model.loadParams.modelName.
* this is the `res_model` from the action (i.e. `crm.lead`)
* @param {Object} [field] a dictionary with keys "name" and "type".
* @param {string} [field.name] name of the field on which the fill_temporal should apply
* (i.e. 'date_deadline')
* @param {string} [field.type] date field type: 'date' or 'datetime'
* @param {string} [granularity] can either be : hour, day, week, month, quarter, year
* @param {integer} [minGroups=4] optional minimal amount of desired groups
* @param {boolean} [forceRecompute=false] optional whether the fill_temporal period should be
* reinstancied
* @returns {FillTemporalPeriod}
*/
const getFillTemporalPeriod = ({ modelName, field, granularity, minGroups = 4, forceRecompute = false }) => {
if (!(modelName in _fillTemporalPeriods)) {
_fillTemporalPeriods[modelName] = {};
}
if (!(field.name in _fillTemporalPeriods[modelName])) {
_fillTemporalPeriods[modelName][field.name] = {};
}
if (!(granularity in _fillTemporalPeriods[modelName][field.name]) || forceRecompute) {
_fillTemporalPeriods[modelName][field.name][granularity] = new FillTemporalPeriod(
modelName,
field,
granularity,
minGroups
);
} else if (_fillTemporalPeriods[modelName][field.name][granularity].minGroups != minGroups) {
_fillTemporalPeriods[modelName][field.name][granularity].setMinGroups(minGroups);
}
return _fillTemporalPeriods[modelName][field.name][granularity];
};
return { getFillTemporalPeriod };
},
};
registry.category("services").add("fillTemporalService", fillTemporalService);

View file

@ -0,0 +1,12 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { graphView } from "@web/views/graph/graph_view";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
export const forecastGraphView = {
...graphView,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_graph", forecastGraphView);

View file

@ -0,0 +1,25 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { INTERVAL_OPTIONS } from "@web/search/utils/dates";
import { KanbanColumnQuickCreate } from "@web/views/kanban/kanban_column_quick_create";
export class ForecastKanbanColumnQuickCreate extends KanbanColumnQuickCreate {
/**
* @override
*/
get relatedFieldName() {
const { granularity = "month" } = this.props.groupByField;
const { description } = INTERVAL_OPTIONS[granularity];
return sprintf(_t("Add next %s"), description.toLocaleLowerCase());
}
/**
* @override
*
* Create column directly upon "unfolding" quick create.
*/
unfold() {
this.props.onValidate();
}
}

View file

@ -0,0 +1,104 @@
/** @odoo-module **/
import { KanbanModel } from "@web/views/kanban/kanban_model";
export class ForecastKanbanModel extends KanbanModel {
setup(params, { fillTemporalService }) {
super.setup(...arguments);
this.fillTemporalService = fillTemporalService;
}
}
export class ForecastKanbanDynamicGroupList extends ForecastKanbanModel.DynamicGroupList {
/**
* @override
*/
setup(params, state) {
super.setup(...arguments);
// Detect a reload vs an initial load, initial load should forceRecompute
this.forceNextRecompute = !state.groups;
}
/**
* @override
*
* Add fill_temporal context keys to the context before loading the groups.
*/
get context() {
const context = super.context;
if (!this.isForecastGroupBy()) {
return context;
}
return this.fillTemporalPeriod.getContext({ context });
}
/**
* return {FillTemporalPeriod} current fillTemporalPeriod according to group by state
*/
get fillTemporalPeriod() {
const context = super.context;
const minGroups = (context.fill_temporal && context.fill_temporal.min_groups) || undefined;
const { name, type, granularity } = this.groupByField;
const forceRecompute = this.forceNextRecompute;
this.forceNextRecompute = false;
return this.model.fillTemporalService.getFillTemporalPeriod({
modelName: this.resModel,
field: {
name,
type,
},
granularity: granularity || "month",
minGroups,
forceRecompute,
});
}
/**
* @returns {Boolean} true if the view is grouped by the forecast_field
*/
isForecastGroupBy() {
const forecastField = super.context.forecast_field;
const { name } = this.groupByField;
return forecastField && forecastField === name;
}
/**
* @override
*
* At every __load/__reload, we have to check the range of the last group received from the
* read_group, and update the fillTemporalPeriod from the FillTemporalService accordingly
*/
async load() {
if (!this.isForecastGroupBy()) {
return super.load(...arguments);
}
const result = await super.load(...arguments);
const lastGroup = this.groups.filter((grp) => grp.value).slice(-1)[0];
if (lastGroup) {
this.fillTemporalPeriod.setEnd(moment.utc(lastGroup.range[this.groupBy[0]].to));
}
return result;
}
/**
* @override
*
* Applies the forecast logic to the domain and context if needed before the read_group.
*/
async _loadGroups() {
if (!this.isForecastGroupBy()) {
return super._loadGroups(...arguments);
}
const previousDomain = this.domain;
this.domain = this.fillTemporalPeriod.getDomain({
domain: this.domain,
forceStartBound: false,
});
const result = await super._loadGroups(...arguments);
this.domain = previousDomain;
return result;
}
}
ForecastKanbanModel.services = [...KanbanModel.services, "fillTemporalService"];
ForecastKanbanModel.DynamicGroupList = ForecastKanbanDynamicGroupList;

View file

@ -0,0 +1,49 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { ForecastKanbanColumnQuickCreate } from "@crm/views/forecast_kanban/forecast_kanban_column_quick_create";
export class ForecastKanbanRenderer extends KanbanRenderer {
setup() {
super.setup(...arguments);
this.fillTemporalService = useService("fillTemporalService");
}
/**
* @override
*
* Allow creating groups when grouping by forecast_field.
*/
canCreateGroup() {
return super.canCreateGroup(...arguments) || this.isGroupedByForecastField();
}
isGroupedByForecastField() {
return (
this.props.list.context.forecast_field &&
this.props.list.groupByField.name === this.props.list.context.forecast_field
);
}
async addForecastColumn() {
const { name, type, granularity } = this.props.list.groupByField;
this.fillTemporalService
.getFillTemporalPeriod({
modelName: this.props.list.resModel,
field: {
name,
type,
},
granularity: granularity || "month",
})
.expand();
await this.props.list.model.root.load();
this.props.list.model.notify();
}
}
ForecastKanbanRenderer.template = "crm.ForecastKanbanRenderer";
ForecastKanbanRenderer.components = {
...KanbanRenderer.components,
ForecastKanbanColumnQuickCreate,
};

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="crm.ForecastKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
<KanbanColumnQuickCreate position="replace">
<t t-if="isGroupedByForecastField()">
<ForecastKanbanColumnQuickCreate
folded="true"
onFoldChange="() => {}"
onValidate.bind="addForecastColumn"
exampleData="exampleData"
groupByField="props.list.groupByField"
/>
</t>
<t t-else="">$0</t>
</KanbanColumnQuickCreate>
</t>
</templates>

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { ForecastKanbanRenderer } from "@crm/views/forecast_kanban/forecast_kanban_renderer";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
import { ForecastKanbanModel } from "@crm/views/forecast_kanban/forecast_kanban_model";
export const forecastKanbanView = {
...kanbanView,
Model: ForecastKanbanModel,
Renderer: ForecastKanbanRenderer,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_kanban", forecastKanbanView);

View file

@ -0,0 +1,12 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
export const forecastListView = {
...listView,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_list", forecastListView);

View file

@ -0,0 +1,12 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { pivotView } from "@web/views/pivot/pivot_view";
import { ForecastSearchModel } from "@crm/views/forecast_search_model";
export const forecastPivotView = {
...pivotView,
SearchModel: ForecastSearchModel,
};
registry.category("views").add("forecast_pivot", forecastPivotView);

View file

@ -0,0 +1,105 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { makeContext } from "@web/core/context";
import { SearchModel } from "@web/search/search_model";
/**
* This is the conversion of ForecastModelExtension. See there for more
* explanations of what is done here.
*/
const DATE_FORMAT = {
datetime: "YYYY-MM-DD HH:mm:ss",
date: "YYYY-MM-DD",
};
export class ForecastSearchModel extends SearchModel {
/**
* @override
*/
exportState() {
const state = super.exportState();
state.forecast = {
forecastStart: this.forecastStart,
};
return state;
}
/**
* @override
*/
_getDomain(params = {}) {
const domain = super._getDomain(...arguments);
const forecastField = this.globalContext.forecast_field;
if (!forecastField) {
return domain;
}
let forecastFilter = false;
for (const queryElem of this.query) {
const searchItem = this.searchItems[queryElem.searchItemId];
if (searchItem.type === "filter") {
const context = makeContext([searchItem.context || {}]);
if (context.forecast_filter) {
forecastFilter = true;
break;
}
}
}
if (!forecastFilter) {
return domain;
}
const forecastStart = this._getForecastStart(forecastField);
const forecastDomain = [
"|",
[forecastField, "=", false],
[forecastField, ">=", forecastStart],
];
const fullDomain = Domain.and([domain, forecastDomain]);
return params.raw ? fullDomain : fullDomain.toList();
}
/**
* @protected
* @param {string} forecastField
* @returns {string}
*/
_getForecastStart(forecastField) {
if (!this.forecastStart) {
const { type } = this.searchViewFields[forecastField];
let startMoment;
const groupBy = this.groupBy;
const firstForecastGroupBy = groupBy.find((gb) => gb.includes(forecastField));
let granularity = "month";
if (firstForecastGroupBy) {
granularity = firstForecastGroupBy.split(":")[1] || "month";
} else if (groupBy.length) {
granularity = "day";
}
startMoment = moment().startOf(granularity);
if (type === "datetime") {
startMoment = moment.utc(startMoment);
}
const format = DATE_FORMAT[type];
this.forecastStart = startMoment.locale("en").format(format);
}
return this.forecastStart;
}
/**
* @override
*/
_importState(state) {
super._importState(...arguments);
if (state.forecast) {
this.forecastStart = state.forecast.forecastStart;
}
}
/**
* @override
*/
_reset() {
super._reset();
this.forecastStart = null;
}
}

View file

@ -0,0 +1,260 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import {
click,
dragAndDrop,
getFixture,
makeDeferred,
nextTick,
patchWithCleanup,
} from '@web/../tests/helpers/utils';
import { KanbanAnimatedNumber } from "@web/views/kanban/kanban_animated_number";
const serviceRegistry = registry.category("services");
let target;
let serverData;
QUnit.module('Crm Kanban Progressbar', {
beforeEach: function () {
patchWithCleanup(KanbanAnimatedNumber, { enableAnimations: false });
serverData = {
models: {
'res.users': {
fields: {
display_name: { string: 'Name', type: 'char' },
},
records: [
{ id: 1, name: 'Dhvanil' },
{ id: 2, name: 'Trivedi' },
],
},
'crm.stage': {
fields: {
display_name: { string: 'Name', type: 'char' },
is_won: { string: 'Is won', type: 'boolean' },
},
records: [
{ id: 1, name: 'New' },
{ id: 2, name: 'Qualified' },
{ id: 3, name: 'Won', is_won: true },
],
},
'crm.lead': {
fields: {
display_name: { string: 'Name', type: 'char' },
bar: {string: "Bar", type: "boolean"},
activity_state: {string: "Activity State", type: "char"},
expected_revenue: { string: 'Revenue', type: 'integer', sortable: true },
recurring_revenue_monthly: { string: 'Recurring Revenue', type: 'integer', sortable: true },
stage_id: { string: 'Stage', type: 'many2one', relation: 'crm.stage' },
user_id: { string: 'Salesperson', type: 'many2one', relation: 'res.users' },
},
records : [
{ id: 1, bar: false, name: 'Lead 1', activity_state: 'planned', expected_revenue: 125, recurring_revenue_monthly: 5, stage_id: 1, user_id: 1 },
{ id: 2, bar: true, name: 'Lead 2', activity_state: 'today', expected_revenue: 5, stage_id: 2, user_id: 2 },
{ id: 3, bar: true, name: 'Lead 3', activity_state: 'planned', expected_revenue: 13, recurring_revenue_monthly: 20, stage_id: 3, user_id: 1 },
{ id: 4, bar: true, name: 'Lead 4', activity_state: 'today', expected_revenue: 4, stage_id: 2, user_id: 2 },
{ id: 5, bar: false, name: 'Lead 5', activity_state: 'overdue', expected_revenue: 8, recurring_revenue_monthly: 25, stage_id: 3, user_id: 1 },
{ id: 6, bar: true, name: 'Lead 4', activity_state: 'today', expected_revenue: 4, recurring_revenue_monthly: 15, stage_id: 1, user_id: 2 },
],
},
},
views: {},
};
target = getFixture();
setupViewRegistries();
serviceRegistry.add(
"user",
makeFakeUserService((group) => group === "crm.group_use_recurring_revenues"),
{ force: true },
);
},
}, function () {
QUnit.test("Progressbar: do not show sum of MRR if recurring revenues is not enabled", async function (assert) {
assert.expect(1);
await makeView({
type: "kanban",
serverData,
resModel: 'crm.lead',
groupBy: ['stage_id'],
arch: `
<kanban js_class="crm_kanban">
<field name="stage_id"/>
<field name="expected_revenue"/>
<field name="recurring_revenue_monthly"/>
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
<div><field name="recurring_revenue_monthly"/></div>
</t>
</templates>
</kanban>`,
});
const reccurringRevenueNoValues = [...target.querySelectorAll('.o_crm_kanban_mrr_counter_side')].map((elem) => elem.textContent)
assert.deepEqual(reccurringRevenueNoValues, [],
"counter should not display recurring_revenue_monthly content");
});
QUnit.test("Progressbar: ensure correct MRR sum is displayed if recurring revenues is enabled", async function (assert) {
assert.expect(1);
await makeView({
type: "kanban",
serverData,
resModel: 'crm.lead',
groupBy: ['stage_id'],
arch: `
<kanban js_class="crm_kanban">
<field name="stage_id"/>
<field name="expected_revenue"/>
<field name="recurring_revenue_monthly"/>
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
<div><field name="recurring_revenue_monthly"/></div>
</t>
</templates>
</kanban>`,
});
const reccurringRevenueValues = [...target.querySelectorAll('.o_kanban_counter_side: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"],
"counter should display the sum of recurring_revenue_monthly values if values are given else display 0");
});
QUnit.test("Progressbar: ensure correct MRR updation after state change", async function (assert) {
assert.expect(3);
await makeView({
type: "kanban",
serverData,
resModel: 'crm.lead',
groupBy: ['bar'],
arch: `
<kanban js_class="crm_kanban">
<field name="stage_id"/>
<field name="expected_revenue"/>
<field name="recurring_revenue_monthly"/>
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
<div><field name="expected_revenue"/></div>
<div><field name="recurring_revenue_monthly"/></div>
</t>
</templates>
</kanban>`,
});
//MRR before state change
let reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side:nth-child(3)')].map((elem) => elem.textContent);
assert.deepEqual(reccurringRevenueNoValues, ['+30','+35'],
"counter should display the sum of recurring_revenue_monthly values");
// Drag the first kanban record from 1st column to the top of the last column
await dragAndDrop(
[...target.querySelectorAll('.o_kanban_record')].shift(),
[...target.querySelectorAll('.o_kanban_record')].pop(),
{ position: 'bottom' }
);
//check MRR after drag&drop
reccurringRevenueNoValues = [...target.querySelectorAll('.o_kanban_counter_side: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");
//Activate "planned" filter on first column
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);
assert.deepEqual(reccurringRevenueNoValues, ['+25','+25'],
"counter should display the sum of recurring_revenue_monthly only of overdue filter in 1st column");
});
QUnit.test("Quickly drag&drop records when grouped by stage_id", async function (assert) {
const def = makeDeferred();
await makeView({
type: "kanban",
serverData,
resModel: 'crm.lead',
groupBy: ['stage_id'],
arch: `
<kanban js_class="crm_kanban">
<field name="stage_id"/>
<field name="expected_revenue"/>
<field name="recurring_revenue_monthly"/>
<field name="activity_state"/>
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" recurring_revenue_sum_field="recurring_revenue_monthly"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
<div><field name="expected_revenue"/></div>
<div><field name="recurring_revenue_monthly"/></div>
</t>
</templates>
</kanban>`,
async mockRPC(route, args) {
if (args.method === "write") {
await def;
}
}
});
assert.containsN(target, ".o_kanban_group", 3);
assert.containsN(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record", 2);
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 2);
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 2);
// drag the first record of the first column on top of the second column
await dragAndDrop(
target.querySelectorAll('.o_kanban_group')[0].querySelector('.o_kanban_record'),
target.querySelectorAll('.o_kanban_group')[1].querySelector('.o_kanban_record'),
{ position: 'top' }
);
assert.containsOnce(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record");
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 3);
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 2);
// drag that same record to the third column -> should have no effect as save still pending
// (but mostly, should not crash)
await dragAndDrop(
target.querySelectorAll('.o_kanban_group')[1].querySelector('.o_kanban_record'),
target.querySelectorAll('.o_kanban_group')[2].querySelector('.o_kanban_record'),
{ position: 'top' }
);
assert.containsOnce(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record");
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 3);
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 2);
def.resolve();
await nextTick();
// drag that same record to the third column
await dragAndDrop(
target.querySelectorAll('.o_kanban_group')[1].querySelector('.o_kanban_record'),
target.querySelectorAll('.o_kanban_group')[2].querySelector('.o_kanban_record'),
{ position: 'top' }
);
assert.containsOnce(target.querySelectorAll(".o_kanban_group")[0], ".o_kanban_record");
assert.containsN(target.querySelectorAll(".o_kanban_group")[1], ".o_kanban_record", 2);
assert.containsN(target.querySelectorAll(".o_kanban_group")[2], ".o_kanban_record", 3);
});
});

View file

@ -0,0 +1,345 @@
/** @odoo-module **/
import "@crm/../tests/mock_server";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import {
click,
dragAndDrop,
getFixture,
selectDropdownItem,
} from '@web/../tests/helpers/utils';
import testUtils from 'web.test_utils';
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
import { startServer } from '@bus/../tests/helpers/mock_python_environment';
import { start } from "@mail/../tests/helpers/test_utils";
addModelNamesToFetch(["crm.stage", "crm.lead"]);
const find = testUtils.dom.find;
let target;
function getMockRpc(assert) {
return async (route, args, performRpc) => {
const result = await performRpc(route, args);
if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') {
assert.step(result || "no rainbowman");
}
return result;
};
}
QUnit.module('Crm Rainbowman Triggers', {
beforeEach: function () {
const format = "YYYY-MM-DD HH:mm:ss";
const serverData = {
models: {
'res.users': {
fields: {
display_name: { string: 'Name', type: 'char' },
},
records: [
{ id: 1, name: 'Mario' },
{ id: 2, name: 'Luigi' },
{ id: 3, name: 'Link' },
{ id: 4, name: 'Zelda' },
],
},
'crm.team': {
fields: {
display_name: { string: 'Name', type: 'char' },
member_ids: { string: 'Members', type: 'many2many', relation: 'res.users' },
},
records: [
{ id: 1, name: 'Mushroom Kingdom', member_ids: [1, 2] },
{ id: 2, name: 'Hyrule', member_ids: [3, 4] },
],
},
'crm.stage': {
fields: {
display_name: { string: 'Name', type: 'char' },
is_won: { string: 'Is won', type: 'boolean' },
},
records: [
{ id: 1, name: 'Start' },
{ id: 2, name: 'Middle' },
{ id: 3, name: 'Won', is_won: true},
],
},
'crm.lead': {
fields: {
display_name: { string: 'Name', type: 'char' },
planned_revenue: { string: 'Revenue', type: 'float' },
stage_id: { string: 'Stage', type: 'many2one', relation: 'crm.stage' },
team_id: { string: 'Sales Team', type: 'many2one', relation: 'crm.team' },
user_id: { string: 'Salesperson', type: 'many2one', relation: 'res.users' },
date_closed: { string: 'Date closed', type: 'datetime' },
},
records : [
{ id: 1, name: 'Lead 1', planned_revenue: 5.0, stage_id: 1, team_id: 1, user_id: 1 },
{ id: 2, name: 'Lead 2', planned_revenue: 5.0, stage_id: 2, team_id: 2, user_id: 4 },
{ id: 3, name: 'Lead 3', planned_revenue: 3.0, stage_id: 3, team_id: 1, user_id: 1, date_closed: moment().subtract(5, 'days').format(format) },
{ id: 4, name: 'Lead 4', planned_revenue: 4.0, stage_id: 3, team_id: 2, user_id: 4, date_closed: moment().subtract(23, 'days').format(format) },
{ id: 5, name: 'Lead 5', planned_revenue: 7.0, stage_id: 3, team_id: 1, user_id: 1, date_closed: moment().subtract(20, 'days').format(format) },
{ id: 6, name: 'Lead 6', planned_revenue: 4.0, stage_id: 2, team_id: 1, user_id: 2 },
{ id: 7, name: 'Lead 7', planned_revenue: 1.8, stage_id: 3, team_id: 2, user_id: 3, date_closed: moment().subtract(23, 'days').format(format) },
{ id: 8, name: 'Lead 8', planned_revenue: 1.9, stage_id: 1, team_id: 2, user_id: 3 },
{ id: 9, name: 'Lead 9', planned_revenue: 1.5, stage_id: 3, team_id: 2, user_id: 3, date_closed: moment().subtract(5, 'days').format(format) },
{ id: 10, name: 'Lead 10', planned_revenue: 1.7, stage_id: 2, team_id: 2, user_id: 3 },
{ id: 11, name: 'Lead 11', planned_revenue: 2.0, stage_id: 3, team_id: 2, user_id: 4, date_closed: moment().subtract(5, 'days').format(format) },
],
},
},
views: {},
};
this.testFormView = {
arch: `
<form js_class="crm_form">
<header><field name="stage_id" widget="statusbar" options="{'clickable': '1'}"/></header>
<field name="name"/>
<field name="planned_revenue"/>
<field name="team_id"/>
<field name="user_id"/>
</form>`,
serverData,
type: "form",
resModel: 'crm.lead',
};
this.testKanbanView = {
arch: `
<kanban js_class="crm_kanban">
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>`,
serverData,
resModel: 'crm.lead',
type: "kanban",
groupBy: ['stage_id'],
};
target = getFixture();
setupViewRegistries();
},
}, function () {
QUnit.test("first lead won, click on statusbar", async function (assert) {
assert.expect(2);
await makeView({
...this.testFormView,
resId: 6,
mockRPC: getMockRpc(assert),
mode: "readonly",
});
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
assert.verifySteps(['Go, go, go! Congrats for your first deal.']);
});
QUnit.test("first lead won, click on statusbar in edit mode", async function (assert) {
assert.expect(2);
await makeView({
...this.testFormView,
resId: 6,
mockRPC: getMockRpc(assert),
});
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
assert.verifySteps(['Go, go, go! Congrats for your first deal.']);
});
QUnit.test("team record 30 days, click on statusbar", async function (assert) {
assert.expect(2);
await makeView({
...this.testFormView,
resId: 2,
mockRPC: getMockRpc(assert),
mode: "readonly",
});
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
assert.verifySteps(['Boom! Team record for the past 30 days.']);
});
QUnit.test("team record 7 days, click on statusbar", async function (assert) {
assert.expect(2);
await makeView({
...this.testFormView,
resId: 1,
mockRPC: getMockRpc(assert),
mode: "readonly",
});
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
assert.verifySteps(['Yeah! Deal of the last 7 days for the team.']);
});
QUnit.test("user record 30 days, click on statusbar", async function (assert) {
assert.expect(2);
await makeView({
...this.testFormView,
resId: 8,
mockRPC: getMockRpc(assert),
mode: "readonly",
});
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
assert.verifySteps(['You just beat your personal record for the past 30 days.']);
});
QUnit.test("user record 7 days, click on statusbar", async function (assert) {
assert.expect(2);
await makeView({
...this.testFormView,
resId: 10,
mockRPC: getMockRpc(assert),
mode: "readonly",
});
await click(target.querySelector(".o_statusbar_status button[data-value='3']"));
assert.verifySteps(['You just beat your personal record for the past 7 days.']);
});
QUnit.test("click on stage (not won) on statusbar", async function (assert) {
assert.expect(2);
await makeView({
...this.testFormView,
resId: 1,
mockRPC: getMockRpc(assert),
mode: "readonly",
});
await click(target.querySelector(".o_statusbar_status button[data-value='2']"));
assert.verifySteps(['no rainbowman']);
});
QUnit.test("first lead won, drag & drop kanban", async function (assert) {
assert.expect(2);
await makeView({
...this.testKanbanView,
mockRPC: getMockRpc(assert),
});
await dragAndDrop(find(target, ".o_kanban_record", "Lead 6"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
assert.verifySteps(['Go, go, go! Congrats for your first deal.']);
});
QUnit.test("team record 30 days, drag & drop kanban", async function (assert) {
assert.expect(2);
await makeView({
...this.testKanbanView,
mockRPC: getMockRpc(assert),
});
await dragAndDrop(find(target, ".o_kanban_record", "Lead 2"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
assert.verifySteps(['Boom! Team record for the past 30 days.']);
});
QUnit.test("team record 7 days, drag & drop kanban", async function (assert) {
assert.expect(2);
await makeView({
...this.testKanbanView,
mockRPC: getMockRpc(assert),
});
await dragAndDrop(find(target, ".o_kanban_record", "Lead 1"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
assert.verifySteps(['Yeah! Deal of the last 7 days for the team.']);
});
QUnit.test("user record 30 days, drag & drop kanban", async function (assert) {
assert.expect(2);
await makeView({
...this.testKanbanView,
mockRPC: getMockRpc(assert),
});
await dragAndDrop(find(target, ".o_kanban_record", "Lead 8"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
assert.verifySteps(['You just beat your personal record for the past 30 days.']);
});
QUnit.test("user record 7 days, drag & drop kanban", async function (assert) {
assert.expect(2);
await makeView({
...this.testKanbanView,
mockRPC: getMockRpc(assert),
});
await dragAndDrop(find(target, ".o_kanban_record", "Lead 10"), target.querySelector('.o_kanban_group:nth-of-type(3)'));
assert.verifySteps(['You just beat your personal record for the past 7 days.']);
});
QUnit.test("drag & drop record kanban in stage not won", async function (assert) {
assert.expect(2);
await makeView({
...this.testKanbanView,
mockRPC: getMockRpc(assert),
});
await dragAndDrop(find(target, ".o_kanban_record", "Lead 8"), target.querySelector('.o_kanban_group:nth-of-type(2)'));
assert.verifySteps(["no rainbowman"]);
});
QUnit.test("drag & drop record in kanban not grouped by stage_id", async function (assert) {
assert.expect(1);
await makeView({
...this.testKanbanView,
groupBy: ["user_id"],
mockRPC: getMockRpc(assert),
});
await dragAndDrop(target.querySelector('.o_kanban_group:nth-of-type(1)'), target.querySelector('.o_kanban_group:nth-of-type(2)'));
assert.verifySteps([]); // Should never pass by the rpc
});
QUnit.test("send a message on a new record after changing the stage", async function (assert) {
assert.expect(1);
const pyEnv = await startServer();
pyEnv["crm.stage"].create({ name : "Dummy Stage", is_won: true });
const views = {
"crm.lead,false,form": `
<form js_class="crm_form">
<sheet>
<field name="stage_id"/>
</sheet>
<div class="oe_chatter">
<field name="message_ids" options="{'open_attachments': True}"/>
</div>
</form>`,
};
const messageBody = "some message";
const { insertText, openView } = await start({
serverData: { views },
mockRPC: function (route, args) {
if (route === "/mail/message/post") {
assert.deepEqual(args.post_data.body, messageBody);
}
}
});
await openView({
res_model: "crm.lead",
views: [[false, "form"]],
});
await selectDropdownItem(target, "stage_id", "Dummy Stage");
await click(target, ".o_ChatterTopbar_buttonSendMessage");
await insertText(".o_ComposerTextInput_textarea", messageBody);
await click(target, ".o_Composer_buttonSend");
});
});

View file

@ -0,0 +1,267 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { fillTemporalService } from "@crm/views/fill_temporal_service";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import {
click,
getFixture,
patchDate,
} from '@web/../tests/helpers/utils';
import testUtils from 'web.test_utils';
const find = testUtils.dom.find;
const serviceRegistry = registry.category("services");
let target;
QUnit.module('Crm Forecast Model Extension', {
beforeEach: async function () {
serviceRegistry.add("fillTemporalService", fillTemporalService);
this.testKanbanView = {
arch: `
<kanban js_class="forecast_kanban">
<field name="date_deadline"/>
<field name="date_closed"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>`,
searchViewArch: `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
<filter name='groupby_date_closed' context="{'group_by':'date_closed'}"/>
</search>`,
serverData: {
models: {
'crm.lead': {
fields: {
name: {string: 'Name', type: 'char'},
date_deadline: {string: "Expected closing", type: 'date'},
date_closed: {string: "Closed Date", type: 'datetime'},
},
records: [
{id: 1, name: 'Lead 1', date_deadline: '2021-01-01', date_closed: '2021-01-01 00:00:00'},
{id: 2, name: 'Lead 2', date_deadline: '2021-01-20', date_closed: '2021-01-20 00:00:00'},
{id: 3, name: 'Lead 3', date_deadline: '2021-02-01', date_closed: '2021-02-01 00:00:00'},
{id: 4, name: 'Lead 4', date_deadline: '2021-02-20', date_closed: '2021-02-20 00:00:00'},
{id: 5, name: 'Lead 5', date_deadline: '2021-03-01', date_closed: '2021-03-01 00:00:00'},
{id: 6, name: 'Lead 6', date_deadline: '2021-03-20', date_closed: '2021-03-20 00:00:00'},
],
},
},
views: {},
},
resModel: 'crm.lead',
type: "kanban",
context: {
search_default_forecast: true,
search_default_groupby_date_deadline: true,
forecast_field: 'date_deadline',
},
groupBy: ['date_deadline'],
};
target = getFixture();
setupViewRegistries();
patchDate(2021, 1, 10, 0, 0, 0);
},
}, function () {
QUnit.test("filter out every records before the start of the current month with forecast_filter for a date field", async function (assert) {
// the filter is used by the forecast model extension, and applies the forecast_filter context key,
// which adds a domain constraint on the field marked in the other context key forecast_field
assert.expect(7);
await makeView(this.testKanbanView);
// the filter is active
assert.containsN(target, '.o_kanban_group', 2, "There should be 2 columns");
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
"1st column February should contain 2 record");
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
"2nd column March should contain 2 records");
// remove the filter
await click(find(target, '.o_searchview_facet', "Forecast"), '.o_facet_remove');
assert.containsN(target, '.o_kanban_group', 3, "There should be 3 columns");
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
"1st column January should contain 2 record");
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
"2nd column February should contain 2 records");
assert.containsN(target, '.o_kanban_group:nth-child(3) .o_kanban_record', 2,
"3nd column March should contain 2 records");
});
QUnit.test("filter out every records before the start of the current month with forecast_filter for a datetime field", async function (assert) {
// same tests as for the date field
assert.expect(7);
await makeView({
...this.testKanbanView,
context: {
search_default_forecast: true,
search_default_groupby_date_closed: true,
forecast_field: 'date_closed',
},
groupBy: ['date_closed'],
});
// with the filter
assert.containsN(target, '.o_kanban_group', 2, "There should be 2 columns");
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
"1st column February should contain 2 record");
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
"2nd column March should contain 2 records");
// remove the filter
await click(find(target, '.o_searchview_facet', "Forecast"), '.o_facet_remove');
assert.containsN(target, '.o_kanban_group', 3, "There should be 3 columns");
assert.containsN(target, '.o_kanban_group:nth-child(1) .o_kanban_record', 2,
"1st column January should contain 2 record");
assert.containsN(target, '.o_kanban_group:nth-child(2) .o_kanban_record', 2,
"2nd column February should contain 2 records");
assert.containsN(target, '.o_kanban_group:nth-child(3) .o_kanban_record', 2,
"3nd column March should contain 2 records");
});
});
QUnit.module('Crm Fill Temporal Service', {
/**
* Remark: -> the filter with the groupBy is needed for the model_extension to access the groupby
* when created with makeView. Not needed in production.
* -> testKanbanView.groupBy is still needed to apply the groupby on the view
*/
beforeEach: async function () {
serviceRegistry.add("fillTemporalService", fillTemporalService);
this.testKanbanView = {
arch: `
<kanban js_class="forecast_kanban">
<field name="date_deadline"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>`,
searchViewArch: `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
</search>`
,
serverData: {
models: {
'crm.lead': {
fields: {
name: {string: 'Name', type: 'char'},
date_deadline: {string: "Expected Closing", type: 'date'},
},
},
},
views: {},
},
resModel: 'crm.lead',
type: "kanban",
context: {
search_default_forecast: true,
search_default_groupby_date_deadline: true,
forecast_field: 'date_deadline',
},
groupBy: ['date_deadline'],
};
target = getFixture();
setupViewRegistries();
patchDate(2021, 9, 10, 0, 0, 0);
},
}, function () {
/**
* Since mock_server does not support fill_temporal,
* we only check the domain and the context sent to the read_group, as well
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
*/
QUnit.test("Forecast on months, until the end of the year of the latest data", async function (assert) {
assert.expect(3);
this.testKanbanView.serverData.models['crm.lead'].records = [
{id: 1, name: 'Lead 1', date_deadline: '2021-01-01'},
{id: 2, name: 'Lead 2', date_deadline: '2021-02-01'},
{id: 3, name: 'Lead 3', date_deadline: '2021-11-01'},
{id: 4, name: 'Lead 4', date_deadline: '2022-01-01'},
];
const kanban = await makeView({
...this.testKanbanView,
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/crm.lead/web_read_group') {
assert.deepEqual(args.kwargs.context.fill_temporal, {
fill_from: "2021-10-01",
min_groups: 4,
});
assert.deepEqual(args.kwargs.domain, [
"&", "|",
["date_deadline", "=", false], ["date_deadline", ">=", "2021-10-01"],
"|",
["date_deadline", "=", false], ["date_deadline", "<", "2023-01-01"],
]);
}
},
});
assert.strictEqual(kanban.env.services.fillTemporalService.getFillTemporalPeriod({
modelName: 'crm.lead',
field: {
name: 'date_deadline',
type: 'date',
},
granularity: 'month',
}).end.format('YYYY-MM-DD'), '2022-02-01');
});
/**
* Since mock_server does not support fill_temporal,
* we only check the domain and the context sent to the read_group, as well
* as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
*/
QUnit.test("Forecast on years, until the end of the year of the latest data", async function (assert) {
assert.expect(3);
this.testKanbanView.serverData.models['crm.lead'].records = [
{id: 1, name: 'Lead 1', date_deadline: '2021-01-01'},
{id: 2, name: 'Lead 2', date_deadline: '2022-02-01'},
{id: 3, name: 'Lead 3', date_deadline: '2027-11-01'},
];
const kanban = await makeView({
...this.testKanbanView,
groupBy: ['date_deadline:year'],
searchViewArch: this.testKanbanView.searchViewArch.replace("'date_deadline'", "'date_deadline:year'"),
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/crm.lead/web_read_group') {
assert.deepEqual(args.kwargs.context.fill_temporal, {
fill_from: "2021-01-01",
min_groups: 4,
});
assert.deepEqual(args.kwargs.domain, [
"&", "|",
["date_deadline", "=", false], ["date_deadline", ">=", "2021-01-01"],
"|",
["date_deadline", "=", false], ["date_deadline", "<", "2025-01-01"],
]);
}
},
});
assert.strictEqual(kanban.env.services.fillTemporalService.getFillTemporalPeriod({
modelName: 'crm.lead',
field: {
name: 'date_deadline',
type: 'date',
},
granularity: 'year',
}).end.format('YYYY-MM-DD'), '2023-01-01');
});
});

View file

@ -0,0 +1,164 @@
/** @odoo-module **/
import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { menuService } from "@web/webclient/menus/menu_service";
import {
toggleFilterMenu,
toggleGroupByMenu,
toggleMenuItem,
toggleMenuItemOption,
} from "@web/../tests/search/helpers";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
import { mock } from "web.test_utils";
import { browser } from "@web/core/browser/browser";
import { fakeCookieService } from "@web/../tests/helpers/mock_services";
const patchDate = mock.patchDate;
const serviceRegistry = registry.category("services");
const forecastDomain = (forecastStart) => {
return ["|", ["date_field", "=", false], ["date_field", ">=", forecastStart]];
};
let serverData;
let target;
QUnit.module("Views", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
foo: {
fields: {
date_field: {
string: "Date Field",
type: "date",
store: true,
sortable: true,
},
bar: {
string: "Bar",
type: "many2one",
relation: "partner",
store: true,
sortable: true,
},
},
records: [],
},
partner: {},
},
views: {
"foo,false,legacy_toy": `<legacy_toy/>`,
"foo,false,graph": `<graph js_class="forecast_graph"/>`,
"foo,false,search": `
<search>
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }"/>
<filter name="group_by_bar" string="Bar" context="{ 'group_by': 'bar' }"/>
<filter name="group_by_date_field" string="Date Field" context="{ 'group_by': 'date_field' }"/>
</search>
`,
},
};
setupViewRegistries();
serviceRegistry.add("menu", menuService);
serviceRegistry.add("cookie", fakeCookieService);
target = getFixture();
});
QUnit.module("Forecast views");
QUnit.test("Forecast graph view", async function (assert) {
assert.expect(5);
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
const unpatchDate = patchDate(2021, 8, 16, 16, 54, 0);
const expectedDomains = [
forecastDomain("2021-09-01"), // month granularity due to no groupby
forecastDomain("2021-09-16"), // day granularity due to simple bar groupby
// quarter granularity due to date field groupby activated with quarter interval option
forecastDomain("2021-07-01"),
// quarter granularity due to date field groupby activated with quarter and year interval option
forecastDomain("2021-01-01"),
// forecast filter no more active
[],
];
await makeView({
resModel: "foo",
type: "graph",
serverData,
searchViewId: false,
context: {
search_default_forecast_filter: 1,
forecast_field: "date_field",
},
mockRPC(_, args) {
if (args.method === "web_read_group") {
const { domain } = args.kwargs;
assert.deepEqual(domain, expectedDomains.shift());
}
},
});
await toggleGroupByMenu(target);
await toggleMenuItem(target, "Bar");
await toggleMenuItem(target, "Date Field");
await toggleMenuItemOption(target, "Date Field", "Quarter");
await toggleMenuItemOption(target, "Date Field", "Year");
await toggleFilterMenu(target);
await toggleMenuItem(target, "Forecast Filter");
unpatchDate();
});
QUnit.test(
"forecast filter domain is combined with other domains with an AND",
async function (assert) {
assert.expect(1);
const unpatchDate = patchDate(2021, 8, 16, 16, 54, 0);
serverData.views["foo,false,search"] = `
<search>
<filter name="other_filter" string="Other Filter" domain="[('bar', '=', 2)]"/>
<filter name="forecast_filter" string="Forecast Filter" context="{ 'forecast_filter': 1 }"/>
</search>
`;
await makeView({
resModel: "foo",
type: "graph",
serverData,
searchViewId: false,
context: {
search_default_other_filter: 1,
search_default_forecast_filter: 1,
forecast_field: "date_field",
},
mockRPC(_, args) {
if (args.method === "web_read_group") {
const { domain } = args.kwargs;
assert.deepEqual(domain, [
"&",
["bar", "=", 2],
"|",
["date_field", "=", false],
["date_field", ">=", "2021-09-01"],
]);
}
},
});
// note that the facets of the two filters are combined with an OR.
// --> current behavior in legacy
unpatchDate();
}
);
});

View file

@ -0,0 +1,55 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, "CRM", {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
*/
async _performRPC(route, args) {
if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') {
let message = false;
const records = this.models['crm.lead'].records;
const record = records.find(r => r.id === args.args[0][0]);
const won_stage = this.models['crm.stage'].records.find(s => s.is_won);
if (record.stage_id === won_stage.id && record.user_id && record.team_id && record.planned_revenue > 0) {
const now = moment();
let query_result = {};
// Total won
query_result['total_won'] = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id).length;
// Max team 30 days
const recordsTeam30 = records.filter(r => r.stage_id === won_stage.id && r.team_id === record.team_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 30));
query_result['max_team_30'] = Math.max(...recordsTeam30.map(r => r.planned_revenue));
// Max team 7 days
const recordsTeam7 = records.filter(r => r.stage_id === won_stage.id && r.team_id === record.team_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 7));
query_result['max_team_7'] = Math.max(...recordsTeam7.map(r => r.planned_revenue));
// Max User 30 days
const recordsUser30 = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 30));
query_result['max_user_30'] = Math.max(...recordsUser30.map(r => r.planned_revenue));
// Max User 7 days
const recordsUser7 = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 7));
query_result['max_user_7'] = Math.max(...recordsUser7.map(r => r.planned_revenue));
if (query_result.total_won === 1) {
message = "Go, go, go! Congrats for your first deal.";
} else if (query_result.max_team_30 === record.planned_revenue) {
message = "Boom! Team record for the past 30 days.";
} else if (query_result.max_team_7 === record.planned_revenue) {
message = "Yeah! Deal of the last 7 days for the team.";
} else if (query_result.max_user_30 === record.planned_revenue) {
message = "You just beat your personal record for the past 30 days.";
} else if (query_result.max_user_7 === record.planned_revenue) {
message = "You just beat your personal record for the past 7 days.";
}
}
return message;
}
return this._super(...arguments);
},
});

View file

@ -0,0 +1,34 @@
/** @odoo-module */
import tour from 'web_tour.tour';
tour.register('create_crm_team_tour', {
url: "/web",
test: true,
}, [
...tour.stepUtils.goToAppSteps('crm.crm_menu_root'),
{
trigger: 'button[data-menu-xmlid="crm.crm_menu_config"]',
}, {
trigger: 'a[data-menu-xmlid="crm.crm_team_config"]',
}, {
trigger: 'button.o_list_button_add',
}, {
trigger: 'input[id="name"]',
run: 'text My CRM Team',
}, {
trigger: 'button.o-kanban-button-new',
}, {
trigger: 'div.modal-dialog tr:contains("Test Salesman") input.form-check-input',
run: 'click',
}, {
trigger: 'div.modal-dialog tr:contains("Test Sales Manager") input.form-check-input',
run: 'click',
}, {
trigger: 'div.modal-dialog tr:contains("Test Sales Manager") input.form-check-input:checked',
run: () => {},
}, {
trigger: 'button.o_select_button',
},
...tour.stepUtils.saveForm()
]);

View file

@ -0,0 +1,56 @@
odoo.define('crm.crm_email_and_phone_propagation', function (require) {
'use strict';
const tour = require('web_tour.tour');
tour.register('crm_email_and_phone_propagation_edit_save', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(),
{
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
content: 'open crm app',
}, {
trigger: '.o_kanban_record .o_kanban_record_title span:contains(Test Lead Propagation)',
content: 'Open the first lead',
run: 'click',
}, {
trigger: '.o_form_button_save',
extra_trigger: '.o_form_editable .o_field_widget[name=email_from] input',
content: 'Save the lead',
run: 'click',
},
]);
tour.register('crm_email_and_phone_propagation_remove_email_and_phone', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(),
{
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
content: 'open crm app',
}, {
trigger: '.o_kanban_record .o_kanban_record_title span:contains(Test Lead Propagation)',
content: 'Open the first lead',
run: 'click',
}, {
trigger: '.o_form_editable .o_field_widget[name=email_from] input',
extra_trigger: '.o_form_editable .o_field_widget[name=phone] input',
content: 'Remove the email and the phone',
run: function (action) {
action.remove_text("", ".o_form_editable .o_field_widget[name=email_from] input");
action.remove_text("", ".o_form_editable .o_field_widget[name=phone] input");
},
}, {
trigger: '.o_back_button',
// wait the warning message to be visible
extra_trigger: '.o_form_sheet_bg .fa-exclamation-triangle:not(.o_invisible_modifier)',
content: 'Save the lead and exit to kanban',
run: 'click',
},{
trigger: '.o_kanban_renderer',
}
]);
});

View file

@ -0,0 +1,96 @@
/** @odoo-module */
import tour from 'web_tour.tour';
const today = moment();
tour.register('crm_forecast', {
test: true,
url: "/web",
}, [
tour.stepUtils.showAppsMenuItem(),
{
trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
content: "open crm app",
}, {
trigger: '.dropdown-toggle[data-menu-xmlid="crm.crm_menu_report"]',
content: 'Open Reporting menu',
run: 'click',
}, {
trigger: '.dropdown-item[data-menu-xmlid="crm.crm_menu_forecast"]',
content: 'Open Forecast menu',
run: 'click',
}, {
trigger: '.o_column_quick_create:contains(Add next month)',
content: 'Wait page loading'
}, {
trigger: ".o-kanban-button-new",
content: "click create",
run: 'click',
}, {
trigger: ".o_field_widget[name=name] input",
content: "complete name",
run: "text Test Opportunity 1",
}, {
trigger: ".o_field_widget[name=expected_revenue] input",
content: "complete expected revenue",
run: "text 999999",
}, {
trigger: "button.o_kanban_edit",
content: "edit lead",
}, {
trigger: "div[name=date_deadline] input",
content: "complete expected closing",
run: `text ${today.format("MM/DD/YYYY")}`,
}, {
trigger: "div[name=date_deadline] input",
content: "click to make the datepicker disappear",
run: "click"
}, {
trigger: "body:not(:has(div.bootstrap-datetimepicker-widget))",
content: "wait for date_picker to disappear",
run: function () {},
}, {
trigger: '.o_back_button',
content: 'navigate back to the kanban view',
position: "bottom",
run: "click"
}, {
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Opportunity 1')",
content: "move to the next month",
run: function (actions) {
const undefined_groups = $('.o_column_title:contains("None")').length;
actions.drag_and_drop_native(`.o_opportunity_kanban .o_kanban_group:eq(${1 + undefined_groups})`, this.$anchor);
},
}, {
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Opportunity 1')",
content: "edit lead",
run: "click"
}, {
trigger: ".o_field_widget[name=date_deadline] input",
content: "complete expected closing",
run: function (actions) {
actions.text(`text ${moment(today).add(5, 'months').startOf('month').subtract(1, 'days').format("MM/DD/YYYY")}`, this.$anchor);
this.$anchor[0].dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
},
}, {
trigger: "body:not(:has(div.bootstrap-datetimepicker-widget))",
content: "wait for date_picker to disappear",
run: function () {},
}, {
trigger: ".o_field_widget[name=probability] input",
content: "max out probability",
run: "text 100"
}, {
trigger: '.o_back_button',
content: 'navigate back to the kanban view',
position: "bottom",
run: "click"
}, {
trigger: '.o_kanban_add_column',
content: "add next month",
run: "click"
}, {
trigger: ".o_kanban_record:contains('Test Opportunity 1'):contains('Won')",
content: "assert that the opportunity has the Won banner",
run: function () {},
}
]);

View file

@ -0,0 +1,87 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('crm_rainbowman', {
test: true,
url: "/web",
}, [
tour.stepUtils.showAppsMenuItem(),
{
trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
content: "open crm app",
}, {
trigger: ".o-kanban-button-new",
content: "click create",
}, {
trigger: ".o_field_widget[name=name] input",
content: "complete name",
run: "text Test Lead 1",
}, {
trigger: ".o_field_widget[name=expected_revenue] input",
content: "complete expected revenue",
run: "text 999999997",
}, {
trigger: "button.o_kanban_add",
content: "create lead",
}, {
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 1')",
content: "move to won stage",
run: "drag_and_drop_native .o_opportunity_kanban .o_kanban_group:has(.o_column_title:contains('Won')) "
}, {
trigger: ".o_reward_rainbow",
extra_trigger: ".o_reward_rainbow",
run: function () {} // check rainbowman is properly displayed
}, {
// This step and the following simulates the fact that after drag and drop,
// from the previous steps, a click event is triggered on the window element,
// which closes the currently shown .o_kanban_quick_create.
trigger: ".o_kanban_renderer",
}, {
trigger: ".o_kanban_renderer:not(:has(.o_kanban_quick_create))",
run() {},
}, {
trigger: ".o-kanban-button-new",
content: "create second lead",
}, {
trigger: ".o_field_widget[name=name] input",
content: "complete name",
run: "text Test Lead 2",
}, {
trigger: ".o_field_widget[name=expected_revenue] input",
content: "complete expected revenue",
run: "text 999999998",
}, {
trigger: "button.o_kanban_add",
content: "create lead",
}, {
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 2')",
run: function () {} // wait for the record to be properly created
}, {
// move first test back to new stage to be able to test rainbowman a second time
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 1')",
content: "move back to new stage",
run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(0) "
}, {
trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 2')",
content: "click on second lead",
}, {
trigger: ".o_statusbar_status button[data-value='4']",
content: "move lead to won stage",
},
...tour.stepUtils.saveForm(),
{
trigger: ".o_statusbar_status button[data-value='1']",
extra_trigger: ".o_reward_rainbow",
content: "move lead to previous stage & rainbowman appears",
}, {
trigger: "button[name=action_set_won_rainbowman]",
content: "click button mark won",
},
...tour.stepUtils.saveForm(),
{
trigger: ".o_menu_brand",
extra_trigger: ".o_reward_rainbow",
content: "last rainbowman appears",
}
]);

Binary file not shown.