[IMP] analytic: add search more option

Allow users to find analytic accounts in the analytic widget if there are more than 7 results for their search criteria, by providing a Search More option.

Additional Enhancements include:
- remove the 'Add a Line' button. replace with an empty tag (no analytic account, remainder of distribution)
- remove the custom AutoComplete. this was used to set the active group on autoComplete focus, and to prevent propogation of click events.
- no longer keep track of the active group (editing plan). Instead pass the group (plan id) to the relevant functions.
- rename variables, for example request > searchTerm
- keep the header of the popup in place (no scroll when there are many tags)
- test the widget in list view (with the search more feature)
- add a new analytic account list view, as users should not multi-edit in the search more dialog

Current behavior before PR:
Users were forced to type the entire analytic account name if they had many analytic accounts with similar names.

forward port of 36391b9aea82091bf7b689658e07149860b01a61
Desired behavior after PR is merged:
A "Search More" option is added to the Autocomplete options when there are more than 6 analytic accounts found. The Search More option provides a SelectCreate Dialog where users are able to find the required analytic account easily. Users are able to select multiple accounts by checking the items in the list.

Task-3180055

Forward port of bef8e00d78f439936534cef8b2fd8313f03f3b1c

X-original-commit: 9f12669bcc1f23198d12a97b31feb6cb82894e11
Part-of: odoo/odoo#122424
This commit is contained in:
Habib (ayh)
2023-05-25 11:07:21 +02:00
parent eab3b230d3
commit db39fa70d1
8 changed files with 329 additions and 115 deletions
@@ -1,20 +1,22 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { useService, useOwnedDialogs } from "@web/core/utils/hooks";
import { evaluateExpr } from "@web/core/py_js/py";
import { getNextTabableElement, getPreviousTabableElement } from "@web/core/utils/ui";
import { usePosition } from "@web/core/position_hook";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { shallowEqual } from "@web/core/utils/arrays";
import { sprintf } from "@web/core/utils/strings";
import { _lt } from "@web/core/l10n/translation";
import { AnalyticAutoComplete } from "../autocomplete/autocomplete";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { TagsList } from "@web/core/tags_list/tags_list";
import { useOpenMany2XRecord } from "@web/views/fields/relational_utils";
import { parseFloat as oParseFloat } from "@web/views/fields/parsers";
import { formatPercentage } from "@web/views/fields/formatters";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
const { Component, useState, useRef, useExternalListener, onWillUpdateProps, onWillStart, onPatched } = owl;
@@ -23,7 +25,6 @@ const PLAN_APPLICABILITY = {
optional: _lt("Optional"),
}
const PLAN_STATUS = {
editing: _lt("Editing"),
invalid: _lt("Invalid"),
ok: _lt("OK"),
}
@@ -45,7 +46,6 @@ export class AnalyticDistribution extends Component {
this.nextId = 1;
this.focusSelector = false;
this.activeGroup = false;
this.currentValue = this.props.record.data[this.props.name];
@@ -74,6 +74,10 @@ export class AnalyticDistribution extends Component {
this.allPlans = [];
this.lastAccount = this.props.account_field && this.props.record.data[this.props.account_field] || false;
this.lastProduct = this.props.product_field && this.props.record.data[this.props.product_field] || false;
this.selectCreateIsOpen = false;
this.addDialog = useOwnedDialogs();
this.onSearchMore = this._onSearchMore.bind(this);
}
// Lifecycle
@@ -190,18 +194,18 @@ export class AnalyticDistribution extends Component {
}
// Autocomplete
get sourcesAnalyticAccount() {
return [this.optionsSourceAnalytic];
sourcesAnalyticAccount(groupId) {
return [this.optionsSourceAnalytic(groupId)];
}
get optionsSourceAnalytic() {
optionsSourceAnalytic(groupId) {
return {
placeholder: this.env._t("Loading..."),
options: this.loadOptionsSourceAnalytic.bind(this),
options:(searchTerm) => this.loadOptionsSourceAnalytic(groupId, searchTerm),
};
}
async loadOptionsSourceAnalytic(request) {
analyticAccountDomain(groupId=null) {
let domain = [['id', 'not in', this.existingAnalyticAccountIDs]];
if (this.props.record.data.company_id){
domain.push(
@@ -211,11 +215,28 @@ export class AnalyticDistribution extends Component {
);
}
if (this.activeGroup) {
domain.push(['root_plan_id', '=', this.activeGroup]);
if (groupId) {
domain.push(['root_plan_id', '=', groupId]);
}
return domain;
}
const records = await this.fetchAnalyticAccounts([...domain, '|', ["name", "ilike", request], '|', ['code', 'ilike', request], ['partner_id', 'ilike', request]], 7);
searchAnalyticDomain(searchTerm) {
return [
'|',
["name", "ilike", searchTerm],
'|',
['code', 'ilike', searchTerm],
['partner_id', 'ilike', searchTerm],
];
}
async loadOptionsSourceAnalytic(groupId, searchTerm) {
const searchLimit = 6;
const records = await this.fetchAnalyticAccounts([
...this.analyticAccountDomain(groupId),
...this.searchAnalyticDomain(searchTerm)], searchLimit + 1);
let options = records.map((result) => ({
value: result.id,
@@ -224,6 +245,14 @@ export class AnalyticDistribution extends Component {
color: result.color,
}));
if (searchLimit < records.length) {
options.push({
label: this.env._t("Search More..."),
action: (editedTag) => this.onSearchMore(searchTerm, editedTag),
classList: "o_m2o_dropdown_option o_m2o_dropdown_option_search_more",
});
}
if (!options.length) {
options.push({
label: this.env._t("No Analytic Accounts for this plan"),
@@ -235,56 +264,98 @@ export class AnalyticDistribution extends Component {
return options;
}
groupAutocompleteFocus(group_id) {
this.activeGroup = group_id;
async _onSearchMore(searchTerm, editedTag) {
let dynamicFilters = [];
if (searchTerm.length) {
dynamicFilters = [
{
description: sprintf(this.env._t("Quick search: %s"), searchTerm),
domain: this.searchAnalyticDomain(searchTerm),
},
];
}
this.selectCreateIsOpen = true;
this.addDialog(SelectCreateDialog, {
title: this.env._t("Search: Analytic Account"),
noCreate: true,
multiSelect: true,
resModel: 'account.analytic.account',
context: {
tree_view_ref: "analytic.view_account_analytic_account_list_select",
},
domain: this.analyticAccountDomain(editedTag.group_id),
dynamicFilters: dynamicFilters,
onSelected: async (resIds) => {
const analytic_accounts = await this.fetchAnalyticAccounts([["id", "in", resIds]]);
// modify the edited tag
editedTag.analytic_account_id = analytic_accounts[0].id;
editedTag.analytic_account_name = analytic_accounts[0].display_name;
this.setFocusSelector(`.tag_${editedTag.id} .o_analytic_percentage`);
if (analytic_accounts.length > 1) {
const planId = editedTag.group_id;
// remove the autofill line
this.list[planId].distribution = this.list[planId].distribution.filter((t) => !!t.analytic_account_id);
for (const account of analytic_accounts.slice(1)) {
// add new tags
const tag = this.newTag(planId);
tag.analytic_account_id = account.id;
tag.analytic_account_name = account.display_name;
this.list[planId].distribution.push(tag);
}
}
this.autoFill();
},
onCreateEdit: () => {},
}, {
onClose: () => {
if (!editedTag.analytic_account_id) {
this.setFocusSelector(`.tag_${editedTag.id} .o_analytic_account_name`);
this.focusToSelector();
}
this.selectCreateIsOpen = false;
},
});
}
autoCompleteInput(dist_tag, {inputValue}) {
if (inputValue === "") {
dist_tag.analytic_account_id = "";
autoCompleteInputChanged(distTag, inputValue) {
if (inputValue === "" && distTag.analytic_account_id) {
this.deleteTag(distTag.id, distTag.group_id);
}
}
// Editing Distributions
async onSelect(option, params, tag) {
if (option.action) {
return option.action(tag);
}
const selected_option = Object.getPrototypeOf(option);
tag.analytic_account_id = parseInt(selected_option.value);
tag.analytic_account_name = selected_option.label;
tag.color = selected_option.color;
this.setFocusSelector(`.tag_${tag.id} .o_analytic_percentage`);
this.autoFill();
}
async percentageChanged(dist_tag, ev) {
dist_tag.percentage = this.parse(ev.target.value);
if (this.remainderByGroup(dist_tag.group_id)) {
this.setFocusSelector(`#plan_${dist_tag.group_id} .incomplete .o_analytic_account_name`);
if (dist_tag.percentage == 0) {
this.deleteTag(dist_tag.id, dist_tag.group_id);
}
this.autoFill();
}
deleteTag(id) {
for (const group_id in this.list) {
this.list[group_id].distribution = this.list[group_id].distribution.filter((dist_tag) => dist_tag.id != id);
if (this.list[group_id].applicability === 'mandatory' && this.remainderByGroup(group_id)) this.addLineToGroup(group_id);
}
deleteTag(id, fromGroup) {
// find the next tag to focus to before deleting the tag
const allTags = this.listFlat;
const currentTagIndex = allTags.findIndex((t) => t.id === id);
const nextTag = allTags[(currentTagIndex + 1) % allTags.length];
// remove the tag from the groups distribution
this.list[fromGroup].distribution = this.list[fromGroup].distribution.filter((dist_tag) => dist_tag.id != id);
if (!this.isDropdownOpen){
this.save();
}
}
addLineToGroup(id) {
const tag_cnt_diff = this.list[id].distribution.length - this.listReadyByGroup(id).length;
if (!tag_cnt_diff) {
this.list[id].distribution.push(this.newTag(id));
} else {
//all tags are not ready - update the value of tag without analytic account or add
const tagToUpdate = this.list[id].distribution.filter((t) => !this.tagIsReady(t) && !t.analytic_account_id)[0];
if (!!tagToUpdate) {
tagToUpdate.percentage = this.remainderByGroup(id);
} else {
this.list[id].distribution.push(this.newTag(id));
}
this.setFocusSelector(`.tag_${nextTag.id} .o_analytic_account_name`);
this.autoFill();
}
}
@@ -296,7 +367,7 @@ export class AnalyticDistribution extends Component {
colorIndex: dist_tag.color,
group_id: dist_tag.group_id,
onClick: (ev) => this.tagClicked(ev, dist_tag.id),
onDelete: this.editingRecord ? () => this.deleteTag(dist_tag.id) : undefined
onDelete: this.editingRecord ? () => this.deleteTag(dist_tag.id, dist_tag.group_id) : undefined
}));
}
@@ -310,8 +381,9 @@ export class AnalyticDistribution extends Component {
get firstIncompletePlanId() {
for (const group_id in this.list) {
const group_status = this.groupStatus(group_id);
if (["editing", "invalid"].includes(group_status)) return group_id;
if (this.groupStatus(group_id) == "invalid") {
return group_id;
}
}
return 0;
}
@@ -341,8 +413,8 @@ export class AnalyticDistribution extends Component {
}
get allowSave() {
for (const group_id in this.list) {
if (['editing', 'invalid'].includes(this.groupStatus(group_id))) return false;
if (this.firstIncompletePlanId > 0) {
return false;
}
return this.props.allow_save;
}
@@ -364,11 +436,7 @@ export class AnalyticDistribution extends Component {
groupStatus(id) {
const group = this.list[id];
const ready_tags = this.listReadyByGroup(id);
if (group.applicability === 'mandatory') {
if (group.distribution.length > ready_tags.length) {
return 'editing'
}
const sum = this.sumByGroup(id);
if (sum < 99.99 || sum >= 100.01) {
return 'invalid';
@@ -405,10 +473,25 @@ export class AnalyticDistribution extends Component {
}
}
/**
* This method is typically called when opening the popup and after any change to the distribution.
* The remainder, will be placed in the first tag with 0%.
* It adds an empty tag allowing users to continue the distribution (replaced 'Add a Line').
* Where an empty tag exists, the percentage is updated.
*/
autoFill() {
for (const group of this.allPlans.filter((p) => p.all_account_count > 0)){
if (this.remainderByGroup(group.id)) {
this.addLineToGroup(group.id);
for (const group of this.allPlans.filter((p) => p.all_account_count > 0)) {
// update the first unmodified tag containing 0%
const tagToUpdate = this.list[group.id].distribution.find((t) => t.percentage == 0);
if (tagToUpdate) {
tagToUpdate.percentage = this.remainderByGroup(group.id);
}
// a tag with no analytic account must always be added / updated
const emptyTag = this.list[group.id].distribution.find((t) => !t.analytic_account_id);
if (emptyTag) {
emptyTag.percentage = this.remainderByGroup(group.id);
} else {
this.list[group.id].distribution.push(this.newTag(group.id));
}
}
}
@@ -421,8 +504,7 @@ export class AnalyticDistribution extends Component {
async save() {
const currentDistribution = this.listForJson;
const dataToSave = currentDistribution;
await this.props.record.update({ [this.props.name]: dataToSave });
await this.props.record.update({ [this.props.name]: currentDistribution });
}
onSaveNew() {
@@ -561,8 +643,11 @@ export class AnalyticDistribution extends Component {
}
onWindowClick(ev) {
if (this.isDropdownOpen && this.dropdownRef.el && !this.dropdownRef.el.contains(ev.target) && !this.widgetRef.el.contains(ev.target)) {
this.closeAnalyticEditor();
if (this.isDropdownOpen
&& this.dropdownRef.el && !this.dropdownRef.el.contains(ev.target)
&& !this.widgetRef.el.contains(ev.target)
&& !this.selectCreateIsOpen) {
this.forceCloseEditor();
}
}
@@ -581,7 +666,7 @@ export class AnalyticDistribution extends Component {
}
AnalyticDistribution.template = "analytic.AnalyticDistribution";
AnalyticDistribution.components = {
AnalyticAutoComplete,
AutoComplete,
TagsList,
}
@@ -592,7 +677,7 @@ AnalyticDistribution.props = {
product_field: { type: String, optional: true },
business_domain_compute: { type: String, optional: true },
force_applicability: { type: String, optional: true },
allow_save: { type: Boolean },
allow_save: { type: Boolean, optional: true },
}
export const analyticDistribution = {
@@ -57,10 +57,6 @@
background-color: $o-success;
margin-left:8px;
}
.o_analytic_status_editing {
background-color: $o-warning;
margin-left:8px;
}
tr:hover {
outline: none !important;
}
@@ -14,7 +14,7 @@
<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">
<div class="popover-header">
<div class="popover-header sticky-top">
<div class="d-flex">
<div class="h5 mt-2 me-auto">Analytic</div>
<div class="popupButtons">
@@ -37,15 +37,14 @@
<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">
<AnalyticAutoComplete
<AutoComplete
id="dist_tag.id.toString()"
placeholder="'Search Analytic Account'"
value="dist_tag.analytic_account_name"
sources="sourcesAnalyticAccount"
sources="sourcesAnalyticAccount(plan.id)"
autoSelect="true"
onSelect.alike="(option, params) => this.onSelect(option, params, dist_tag)"
onFocus.alike="() => this.groupAutocompleteFocus(plan.id)"
onInput.alike="(inputVal) => this.autoCompleteInput(dist_tag, inputVal)"/>
onChange.alike="({inputValue}) => this.autoCompleteInputChanged(dist_tag, inputValue)"/>
</td>
<td class="o_analytic_percentage">
<input
@@ -57,15 +56,10 @@
t-on-change="(ev) => this.percentageChanged(dist_tag, ev)"/>
</td>
<td>
<span class="fa fa-trash-o cursor-pointer" t-on-click.stop="() => this.deleteTag(dist_tag.id)"/>
<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>
</tr>
</t>
<tr>
<td class="o_field_x2many_list_row_add" colspan="3">
<a t-if="plan.all_account_count" href="#" role="button" tabindex="-1" t-on-click.prevent.stop="() => this.addLineToGroup(plan.id)">Add a line</a>
</td>
</tr>
</table>
</t>
<div tabindex="0" class="hidden-focus"/>
@@ -1,6 +0,0 @@
/** @odoo-module **/
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
export class AnalyticAutoComplete extends AutoComplete {}
AnalyticAutoComplete.template = "analytic.AutoComplete";
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="analytic.AutoComplete" t-inherit="web.AutoComplete" t-inherit-mode="primary" owl="1">
<xpath expr="//input" position="attributes">
<attribute name="t-on-click"></attribute>
<attribute name="t-on-click.stop">onInputClick</attribute>
</xpath>
</t>
</templates>
@@ -26,15 +26,22 @@ QUnit.module("Analytic", (hooks) => {
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: "Namur" },
{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: {
@@ -53,10 +60,10 @@ QUnit.module("Analytic", (hooks) => {
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: 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 },
],
@@ -70,9 +77,9 @@ QUnit.module("Analytic", (hooks) => {
},
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: '{}'},
{ 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: {
@@ -102,6 +109,14 @@ QUnit.module("Analytic", (hooks) => {
]
}
},
views: {
"account.analytic.account,false,search": `<search/>`,
"account.analytic.account,analytic.view_account_analytic_account_list_select,list": `
<tree>
<field name="name"/>
</tree>
`,
}
};
setupViewRegistries();
@@ -109,7 +124,7 @@ QUnit.module("Analytic", (hooks) => {
QUnit.module("AnalyticDistribution");
QUnit.test("field in form view basic features", async function (assert) {
QUnit.test("analytic field in form view basic features", async function (assert) {
await makeView({
type: "form",
resModel: "aml",
@@ -137,10 +152,10 @@ QUnit.module("Analytic", (hooks) => {
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 '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 'FI 69.7%'"
"should have rendered tag 'FI 69.7%'"
);
assert.containsN(target, ".o_delete", 2, "tags should contain a delete button");
@@ -151,9 +166,10 @@ QUnit.module("Analytic", (hooks) => {
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: title, 2 tags, add a line"
"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"
@@ -163,12 +179,7 @@ QUnit.module("Analytic", (hooks) => {
const input = document.activeElement;
await editInput(input, null, "19");
assert.containsOnce(planTable, '.o_analytic_status_editing', "Mandatory plan has incomplete status");
let incompleteInputName = planTable.querySelector('tr.incomplete .o_analytic_account_name input');
assert.strictEqual(document.activeElement, incompleteInputName,
"focus is on the first incomplete tag"
);
assert.containsOnce(planTable, '.o_analytic_status_invalid', "Mandatory plan has invalid status");
triggerHotkey("Escape");
await nextTick();
@@ -179,7 +190,7 @@ QUnit.module("Analytic", (hooks) => {
popup = target.querySelector('.analytic_distribution_popup');
planTable = popup.querySelectorAll('table')[0];
incompleteInputName = planTable.querySelector('tr.incomplete .o_analytic_account_name input');
let incompleteInputName = planTable.querySelector('tr.incomplete .o_analytic_account_name input');
assert.strictEqual(document.activeElement, incompleteInputName,
"focus returns to the first incomplete tag"
);
@@ -197,4 +208,133 @@ QUnit.module("Analytic", (hooks) => {
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%, tag is removed and balance goes to last tag (tag 5)
let accountEls = [...popup.querySelectorAll("table#plan_5 .o_analytic_account_name input")];
await editInput(percentageEls[3], null, "0");
percentageEls = [...popup.querySelectorAll("table#plan_5 .o_analytic_percentage input")];
expectedPercentages = ['10%', '50%', '10%', '10%', '20%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `7: Percentage Element ${i} should be ${expectedPercentages[i]}`);
}
assert.strictEqual(document.activeElement, accountEls[4], "Focus should be on the fifth tag (one was removed)");
// delete tag 3, balance goes to last empty tag (tag 4)
let trashIcons = [...document.querySelectorAll("table#plan_5 .fa-trash-o")];
assert.equal(trashIcons.length, 4, "1 tag should not have a trash icon");
await click(trashIcons[2]);
percentageEls = [...popup.querySelectorAll("table#plan_5 .o_analytic_percentage input")];
expectedPercentages = ['10%', '50%', '10%', '30%'];
for (const [i, el] of percentageEls.entries()) {
assert.equal(el.value, expectedPercentages[i], `7: 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", 10, "should contain 2 rows of 5 tags each");
});
});
@@ -66,6 +66,19 @@
</field>
</record>
<record id="view_account_analytic_account_list_select" model="ir.ui.view">
<field name="name">account.analytic.account.list.select</field>
<field name="model">account.analytic.account</field>
<field name="mode">primary</field>
<field eval="18" name="priority"/>
<field name="inherit_id" ref="analytic.view_account_analytic_account_list"/>
<field name="arch" type="xml">
<tree position="attributes">
<attribute name="multi_edit">0</attribute>
</tree>
</field>
</record>
<record id="view_account_analytic_account_kanban" model="ir.ui.view">
<field name="name">account.analytic.account.kanban</field>
<field name="model">account.analytic.account</field>
@@ -8,7 +8,8 @@
<field name="partner_id" optional="show"/>
<field name="partner_category_id" optional="hide"/>
<field name="company_id" groups="base.group_multi_company" optional="show"/>
<field name="analytic_distribution" widget="analytic_distribution" optional="show"/>
<field name="analytic_distribution" widget="analytic_distribution" optional="show"
options="{'force_applicability': 'optional', 'disable_save': true}"/>
<button name="action_read_distribution_model" type="object" string="View" class="float-end btn-secondary"/>
</tree>
</field>