19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,8 +1,11 @@
.o_field_analytic_distribution {
height: auto;
.analytic_distribution_placeholder {
height: 1.5em;
width: 20px;
color: grey;
opacity: 1;
}
.o_input_dropdown {
@ -41,8 +44,11 @@
}
.analytic_distribution_popup {
width: 400px;
min-width: 400px;
max-width: $o-form-sheet-min-width;
max-height: 50vh;
// this ensures that analytic distribution widget does not cover "new model" or "search more" modals
z-index: $zindex-modal - 1;
white-space: nowrap;
cursor: default;
@ -67,5 +73,12 @@
tr:hover {
outline: none !important;
}
.numeric_column_width {
max-width: 105px;
// width: 88px;
}
.w-20px {
width: 20px;
}
}
}

View file

@ -1,70 +1,84 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="analytic.AnalyticDistribution" owl="1">
<t t-name="analytic.AnalyticDistribution">
<div class="o_field_tags d-inline-flex flex-wrap mw-100" t-att-class="{'o_tags_input o_input': !props.readonly}" t-ref="analyticDistribution" t-on-keydown="onWidgetKeydown">
<TagsList tags="this.tags"/>
<TagsList tags="planSummaryTags()"/>
<div t-if="!props.readonly" class="o_input_dropdown d-inline-flex w-100" tabindex="0" t-ref="mainElement" t-on-focus="onMainElementFocus" t-on-click="onMainElementFocus">
<span class="analytic_distribution_placeholder"/>
<a role="button" class="o_dropdown_button" draggable="false"/>
<span class="analytic_distribution_placeholder"><t t-if="!state.formattedData.length" t-esc="props.placeholder"/></span>
<span class="o_dropdown_button" />
<t t-call="analytic.AnalyticDistributionPopup"/>
</div>
</div>
</t>
<t t-name="analytic.AnalyticDistributionPopup" owl="1">
<div class="analytic_distribution_popup o-dropdown-menu show rounded py-0" t-if="state.showDropdown" t-ref="analyticDropdown">
<t t-name="analytic.AnalyticDistributionPopup">
<div class="popover analytic_distribution_popup dropdown-menu o-dropdown--menu show rounded py-0 overflow-x-hidden" t-if="state.showDropdown" t-ref="analyticDropdown">
<div class="popover-header sticky-top">
<div class="d-flex">
<div class="h5 mt-2 me-auto">
Analytic
<span t-if="tags.length and allowSave" class="btn btn-link" t-on-click="onSaveNew">New Model</span>
<span t-if="allowSave" class="btn btn-link" t-on-click="onSaveNew" title="Save as new analytic distribution model">New Model</span>
</div>
<div class="popupButtons">
<span class="o_button ms-2 cursor-pointer" t-on-click.stop="() => this.closeAnalyticEditor()"><span class="fa fa-close"/></span>
<span class="btn o_button" t-on-click.stop="() => this.closeAnalyticEditor()" title="Close"><span class="fa fa-close"/></span>
</div>
</div>
</div>
<div class="p-2">
<span t-if="!sortedList.length">No plans available</span>
<t t-foreach="sortedList" t-as="plan" t-key="plan.id">
<table class="o_list_table table table-sm table-hover o_analytic_table mb-2 table-borderless" t-attf-id="plan_{{plan.id}}">
<div class="popover-body p-2 table-responsive" style="max-width: 100vw;">
<span t-if="!allPlans.length">No analytic plans found</span>
<table t-else="" class="o_list_table table table-sm table-hover o_analytic_table mb-2 table-striped">
<t t-set="totals" t-value="planTotals()"/>
<thead>
<tr class="border-bottom">
<th class="o_analytic_account_name">
<t t-esc="plan.name"/>
<t t-if="plan.account_count === 0"> (no accounts)</t>
<span t-if="plan.applicability === 'mandatory'" t-attf-class="o_status d-inline-block o_analytic_status_{{groupStatus(plan.id)}}" t-att-title="statusDescription(plan.id)"/>
<th t-foreach="allPlans" t-as="plan" t-key="plan.id">
<t t-if="props.multi_edit">
<a t-if="state.update_plan[plan.column_name]" t-on-click="() => state.update_plan[plan.column_name] = false" href="#">Don't update</a>
<a t-else="" t-on-click="() => state.update_plan[plan.column_name] = true" href="#">Update</a>
<br/>
</t>
<span t-out="plan.name"/>
<t t-if="state.update_plan[plan.column_name]">(<span t-att-class="totals[plan.id].class" t-out="totals[plan.id].formattedValue"/>)</t>
</th>
<th class="numeric_column_width">Percentage</th>
<th t-if="valueColumnEnabled" class="numeric_column_width" t-out="props.record.fields[props.amount_field].string"/>
<th class="deleteColumn w-20px"/>
</tr>
<t t-foreach="plan.distribution" t-as="dist_tag" t-key="dist_tag.id">
<tr t-attf-class="{{tagIsReady(dist_tag) and 'ready' or !!dist_tag.analytic_account_id and 'to_remove' or 'incomplete'}} tag_{{dist_tag.id}}">
<td class="o_analytic_account_name">
<AutoComplete
id="dist_tag.id.toString()"
placeholder="'Search Analytic Account'"
value="dist_tag.analytic_account_name"
sources="sourcesAnalyticAccount(plan.id)"
autoSelect="true"
onSelect.alike="(option, params) => this.onSelect(option, params, dist_tag)"
onChange.alike="({inputValue}) => this.autoCompleteInputChanged(dist_tag, inputValue)"/>
</thead>
<tbody>
<tr t-foreach="state.formattedData" t-as="line" t-key="line.id" t-att-id="line_index" t-att-name="'line_' + line_index">
<Record t-props="recordProps(line)" t-slot-scope="data">
<t t-foreach="Object.keys(data.record.fields).filter((f) => f.startsWith('x_plan') || f == 'account_id')" t-as="field" t-key="field">
<td t-att-style="state.update_plan[field] ? '' : '--table-bg-state: var(--table-hover-bg);'">
<Field t-if="state.update_plan[field]"
id="field"
name="field"
record="data.record"
domain="data.record.fields[field].domain"
canOpen="false"
canCreate="false"
canCreateEdit="false"
canQuickCreate="false"/>
</td>
</t>
<td class="numeric_column_width">
<Field id="'percentage'" name="'percentage'" record="data.record"/>
</td>
<td class="o_analytic_percentage">
<input
class="o_input"
inputmode="numeric"
type="text"
t-att-value="formatPercentage(dist_tag.percentage)"
t-on-click.stop=""
t-on-change="(ev) => this.percentageChanged(dist_tag, ev)"/>
<td t-if="valueColumnEnabled" class="numeric_column_width">
<Field id="props.amount_field" name="props.amount_field" record="data.record"/>
</td>
<td>
<span t-if="dist_tag.analytic_account_id" class="fa fa-trash-o cursor-pointer" t-on-click.stop="() => this.deleteTag(dist_tag.id, dist_tag.group_id)"/>
<td class="w-20px">
<span class="fa fa-trash-o cursor-pointer" t-on-click.stop="() => this.deleteLine(line_index)"/>
</td>
</tr>
</t>
</table>
</t>
<div tabindex="0" class="hidden-focus"/>
</Record>
</tr>
<tr>
<td t-on-click.stop.prevent="addLine" class="o_field_x2many_list_row_add" t-att-colspan="allPlans.length + 2 + valueColumnEnabled">
<a href="#" t-ref="addLineButton" tabindex="0">Add a Line</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</t>

View file

@ -0,0 +1,88 @@
import { registry } from "@web/core/registry";
import { ORM } from "@web/core/orm_service";
import { unique } from "@web/core/utils/arrays";
import { Deferred } from "@web/core/utils/concurrency";
class RequestBatcherORM extends ORM {
constructor() {
super();
this.searchReadBatches = {};
this.searchReadBatchId = 1;
this.batches = {};
}
/**
* @param {number[]} ids
* @param {any[]} keys
* @param {Function} callback
* @returns {Promise<any>}
*/
async batch(ids, keys, callback) {
const key = JSON.stringify(keys);
let batch = this.batches[key];
if (!batch) {
batch = {
deferred: new Deferred(),
scheduled: false,
ids: [],
};
this.batches[key] = batch;
}
batch.ids = unique([...batch.ids, ...ids]);
if (!batch.scheduled) {
batch.scheduled = true;
Promise.resolve().then(async () => {
delete this.batches[key];
let result;
try {
result = await callback(batch.ids);
} catch (e) {
return batch.deferred.reject(e);
}
batch.deferred.resolve(result);
});
}
return batch.deferred;
}
/**
* Entry point to batch "read" calls. If the `fields` and `resModel`
* arguments have already been called, the given ids are added to the
* previous list of ids to perform a single read call. Once the server
* responds, records are then dispatched to the callees based on the
* given ids arguments (kept in the closure).
*
* @param {string} resModel
* @param {number[]} resIds
* @param {string[]} fields
* @returns {Promise<Object[]>}
*/
async read(resModel, resIds, fields, kwargs) {
const records = await this.batch(resIds, ["read", resModel, fields, kwargs], (resIds) =>
super.read(resModel, resIds, fields, kwargs)
);
return records.filter((r) => resIds.includes(r.id));
}
}
export const batchedOrmService = {
async: [
"call",
"create",
"nameGet",
"read",
"formattedReadGroup",
"search",
"searchRead",
"unlink",
"webSearchRead",
"write",
],
start() {
return new RequestBatcherORM();
},
};
registry.category("services").add("batchedOrm", batchedOrmService);

View file

@ -0,0 +1,30 @@
import { SearchModel } from "@web/search/search_model";
const PLAN_REGEX = /^(?:x_)?(x_plan\d+_id|account_id)(_\d+)?$/;
export class AnalyticSearchModel extends SearchModel {
getSearchItems(predicate) {
let searchItems = super.getSearchItems(predicate);
const mapped = Map.groupBy(
searchItems.filter((f) => f.fieldName?.match(PLAN_REGEX)),
(f) => f.fieldName.match(PLAN_REGEX)[1],
);
searchItems = searchItems.filter(
(f) => !f.fieldName?.match(PLAN_REGEX) || mapped.has(f.fieldName)
);
searchItems.forEach((f) => {
if (f.fieldName && mapped.has(f.fieldName) && mapped.get(f.fieldName).length > 1) {
f.options = mapped.get(f.fieldName);
}
});
return searchItems;
}
toggleDateGroupBy(searchItemId, intervalId) {
if (typeof(intervalId) === "number") {
this.toggleSearchItem(intervalId);
} else {
super.toggleDateGroupBy(searchItemId, intervalId);
}
}
}

View file

@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { graphView } from "@web/views/graph/graph_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
export const analyticGraphView = {
...graphView,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_graph", analyticGraphView);

View file

@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
export const analyticKanbanView = {
...kanbanView,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_kanban", analyticKanbanView);

View file

@ -0,0 +1,10 @@
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
export const analyticListView = {
...listView,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_list", analyticListView);

View file

@ -0,0 +1,21 @@
import { PivotRenderer } from "@web/views/pivot/pivot_renderer";
export class AnalyticPivotRenderer extends PivotRenderer {
/*
* Override to avoid using incomplete groupByItems
*/
onGroupBySelected({ itemId, optionId }) {
if (typeof(optionId) === "number") {
itemId = optionId;
}
let searchItems = this.env.searchModel.getSearchItems(
(searchItem) =>
["groupBy", "dateGroupBy"].includes(searchItem.type) && !searchItem.custom
)
searchItems = [...searchItems, ...searchItems.flatMap((f) => f.options).filter((f) => typeof(f?.id) === "number")]
const { fieldName } = searchItems.find(({ id }) => id === itemId);
this.model.addGroupBy({ ...this.dropdown.cellInfo, fieldName, interval: optionId });
}
}

View file

@ -0,0 +1,12 @@
import { registry } from "@web/core/registry";
import { pivotView } from "@web/views/pivot/pivot_view";
import { AnalyticSearchModel } from "@analytic/views/analytic_search_model";
import { AnalyticPivotRenderer } from "@analytic/views/pivot/pivot_renderer";
export const analyticPivotView = {
...pivotView,
Renderer: AnalyticPivotRenderer,
SearchModel: AnalyticSearchModel,
};
registry.category("views").add("analytic_pivot", analyticPivotView);

View file

@ -0,0 +1,365 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame, edit, getActiveElement, press } from "@odoo/hoot-dom";
import {
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { defineAnalyticModels } from "./analytic_test_helpers";
defineAnalyticModels();
class AccountAnalyticAccount extends models.Model {
_name = "account.analytic.account";
name = fields.Char({ string: "Name" });
plan_id = fields.Many2one({ string: "Plan", relation: "account.analytic.plan" });
root_plan_id = fields.Many2one({ string: "Root Plan", relation: "account.analytic.plan" });
color = fields.Integer({ string: "Color" });
code = fields.Char({ string: "Ref" });
partner_id = fields.Many2one({ string: "Partner", relation: "partner" });
company_id = fields.Many2one({ relation: "res.company" });
_records = [
{ id: 1, color: 1, root_plan_id: 2, plan_id: 2, name: "RD", company_id: 1 },
{ id: 2, color: 1, root_plan_id: 2, plan_id: 2, name: "HR", company_id: 1 },
{ id: 3, color: 1, root_plan_id: 2, plan_id: 2, name: "FI", company_id: 1 },
{ id: 4, color: 2, root_plan_id: 1, plan_id: 1, name: "Time Off", company_id: 1 },
{ id: 5, color: 2, root_plan_id: 1, plan_id: 1, name: "Operating Costs", company_id: 1 },
{ id: 6, color: 6, root_plan_id: 4, plan_id: 4, name: "Incognito", company_id: 1 },
{ id: 7, color: 5, root_plan_id: 5, plan_id: 5, name: "Belgium", company_id: 1 },
{ id: 8, color: 6, root_plan_id: 5, plan_id: 6, name: "Brussels", company_id: 1 },
{ id: 9, color: 6, root_plan_id: 5, plan_id: 6, name: "Beirut", company_id: 1 },
{ id: 10, color: 6, root_plan_id: 5, plan_id: 6, name: "Berlin", company_id: 1 },
{ id: 11, color: 6, root_plan_id: 5, plan_id: 6, name: "Bruges", company_id: 1 },
{ id: 12, color: 6, root_plan_id: 5, plan_id: 6, name: "Birmingham", company_id: 1 },
{ id: 13, color: 6, root_plan_id: 5, plan_id: 6, name: "Bologna", company_id: 1 },
{ id: 14, color: 6, root_plan_id: 5, plan_id: 6, name: "Bratislava", company_id: 1 },
{ id: 15, color: 6, root_plan_id: 5, plan_id: 6, name: "Budapest", company_id: 1 },
{ id: 16, color: 6, root_plan_id: 5, plan_id: 6, name: "Namur", company_id: 1 },
];
_views = {
search: `
<search>
<field name="name"/>
</search>
`,
list: `
<list>
<field name="name"/>
</list>
`,
};
}
class Plan extends models.Model {
_name = "account.analytic.plan";
name = fields.Char();
applicability = fields.Selection({
string: "Applicability",
selection: [
["mandatory", "Mandatory"],
["optional", "Options"],
["unavailable", "Unavailable"],
],
});
color = fields.Integer({ string: "Color" });
all_account_count = fields.Integer();
parent_id = fields.Many2one({ relation: "account.analytic.plan" });
column_name = fields.Char();
_records = [
{ id: 1, name: "Internal", applicability: "optional", all_account_count: 2, column_name: 'x_plan1_id' },
{ id: 2, name: "Departments", applicability: "mandatory", all_account_count: 3, column_name: 'x_plan2_id' },
{ id: 3, name: "Projects", applicability: "optional", column_name: 'account_id' },
{ id: 4, name: "Hidden", applicability: "unavailable", all_account_count: 1, column_name: 'x_plan4_id' },
{ id: 5, name: "Country", applicability: "optional", all_account_count: 3, column_name: 'x_plan5_id' },
{ id: 6, name: "City", applicability: "optional", all_account_count: 2, parent_id: 5, column_name: 'x_plan5_id' },
];
}
class Move extends models.Model {
line_ids = fields.One2many({
string: "Move Lines",
relation: "aml",
relation_field: "move_line_id",
});
_records = [
{ id: 1, display_name: "INV0001", line_ids: [1, 2] },
{ id: 2, display_name: "INV0002", line_ids: [3, 4] },
];
}
class Aml extends models.Model {
label = fields.Char({ string: "Label" });
amount = fields.Float({ string: "Amount" });
analytic_distribution = fields.Json({ string: "Analytic" });
move_id = fields.Many2one({ string: "Account Move", relation: "move" });
analytic_precision = fields.Integer({ string: "Analytic Precision" });
company_id = fields.Many2one({ relation: "res.company" });
_records = [
{
id: 1,
label: "Developer Time",
amount: 100.0,
analytic_distribution: { "1, 7": 30.3, 3: 69.704 },
analytic_precision: 3,
company_id: 1,
},
{ id: 2, label: "Coke", amount: 100.0, analytic_distribution: {}, company_id: 1 },
{ id: 3, label: "Sprite", amount: 100.0, analytic_distribution: {}, analytic_precision: 3, company_id: 1 },
{ id: 4, label: "", amount: 100.0, analytic_distribution: {}, company_id: 1 },
];
}
defineModels([Aml, AccountAnalyticAccount, Move, Plan]);
test.tags("desktop");
test("analytic field in form view basic features", async () => {
onRpc("account.analytic.plan", "get_relevant_plans", function ({ model }) {
return this.env[model].filter((r) => !r.parent_id && r.applicability !== "unavailable");
});
await mountView({
type: "form",
resModel: "aml",
resId: 1,
arch: `
<form>
<sheet>
<group>
<field name="label"/>
<field name="analytic_distribution" widget="analytic_distribution"/>
<field name="amount"/>
</group>
</sheet>
</form>`,
});
// tags
expect(".o_field_analytic_distribution").toHaveCount(1);
expect(".badge").toHaveCount(2);
expect(".badge .o_tag_badge_text:eq(0)").toHaveText("30.3% RD | 69.7% FI");
expect(".badge .o_tag_badge_text:eq(1)").toHaveText("30.3% Belgium");
// open popup
await contains(".o_field_analytic_distribution .o_input_dropdown").click();
expect(".analytic_distribution_popup").toHaveCount(1);
// contents of popup
expect(".analytic_distribution_popup table:eq(0) tr").toHaveCount(4);
expect(".analytic_distribution_popup table:eq(0) tr:first-of-type #x_plan1_id").toBeFocused();
// change percentage
await contains(
".analytic_distribution_popup table:eq(0) tr:first-of-type .o_field_percentage input"
).edit("19.7001");
// mandatory plan is red
expect("th:contains(Departments) .text-danger:contains(50%)").toHaveCount(1);
// close and open popup with keyboard
await press("Escape");
await animationFrame();
expect(".analytic_distribution_popup").toHaveCount(0);
await press("ArrowDown");
await animationFrame();
expect(".analytic_distribution_popup").toHaveCount(1);
// add a line
await contains(
".analytic_distribution_popup table:eq(0) .o_field_x2many_list_row_add a"
).click();
expect(
".analytic_distribution_popup table:eq(0) tr:nth-of-type(3) .o_field_percentage input"
).toHaveValue("50");
// choose an account for the mandatory plan using the keyboard
await contains(
".analytic_distribution_popup table:eq(0) tr:nth-of-type(3) #x_plan2_id"
).click();
await press("ArrowDown");
await animationFrame();
await press("Enter");
await animationFrame();
// mandatory plan is green
expect(
".analytic_distribution_popup table:eq(0) th:contains(Departments) .text-success:contains(100%)"
).toHaveCount(1);
// tags
await contains(".fa-close").click();
expect(".analytic_distribution_popup").toHaveCount(0);
expect(".badge").toHaveCount(2);
expect(".badge:eq(0) .o_tag_badge_text").toHaveText("30.3% RD | 50% HR | 19.7% FI");
expect(".badge:eq(1) .o_tag_badge_text").toHaveText("30.3% Belgium");
});
test.tags("desktop");
test("analytic field in multi_edit list view + search more", async () => {
onRpc("account.analytic.plan", "get_relevant_plans", function ({ model, kwargs }) {
return this.env[model]
.filter((r) => !r.parent_id && r.applicability !== "unavailable")
.map((r) => ({ ...r, applicability: kwargs.applicability }));
});
onRpc("account.analytic.account", "web_search_read", function ({ model, kwargs }) {
const records = this.env[model]._filter(kwargs.domain);
return {
length: records.length,
records,
};
});
await mountView({
type: "list",
resModel: "aml",
arch: `
<list multi_edit="1">
<field name="label"/>
<field name="analytic_distribution" widget="analytic_distribution" options="{'force_applicability': 'optional'}"/>
<field name="amount"/>
</list>`,
});
expect(".badge").toHaveCount(2);
await contains(".badge:eq(0) .o_tag_badge_text").click();
expect(".analytic_distribution_popup").toHaveCount(0);
// select 2 rows
await contains(".o_data_row:eq(0) .o_list_record_selector input").check();
await contains(".o_data_row:eq(1) .o_list_record_selector input").check();
await contains(".o_data_row:eq(0) .badge:eq(0)").click();
await animationFrame();
expect(".analytic_distribution_popup").toHaveCount(1);
expect(".analytic_distribution_popup:not(:has(.text-success))").toHaveCount(1);
// add a line
await contains(".analytic_distribution_popup .o_field_x2many_list_row_add").click();
await contains(".analytic_distribution_popup tr[name='line_2'] #x_plan5_id").click();
await contains(".analytic_distribution_popup .ui-menu-item:contains(search more)").click();
expect(".modal-dialog .o_list_renderer").toHaveCount(1);
await contains(".modal-dialog .modal-title").click();
await contains(".modal-dialog .o_data_row:nth-of-type(4) .o_data_cell:first-of-type").click();
expect(".modal-dialog .o_list_renderer").toHaveCount(0);
await contains(".fa-close").click();
await contains(".modal-dialog .btn-primary").click();
await animationFrame();
expect(".o_data_row .badge").toHaveCount(4);
expect("tr:nth-of-type(2) .badge:nth-of-type(2) .o_tag_badge_text").toHaveText(
"30.3% Belgium | 69.7% Berlin"
);
});
test.tags("desktop");
test("Rounding, value suggestions, keyboard only", async () => {
onRpc("account.analytic.plan", "get_relevant_plans", function ({ model }) {
return this.env[model].filter(
(r) => !r.parent_id && r.applicability !== "unavailable" && r.all_account_count
);
});
await mountView({
type: "form",
resModel: "move",
resId: 2,
arch: `
<form>
<sheet>
<field name="line_ids">
<list editable="bottom">
<field name="label"/>
<field name="analytic_distribution" widget="analytic_distribution"/>
<field name="amount"/>
</list>
</field>
</sheet>
</form>`,
});
await contains(".o_data_row:nth-of-type(1) .o_list_char").click();
await press("Tab");
await animationFrame();
expect(".analytic_distribution_popup").toHaveCount(1);
// department
await press("Tab");
await press("ArrowDown");
await animationFrame();
await press("Enter"); // choose the RD account
await animationFrame();
await press("Tab"); // tab to country
await press("Tab"); // tab to percentage
await edit("99.9", { confirm: "tab" });
// internal
await animationFrame();
await press("ArrowDown");
await animationFrame();
await press("Enter"); // choose the Time off account
await animationFrame();
await press("Tab"); // tab to departments
await press("Tab"); // tab to country
await press("Tab"); // tab to percentage
await edit("99.99", { confirm: "tab" });
// country
await animationFrame();
await press("Tab"); // tab to departments
await press("Tab"); // tab to country
await press("ArrowDown");
await animationFrame();
await press("Enter"); // choose the Belgium account
await animationFrame();
await press("Tab"); // tab to percentage
await edit("99.999", { confirm: "tab" });
await animationFrame();
// tags
expect(".badge:contains(99.9% RD)").toHaveCount(1);
expect(".badge:contains(99.99% Time Off)").toHaveCount(1);
expect(".badge:contains(100% Belgium)").toHaveCount(1);
// fill department
await press("Tab"); // tab to departments
await press("ArrowDown");
await animationFrame();
await press("ArrowDown");
await animationFrame();
await press("Enter"); // choose the HR account
await animationFrame();
await press("Tab");
await press("Tab");
expect(getActiveElement()).toHaveValue(0.1);
await edit("0.0996", { confirm: "tab" });
await animationFrame();
expect(".badge:contains('99.9% RD | 0.1% HR')").toHaveCount(1);
expect(".text-success:contains('100%')").toHaveCount(1);
// fill country
await press("Tab"); // tab to departments
await press("Tab"); // tab to country
await press("ArrowDown");
await animationFrame();
await press("Enter"); // choose Belgium again
await animationFrame();
await press("Tab"); // tab to percentage
await animationFrame();
expect(getActiveElement()).toHaveValue(0.001);
await edit("0.0006", { confirm: "tab" });
await animationFrame();
expect(".badge:contains('Belgium'):not(:contains('%'))").toHaveCount(1);
// fill internal
const autocomplete = getActiveElement().parentNode;
// choose Operating Costs
while (
autocomplete.querySelector("a[aria-selected='true']")?.textContent !== "Operating Costs"
) {
await press("ArrowDown");
await animationFrame();
}
await press("Enter"); // validate
await animationFrame();
await press("Escape"); // close the popup
await animationFrame();
expect(".badge:contains('99.99% Time Off | 0.01% Operating Costs')").toHaveCount(1);
});

View file

@ -1,338 +0,0 @@
/** @odoo-module **/
import {
click,
editInput,
getFixture,
nextTick,
triggerHotkey,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Analytic", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
"account.analytic.account": {
fields: {
plan_id: { string: "Plan", type: "many2one", relation: "plan" },
root_plan_id: { string: "Root Plan", type: "many2one", relation: "plan" },
color: { string: "Color", type: "integer" },
code: { string: "Ref", type: "string"},
partner_id: { string: "Partner", type: "many2one", relation: "partner" },
},
records: [
{id: 1, color: 1, root_plan_id: 2, plan_id: 2, name: "RD" },
{id: 2, color: 1, root_plan_id: 2, plan_id: 2, name: "HR" },
{id: 3, color: 1, root_plan_id: 2, plan_id: 2, name: "FI" },
{id: 4, color: 2, root_plan_id: 1, plan_id: 1, name: "Time Off" },
{id: 5, color: 2, root_plan_id: 1, plan_id: 1, name: "Operating Costs" },
{id: 6, color: 6, root_plan_id: 4, plan_id: 4, name: "Incognito" },
{id: 7, color: 5, root_plan_id: 5, plan_id: 5, name: "Belgium" },
{id: 8, color: 6, root_plan_id: 5, plan_id: 6, name: "Brussels" },
{id: 9, color: 6, root_plan_id: 5, plan_id: 6, name: "Beirut" },
{id: 10, color: 6, root_plan_id: 5, plan_id: 6, name: "Berlin" },
{id: 11, color: 6, root_plan_id: 5, plan_id: 6, name: "Bruges" },
{id: 12, color: 6, root_plan_id: 5, plan_id: 6, name: "Birmingham" },
{id: 13, color: 6, root_plan_id: 5, plan_id: 6, name: "Bologna" },
{id: 14, color: 6, root_plan_id: 5, plan_id: 6, name: "Bratislava" },
{id: 15, color: 6, root_plan_id: 5, plan_id: 6, name: "Budapest" },
{id: 16, color: 6, root_plan_id: 5, plan_id: 6, name: "Namur" },
],
},
plan: {
fields: {
applicability: {
string: "Applicability",
type: "selection",
selection: [
["mandatory", "Mandatory"],
["optional", "Options"],
["unavailable", "Unavailable"],
],
},
color: { string: "Color", type: "integer" },
all_account_count: { type: "integer" },
parent_id: { type: "many2one", relation: "plan" },
},
records: [
{ id: 1, name: "Internal", applicability: "optional", all_account_count: 2 },
{ id: 2, name: "Departments", applicability: "mandatory", all_account_count: 3 },
{ id: 3, name: "Projects", applicability: "optional" },
{ id: 4, name: "Hidden", applicability: "unavailable", all_account_count: 1 },
{ id: 5, name: "Country", applicability: "optional", all_account_count: 3 },
{ id: 6, name: "City", applicability: "optional", all_account_count: 2, parent_id: 5 },
],
},
aml: {
fields: {
label: { string: "Label", type: "char" },
amount: { string: "Amount", type: "float" },
analytic_distribution: { string: "Analytic", type: "json" },
move_id: { string: "Account Move", type: "many2one", relation: "move" },
},
records: [
{ id: 1, label: "Developer Time", amount: 100.00, analytic_distribution: {"1": 30.3, "3": 69.7}},
{ id: 2, label: "Coke", amount: 100.00, analytic_distribution: {}},
{ id: 3, label: "Sprite", amount: 100.00, analytic_distribution: {}},
{ id: 4, label: "", amount: 100.00, analytic_distribution: {}},
],
},
partner: {
fields: {
name: { string: "Name", type: "char" },
},
records: [
{ id: 1, name: "Great Partner" },
],
},
move: {
fields: {
line_ids: { string: "Move Lines", type: "one2many", relation: "aml", relation_field: "move_line_id" },
},
records: [
{ id: 1, display_name: "INV0001", line_ids: [1, 2]},
{ id: 2, display_name: "INV0002", line_ids: [3, 4]},
],
},
"decimal.precision": {
fields: {
name: { string: "Name", type: "char" },
digits: { string: "Digits", type: "int" },
},
records: [
{ id: 1, name: "Percentage Analytic", digits: 2}
]
}
},
views: {
"account.analytic.account,false,search": `<search/>`,
"account.analytic.account,analytic.view_account_analytic_account_list_select,list": `
<tree>
<field name="name"/>
</tree>
`,
}
};
setupViewRegistries();
});
QUnit.module("AnalyticDistribution");
QUnit.test("analytic field in form view basic features", async function (assert) {
await makeView({
type: "form",
resModel: "aml",
resId: 1,
serverData,
arch: `
<form>
<sheet>
<group>
<field name="label"/>
<field name="analytic_distribution" widget="analytic_distribution"/>
<field name="amount"/>
</group>
</sheet>
</form>`,
mockRPC(route, { kwargs, method, model }) {
if (method === "get_relevant_plans" && model === "account.analytic.plan") {
return Promise.resolve(
serverData.models['plan'].records.filter((r) => !r.parent_id && r.applicability !== "unavailable")
);
}
},
});
assert.containsOnce(target, ".o_field_analytic_distribution", "widget should be visible");
assert.containsN(target, ".badge", 2, "should contain 2 tags");
assert.strictEqual(target.querySelector(".badge .o_tag_badge_text").textContent, "RD 30.3%",
"should have rendered tag 'RD 30.3%'"
);
assert.strictEqual(target.querySelectorAll(".badge .o_tag_badge_text")[1].textContent, "FI 69.7%",
"should have rendered tag 'FI 69.7%'"
);
assert.containsN(target, ".o_delete", 2, "tags should contain a delete button");
let badge1 = target.querySelector('.badge');
await click(badge1, ".o_tag_badge_text");
assert.containsN(target, ".analytic_distribution_popup", 1, "popup should be visible");
let popup = target.querySelector('.analytic_distribution_popup');
let planTable = popup.querySelectorAll('table')[0];
assert.strictEqual(planTable.id, "plan_2", "mandatory plan appears first");
assert.containsN(planTable, 'tr', 4,
"first plan contains 4 rows including: plan title, 2 tags, empty tag"
);
assert.strictEqual(document.activeElement, planTable.querySelector('input'),
"focus is on the first analytic account"
);
triggerHotkey("Tab");
const input = document.activeElement;
await editInput(input, null, "19");
assert.containsOnce(planTable, '.o_analytic_status_invalid', "Mandatory plan has invalid status");
triggerHotkey("Escape");
await nextTick();
assert.containsNone(target, '.analytic_distribution_popup', "The popup should be closed");
triggerHotkey("arrowdown"); //opens the popup again
await nextTick();
popup = target.querySelector('.analytic_distribution_popup');
planTable = popup.querySelectorAll('table')[0];
let incompleteInputName = planTable.querySelector('tr.incomplete .o_analytic_account_name input');
assert.strictEqual(document.activeElement, incompleteInputName,
"focus returns to the first incomplete tag"
);
triggerHotkey("arrowdown");
await nextTick();
triggerHotkey("Tab");
await nextTick();
assert.strictEqual(document.activeElement.value, "11.3%", "remainder percentage is prepopulated");
await click(target, '.fa-close');
assert.containsNone(target, '.analytic_distribution_popup', "The popup should be closed");
assert.containsNone(target, '.o_field_invalid', "Distribution is valid");
assert.containsN(target, ".badge", 3, "should contain 3 tags");
});
QUnit.test("analytic field in multi_edit list view + search more", async function (assert) {
await makeView({
type: "list",
resModel: "aml",
serverData,
arch: `
<tree multi_edit="1">
<field name="label"/>
<field name="analytic_distribution" widget="analytic_distribution" options="{'force_applicability': 'optional'}"/>
<field name="amount"/>
</tree>`,
mockRPC(route, { kwargs, method, model }) {
if (method === "get_relevant_plans" && model === "account.analytic.plan") {
assert.equal(kwargs.applicability, "optional")
return Promise.resolve(
serverData.models['plan'].records
.filter((r) => !r.parent_id && r.applicability !== "unavailable")
.map((r) => ({...r, applicability: "optional"}))
);
}
},
});
assert.containsN(target, ".badge", 2, "should contain 2 tags");
let badge1 = target.querySelector('.badge');
await click(badge1, ".o_tag_badge_text");
assert.containsNone(target, '.analytic_distribution_popup', "The popup should not open in readonly mode");
// select 2 rows
const amlrows = target.querySelectorAll(".o_data_row");
await click(amlrows[0].querySelector(".o_list_record_selector input"));
await click(amlrows[1].querySelector(".o_list_record_selector input"));
await click(badge1, ".o_tag_badge_text");
await nextTick();
assert.containsN(target, ".analytic_distribution_popup", 1, "popup should be visible");
let popup = target.querySelector('.analytic_distribution_popup');
assert.containsNone(popup, 'th span', "All plans are optional with no status indicator");
let incompleteCountryTag = popup.querySelector("table#plan_5 .incomplete .o_analytic_account_name input");
await click(incompleteCountryTag);
await click(target.querySelector(".o_m2o_dropdown_option_search_more"));
assert.containsN(target, ".modal-dialog .o_list_renderer", 1, "select create list dialog is visible");
// select 2 analytic accounts
let accountRows = [...target.querySelectorAll(".modal-dialog .o_data_row")];
for (const row of accountRows.slice(0,2)) {
await click(row.querySelector(".o_list_record_selector input"));
}
await click(target.querySelector(".o_select_button"));
let percentageEls = [...popup.querySelectorAll("table#plan_5 .o_analytic_percentage input")];
let expectedPercentages = ['100%', '0%', '0%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `1: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
// modify the percentage of tag 1, tag 2 is filled
await editInput(percentageEls[0], null, "50");
expectedPercentages = ['50%', '50%', '0%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `2: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
// modify the percentage of tag 1, last empty tag (tag 3) is filled
await editInput(percentageEls[0], null, "40");
expectedPercentages = ['40%', '50%', '10%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `3: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
// replace the first analytic account with 4 accounts
triggerHotkey("shift+Tab");
await click(document.activeElement);
await click(target.querySelector(".o_m2o_dropdown_option_search_more"));
accountRows = [...target.querySelectorAll(".modal-dialog .o_data_row")];
for (const row of accountRows.slice(0,4)) {
await click(row.querySelector(".o_list_record_selector input"));
}
await click(target.querySelector(".o_select_button"));
percentageEls = [...popup.querySelectorAll("table#plan_5 .o_analytic_percentage input")];
expectedPercentages = ['40%', '50%', '10%', '0%', '0%', '0%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `4: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
// modify percentage of the tag 1 (focused), balance goes to the first zero (tag 4)
await editInput(document.activeElement, null, "10");
expectedPercentages = ['10%', '50%', '10%', '30%', '0%', '0%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `5: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
// modify percentage of tag 4, balance goes to the first zero (tag 5)
await editInput(percentageEls[3], null, "20");
expectedPercentages = ['10%', '50%', '10%', '20%', '10%', '0%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `6: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
// change tag 4 to 0%, the balance resets it to its previous value
await editInput(percentageEls[3], null, "0");
percentageEls = [...popup.querySelectorAll("table#plan_5 .o_analytic_percentage input")];
expectedPercentages = ['10%', '50%', '10%', '20%', '10%', '0%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `7: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
// delete tag 3, balance goes to last empty tag (tag 5)
let trashIcons = [...document.querySelectorAll("table#plan_5 .fa-trash-o")];
assert.equal(trashIcons.length, 5);
await click(trashIcons[2]);
percentageEls = [...popup.querySelectorAll("table#plan_5 .o_analytic_percentage input")];
expectedPercentages = ['10%', '50%', '20%', '10%', '10%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `8: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
assert.equal(popup.querySelector("table#plan_5 tr:last-of-type .o_analytic_account_name input").value, "",
"Last tag's account is empty");
// apply the changes to both move lines
triggerHotkey("Escape");
await nextTick();
await click(target.querySelector(".modal-dialog .btn-primary"));
assert.containsN(target, ".badge", 12, "should contain 2 rows of 6 tags each");
});
});

View file

@ -0,0 +1,15 @@
import { AccountAnalyticAccount } from "@analytic/../tests/mock_server/mock_models/account_analytic_account";
import { AccountAnalyticLine } from "@analytic/../tests/mock_server/mock_models/account_analytic_line";
import { AccountAnalyticPlan } from "@analytic/../tests/mock_server/mock_models/account_analytic_plan";
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { defineModels } from "@web/../tests/web_test_helpers";
export const analyticModels = {
AccountAnalyticAccount,
AccountAnalyticLine,
AccountAnalyticPlan,
};
export function defineAnalyticModels() {
return defineModels({ ...mailModels, ...analyticModels });
}

View file

@ -0,0 +1,121 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { contains, makeMockServer, mountView } from "@web/../tests/web_test_helpers";
import { defineAnalyticModels } from "./analytic_test_helpers";
defineAnalyticModels()
const searchViewArch = `
<search>
<filter name="account_id" context="{'group_by': 'account_id'}"/>
<filter name="x_plan1_id" context="{'group_by': 'x_plan1_id'}"/>
<filter name="x_plan1_id_1" context="{'group_by': 'x_plan1_id_1'}"/>
<filter name="x_plan1_id_2" context="{'group_by': 'x_plan1_id_2'}"/>
</search>
`
beforeEach(async () => {
const { env } = await makeMockServer();
const root = env['account.analytic.plan'].create({ name: "State" });
const eu = env['account.analytic.plan'].create({ name: "Europe", parent_id: root, root_id: root });
const be = env['account.analytic.plan'].create({ name: "Belgium", parent_id: eu, root_id: root });
const fr = env['account.analytic.plan'].create({ name: "France", parent_id: eu, root_id: root });
const am = env['account.analytic.plan'].create({ name: "America", parent_id: root, root_id: root });
const us = env['account.analytic.plan'].create({ name: "USA", parent_id: am, root_id: root });
const accounts = env['account.analytic.account'].create([
{ plan_id: be, name: "Brussels" },
{ plan_id: be, name: "Antwerpen" },
{ plan_id: fr, name: "Paris" },
{ plan_id: fr, name: "Marseille" },
{ plan_id: us, name: "New York" },
{ plan_id: us, name: "Los Angeles" },
])
env["account.analytic.line"].create([
{ x_plan1_id: accounts[0], x_plan1_id_1: eu, x_plan1_id_2: be, analytic_distribution: {[accounts[0]]: 100}, amount: 1 },
{ x_plan1_id: accounts[1], x_plan1_id_1: eu, x_plan1_id_2: be, analytic_distribution: {[accounts[1]]: 100}, amount: 10 },
{ x_plan1_id: accounts[2], x_plan1_id_1: eu, x_plan1_id_2: fr, analytic_distribution: {[accounts[2]]: 100}, amount: 100 },
{ x_plan1_id: accounts[3], x_plan1_id_1: eu, x_plan1_id_2: fr, analytic_distribution: {[accounts[3]]: 100}, amount: 1000 },
{ x_plan1_id: accounts[4], x_plan1_id_1: am, x_plan1_id_2: us, analytic_distribution: {[accounts[4]]: 100}, amount: 10000 },
{ x_plan1_id: accounts[5], x_plan1_id_1: am, x_plan1_id_2: us, analytic_distribution: {[accounts[5]]: 100}, amount: 100000 },
]);
});
test.tags("desktop");
test("Analytic hierachy in list view", async () => {
await mountView({
type: "list",
resModel: "account.analytic.line",
arch: `<list js_class="analytic_list"><field name="account_id"/></list>`,
searchViewId: false,
searchViewArch: searchViewArch,
});
await contains(".o_searchview_dropdown_toggler").click()
await contains(".o_group_by_menu .o_accordion_toggle").click();
expect(".o_group_by_menu .o_accordion_values .o-dropdown-item").toHaveCount(3);
await contains(".o_group_by_menu .o_accordion_values .o-dropdown-item:last").click();
expect(".o_facet_value").toHaveText("Country")
expect(".o_list_table tbody .o_group_name").toHaveCount(3);
});
test.tags("desktop");
test("Analytic hierachy in kanban view", async () => {
await mountView({
type: "kanban",
resModel: "account.analytic.line",
arch: `
<kanban js_class="analytic_kanban">
<templates>
<t t-name="card">
<field class="text-muted" name="account_id"/>
</t>
</templates>
</kanban>`,
searchViewId: false,
searchViewArch: searchViewArch,
});
await contains(".o_searchview_dropdown_toggler").click()
await contains(".o_group_by_menu .o_accordion_toggle").click();
expect(".o_group_by_menu .o_accordion_values .o-dropdown-item").toHaveCount(3);
await contains(".o_group_by_menu .o_accordion_values .o-dropdown-item:last").click();
expect(".o_facet_value").toHaveText("Country")
expect(".o_kanban_renderer .o_kanban_group").toHaveCount(3);
});
test.tags("desktop");
test("Analytic hierachy in pivot view", async () => {
await mountView({
type: "pivot",
resModel: "account.analytic.line",
arch: `
<pivot js_class="analytic_pivot">
<field name="amount" type="measure"/>
</pivot>`,
searchViewId: false,
searchViewArch: searchViewArch,
});
await contains(".o_searchview_dropdown_toggler").click()
await contains(".o_group_by_menu .o_accordion_toggle").click();
expect(".o_group_by_menu .o_accordion_values .o-dropdown-item").toHaveCount(3);
await contains(".o_group_by_menu .o_accordion_values .o-dropdown-item:last").click();
expect(".o_facet_value").toHaveText("Country");
expect(".o_pivot tbody .o_value").toHaveCount(4); // 3 groups + 1 total
// Also check the pivot cell choices
await contains(".o_pivot tbody .o_pivot_header_cell_closed").click()
await contains(".o_popover .o-dropdown-caret").hover()
expect(".o_popover.o-dropdown--menu-submenu span.o-dropdown-item").toHaveCount(3);
});
test.tags("desktop");
test("Analytic hierachy in graph view", async () => {
await mountView({
type: "graph",
resModel: "account.analytic.line",
arch: `<graph js_class="analytic_graph"><field name="account_id"/></graph>`,
searchViewId: false,
searchViewArch: searchViewArch,
});
await contains(".o_searchview_dropdown_toggler").click()
await contains(".o_group_by_menu .o_accordion_toggle").click();
expect(".o_group_by_menu .o_accordion_values .o-dropdown-item").toHaveCount(3);
await contains(".o_group_by_menu .o_accordion_values .o-dropdown-item:last").click();
expect(".o_facet_value").toHaveText("Country")
});

View file

@ -0,0 +1,119 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { contains, makeMockServer, mountView, onRpc } from "@web/../tests/web_test_helpers";
import { defineAnalyticModels } from "./analytic_test_helpers";
defineAnalyticModels()
beforeEach(async () => {
const { env } = await makeMockServer();
const plan = env['account.analytic.plan'].create({ name: "State", root_id: 1 });
const accounts = env['account.analytic.account'].create([
{ plan_id: plan, name: "Brussels" },
{ plan_id: plan, name: "Antwerpen" },
{ plan_id: plan, name: "Paris" },
{ plan_id: plan, name: "Marseille" },
{ plan_id: plan, name: "New York" },
{ plan_id: plan, name: "Los Angeles" },
])
env["account.analytic.line"].create([
{ x_plan1_id: accounts[0], analytic_distribution: {[accounts[0]]: 100}, amount: 1 },
{ x_plan1_id: accounts[1], analytic_distribution: {[accounts[1]]: 100}, amount: 10 },
{ x_plan1_id: accounts[2], analytic_distribution: {[accounts[2]]: 100}, amount: 100 },
{ x_plan1_id: accounts[3], analytic_distribution: {[accounts[3]]: 100}, amount: 1000 },
{ x_plan1_id: accounts[4], analytic_distribution: {[accounts[4]]: 100}, amount: 10000 },
{ x_plan1_id: accounts[5], analytic_distribution: {[accounts[5]]: 100}, amount: 100000 },
]);
});
test.tags("desktop");
test("Analytic single-edit no dynamic", async () => {
onRpc("account.analytic.line", "write", (params) => {
// don't have "to update" information if not in multi edit
expect(params.args[1].analytic_distribution.__update__).toBe(undefined);
});
await mountView({
type: "list",
resModel: "account.analytic.line",
arch: `
<list multi_edit="1" default_order="id DESC">
<field name="account_id"/>
<field name="x_plan1_id"/>
<field name="analytic_distribution" widget="analytic_distribution" options="{'multi_edit': False}"/>
</list>`,
});
// select the first 2 lines to be able to edit
await contains(".o_list_table tbody tr:nth-child(1) .o_list_record_selector input").click();
await contains(".o_list_table tbody tr:nth-child(2) .o_list_record_selector input").click();
await contains(".o_list_table tbody tr:first .o_field_analytic_distribution").click();
await animationFrame();
expect(".analytic_distribution_popup").toHaveCount(1);
// all the fields should be displayed
expect(".analytic_distribution_popup tbody tr:first .o_field_many2one").toHaveCount(1);
// we shouldn't display the button-links to hide/display the fields
expect(".analytic_distribution_popup .o_list_table thead th:first a").toHaveCount(0);
await contains(".o_list_renderer").click();
})
test.tags("desktop");
test("Analytic dynamic multi-edit", async () => {
let to_update;
onRpc("account.analytic.line", "write", (params) => {
expect(params.args[1].analytic_distribution.__update__).toEqual(to_update);
});
await mountView({
type: "list",
resModel: "account.analytic.line",
arch: `
<list multi_edit="1" default_order="id DESC">
<field name="account_id"/>
<field name="x_plan1_id"/>
<field name="analytic_distribution" widget="analytic_distribution" options="{'multi_edit': True}"/>
</list>`,
});
expect(".o_list_table tbody tr:nth-child(1) .o_field_analytic_distribution .o_tag_badge_text").toHaveText("Los Angeles");
expect(".o_list_table tbody tr:nth-child(2) .o_field_analytic_distribution .o_tag_badge_text").toHaveText("New York");
// select the first 2 lines to be able to edit
await contains(".o_list_table tbody tr:nth-child(1) .o_list_record_selector input").click();
await contains(".o_list_table tbody tr:nth-child(2) .o_list_record_selector input").click();
// everything is empty when opening the widget
await contains(".o_list_table tbody tr:first .o_field_analytic_distribution").click();
await animationFrame();
expect(".analytic_distribution_popup").toHaveCount(1);
expect(".analytic_distribution_popup tbody tr:first .o_field_many2one").toHaveCount(0);
await contains(".o_list_renderer").click(); // close the widget
await contains(".modal-footer .btn-secondary").click(); // cancel confirmation
// update the right columns when ticked
to_update = ["x_plan1_id"];
await contains(".o_list_table tbody tr:first .o_field_analytic_distribution").click();
await animationFrame();
await contains(".analytic_distribution_popup .o_list_table thead th:first a").click();
expect(".analytic_distribution_popup tbody tr:first .o_field_many2one").toHaveCount(1);
await contains(".analytic_distribution_popup tbody tr:first .o_field_many2one").click();
await contains(".analytic_distribution_popup tbody tr:first .o_field_many2one input").edit("Brussels", {confirm: false});
await runAllTimers();
await contains(".analytic_distribution_popup tbody tr:first .o_field_many2one .o_input_dropdown a").click();
await contains(".o_list_renderer").click(); // close the widget
// we don't change the value until it's saved
expect(".o_list_table tbody tr:nth-child(1) .o_field_analytic_distribution .o_tag_badge_text").toHaveText("Los Angeles");
expect(".o_list_table tbody tr:nth-child(2) .o_field_analytic_distribution .o_tag_badge_text").toHaveText("New York");
await contains(".modal-footer .btn-primary").click(); // validate confirmation
await runAllTimers();
expect(".o_list_table tbody tr:nth-child(1) .o_field_analytic_distribution .o_tag_badge_text").toHaveText("Brussels");
expect(".o_list_table tbody tr:nth-child(2) .o_field_analytic_distribution .o_tag_badge_text").toHaveText("Brussels");
// everything should be back to like the first time we opened it
to_update = [];
await contains(".o_list_table tbody tr:first .o_field_analytic_distribution").click();
await animationFrame();
expect(".analytic_distribution_popup").toHaveCount(1);
expect(".analytic_distribution_popup tbody tr:first .o_field_many2one").toHaveCount(0);
await contains(".o_list_renderer").click(); // close the widget
await contains(".modal-footer .btn-primary").click(); // validate confirmation
})

View file

@ -0,0 +1,8 @@
import { models, fields } from "@web/../tests/web_test_helpers";
export class AccountAnalyticAccount extends models.ServerModel {
_name = "account.analytic.account";
name = fields.Char()
plan_id = fields.Many2one({ relation: 'account.analytic.plan' })
}

View file

@ -0,0 +1,12 @@
import { models, fields } from "@web/../tests/web_test_helpers";
export class AccountAnalyticLine extends models.ServerModel {
_name = "account.analytic.line";
amount = fields.Float()
account_id = fields.Many2one({ relation: "account.analytic.account" })
x_plan1_id = fields.Many2one({ string: "State", relation: "account.analytic.account" })
x_plan1_id_1 = fields.Many2one({ string: "Continent", relation: "account.analytic.plan" })
x_plan1_id_2 = fields.Many2one({ string: "Country ", relation: "account.analytic.plan" })
analytic_distribution = fields.Json();
}

View file

@ -0,0 +1,21 @@
import { models, fields } from "@web/../tests/web_test_helpers";
export class AccountAnalyticPlan extends models.ServerModel {
_name = "account.analytic.plan";
name = fields.Char()
parent_id = fields.Many2one({ relation: "account.analytic.plan" })
get_relevant_plans() {
return this.filter((plan) => !plan.parent_id).map((plan) => {
return {
"id": plan.id,
"name": plan.name,
"color": plan.color,
"applicability": plan.default_applicability || "optional",
"all_account_count": 1,
"column_name": `x_plan${plan.id}_id`,
}
})
}
}