I have a field in my Python model:
analytic_distribution = fields.Json(inverse=”_inverse_analytic_distribution”)
In the XML, this field utilizes a widget:
<t t-name="analytic.AnalyticDistributionPopup">
<div class="analytic_distribution_popup dropdown-menu o-dropdown--menu show rounded py-0 overflow-x-hidden" t-if="state.showDropdown" t-ref="analyticDropdown">
<div class="p-2 table-responsive">
<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 t-foreach="allPlans" t-as="plan" t-key="plan.id">
<span t-out="plan.name"/> (<span t-att-class="totals[plan.id].class" t-out="totals[plan.id].formattedValue"/>)
</th>
<th t-out="'Percentage'" class="numeric_column_width"/>
<th t-if="valueColumnEnabled" class="numeric_column_width" t-out="props.record.fields[props.amount_field].string"/>
<th class="deleteColumn w-20px"/>
</tr>
</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">
<td t-foreach="Object.keys(data.record.fields).filter((f) => f.startsWith('x_plan'))" t-as="field" t-key="field">
<Field id="field" name="field" record="data.record" domain="data.record.fields[field].domain" canOpen="false" canCreate="false" canCreateEdit="false" canQuickCreate="false"/>
</td>
<td class="numeric_column_width">
<Field id="'percentage'" name="'percentage'" record="data.record"/>
</td>
<td t-if="valueColumnEnabled" class="numeric_column_width">
<Field id="props.amount_field" name="props.amount_field" record="data.record"/>
</td>
<td class="w-20px">
<span class="fa fa-trash-o cursor-pointer" t-on-click.stop="() => this.deleteLine(line_index)"/>
</td>
</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>
And this is the JavaScript code of the widget:
export class AnalyticDistribution extends Component {
static template = "analytic.AnalyticDistribution";
static components = {
TagsList,
Record,
Field,
}
static props = {
...standardFieldProps,
business_domain: { type: String, optional: true },
account_field: { type: String, optional: true },
product_field: { type: String, optional: true },
amount_field: { type: String, optional: true },
business_domain_compute: { type: String, optional: true },
force_applicability: { type: String, optional: true },
allow_save: { type: Boolean, optional: true },
}
setup(){
this.orm = useService("orm");
this.batchedOrm = useService("batchedOrm");
this.state = useState({
showDropdown: false,
formattedData: [],
});
this.widgetRef = useRef("analyticDistribution");
this.dropdownRef = useRef("analyticDropdown");
this.mainRef = useRef("mainElement");
this.addLineButton = useRef("addLineButton");
usePosition("analyticDropdown", () => this.widgetRef.el);
this.nextId = 1;
this.focusSelector = false;
this.currentValue = this.props.record.data[this.props.name];
onWillStart(this.willStart);
useRecordObserver(this.willUpdateRecord.bind(this));
onPatched(this.patched);
useExternalListener(window, "click", this.onWindowClick, true);
useExternalListener(window, "resize", this.onWindowResized);
this.openTemplate = useOpenMany2XRecord({
resModel: "account.analytic.distribution.model",
activeActions: {
create: true,
edit: false,
write: true,
},
isToMany: false,
onRecordSaved: async (record) => {
if (!this.props.record.model.multiEdit) {
this.mainRef.el.focus();
}
},
onClose: () => {
if (!this.props.record.model.multiEdit) {
this.mainRef.el.focus();
}
},
fieldString: _t("Analytic Distribution Model"),
});
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;
}
// Lifecycle
async willStart() {
if (this.editingRecord) {
// for performance in list views, plans are not retrieved until they are required.
await this.fetchAllPlans(this.props);
}
await this.jsonToData(this.props.record.data[this.props.name]);
}
async willUpdateRecord(record) {
const valueChanged =
JSON.stringify(this.currentValue) !==
JSON.stringify(record.data[this.props.name]);
const currentAccount = this.props.account_field && record.data[this.props.account_field] || false;
const currentProduct = this.props.product_field && record.data[this.props.product_field] || false;
const accountChanged = !shallowEqual(this.lastAccount, currentAccount);
const productChanged = !shallowEqual(this.lastProduct, currentProduct);
if (valueChanged || accountChanged || productChanged) {
if (!this.props.force_applicability) {
await this.fetchAllPlans({ record });
}
this.lastAccount = accountChanged && currentAccount || this.lastAccount;
this.lastProduct = productChanged && currentProduct || this.lastProduct;
await this.jsonToData(record.data[this.props.name]);
}
this.currentValue = record.data[this.props.name];
}
patched() {
this.focusToSelector();
}
accountTotalsByPlan() {
const accountTotals = {};
this.state.formattedData.map((line) => {
line.analyticAccounts.map((column) => {
if (column.accountId) {
let {
accId = column.accountId,
accName = column.accountDisplayName,
total = 0.0,
planId = column.accountRootPlanId,
planColor = column.accountColor,
} = accountTotals[column.accountRootPlanId]?.[column.accountId] || {};
total += roundDecimals(line.percentage, this.decimalPrecision.digits[1] + 2);
accountTotals[planId] = accountTotals[planId] || {};
accountTotals[planId][accId] = { accId, accName, planId, total, planColor};
}
})
});
return accountTotals;
}
planTotals() {
const summary = this.accountTotalsByPlan();
this.allPlans.map((plan) => {
const planTotal = (summary[plan.id] && Object.values(summary[plan.id]) || []).reduce((prev, next) => prev + next.total, 0.0);
const className = plan.applicability === "mandatory" && !this.planIsComplete(planTotal) ? 'text-danger' : plan.applicability === "mandatory" ? 'text-success' : '';
summary[plan.id] = {
value: planTotal,
formattedValue: formatPercentage(planTotal, this.decimalPrecision),
class: className,
applicability: plan.applicability,
}
});
return summary;
}
planIsComplete(total) {
return roundDecimals(total, this.decimalPrecision.digits[1] + 2) === 1;
}
planSummaryTags() {
const accountTotals = this.accountTotalsByPlan();
return Object.values(accountTotals).map((planSummary) => {
const accs = Object.values(planSummary);
return {
id: accs[0].planId,
text: accs.reduce((p, n) => p + (p.length ? " | " : "") + (this.planIsComplete(n.total) ? n.accName : `${formatPercentage(n.total)} ${n.accName}`) , ""),
colorIndex: accs[0].planColor,
onClick: (ev) => this.tagClicked(ev),
};
});
}
plansToArray() {
return this.allPlans.map((plan) => ({
planId: plan.id,
planName: plan.name,
planColor: plan.color,
}));
}
async jsonToData(jsonFieldValue) {
const analyticAccountIds = jsonFieldValue ? Object.keys(jsonFieldValue).map((key) => key.split(',')).flat().map((id) => parseInt(id)) : [];
const analyticAccountDict = analyticAccountIds.length ? await this.fetchAnalyticAccounts([["id", "in", analyticAccountIds]]) : [];
let distribution = [];
let accountNotFound = false;
for (const [accountIds, percentage] of Object.entries(jsonFieldValue)) {
const defaultVals = this.plansToArray(); // empty if the popup was not opened
const ids = accountIds.split(',');
for (const id of ids) {
const account = analyticAccountDict[parseInt(id)];
if (account) {
// since tags are displayed even though plans might not be retrieved (ie defaultVals is empty)
// push the accounts anyway, as order doesn't matter
// once the popup is opened, plans are fetched and the analyticAccounts list will be ordered
Object.assign(defaultVals.find((plan) => plan.planId == account.root_plan_id[0]) || defaultVals.push({}) && defaultVals[defaultVals.length-1],
{
accountId: parseInt(id),
accountDisplayName: account.display_name,
accountColor: account.color,
accountRootPlanId: account.root_plan_id[0],
});
} else {
accountNotFound = true;
}
}
distribution.push({
analyticAccounts: defaultVals,
percentage: percentage / 100,
id: this.nextId++,
})
}
this.state.formattedData = distribution;
if (accountNotFound) {
// Analytic accounts in the json were not found, save the json without them
await this.save();
}
}
recordProps(line) {
const analyticAccountFields = {
id: { type: "int" },
display_name: { type: "char" },
color: { type: "int" },
plan_id: { type: "many2one" },
root_plan_id: { type: "many2one" },
};
let recordFields = {};
const values = {};
// Analytic Account fields
line.analyticAccounts.map((account) => {
const fieldName = `x_plan${account.planId}_id`;
recordFields[fieldName] = {
string: account.planName,
relation: "account.analytic.account",
type: "many2one",
related: {
fields: analyticAccountFields,
activeFields: analyticAccountFields,
},
// company domain might be required here
domain: [["root_plan_id", "=", account.planId]],
};
values[fieldName] = account?.accountId || false;
});
// Percentage field
recordFields['percentage'] = {
string: _t("Percentage"),
type: "percentage",
cellClass: "numeric_column_width",
...this.decimalPrecision,
};
values['percentage'] = line.percentage;
if (this.props.amount_field) {
const { string, name, type, currency_field } = this.props.record.fields[this.props.amount_field];
recordFields[name] = { string, name, type, currency_field, cellClass: "numeric_column_width" };
values[name] = this.props.record.data[name] * values['percentage'];
if (currency_field) {
const { string, name, type, relation } = this.props.record.fields[currency_field];
recordFields[currency_field] = { name, string, type, relation, invisible: true };
values[currency_field] = this.props.record.data[currency_field][0];
}
}
return {
fields: recordFields,
values: values,
activeFields: recordFields,
onRecordChanged: async (record, changes) => await this.lineChanged(record, changes, line),
}
}
accountCount(line) {
return line.analyticAccounts.map((acc) => acc.accountId).filter(Boolean).length;
}
lineIsValid(line) {
return this.accountCount(line) && line.percentage;
}
// ORM
fetchPlansArgs({ record }) {
let args = {};
if (this.props.business_domain_compute) {
args['business_domain'] = evaluateExpr(this.props.business_domain_compute, record.evalContext);
}
if (this.props.business_domain) {
args['business_domain'] = this.props.business_domain;
}
if (this.props.product_field && record.data[this.props.product_field]) {
args['product'] = record.data[this.props.product_field][0];
}
if (this.props.account_field && record.data[this.props.account_field]) {
args['account'] = record.data[this.props.account_field][0];
}
if (this.props.force_applicability) {
args['applicability'] = this.props.force_applicability;
}
const existing_account_ids = Object.keys(record.data[this.props.name]).map((k) => k.split(",")).flat().map((i) => parseInt(i));
if (existing_account_ids.length) {
args['existing_account_ids'] = existing_account_ids;
}
if (record.data.company_id) {
args['company_id'] = record.data.company_id[0];
}
return args;
}
async fetchAllPlans(props) {
const argsPlan = this.fetchPlansArgs(props);
this.allPlans = await this.orm.call("account.analytic.plan", "get_relevant_plans", [], argsPlan);
}
async fetchAnalyticAccounts(domain) {
const args = {
domain: domain,
fields: ["id", "display_name", "root_plan_id", "color"],
context: [],
}
// batched call
const records = await this.batchedOrm.read("account.analytic.account", domain[0][2], args.fields, {});
return Object.assign({}, ...records.map((r) => {
const {id, ...rest} = r;
return {[id]: rest};
}));
}
// Editing Distributions
async lineChanged(record, changes, line) {
// record analytic account changes to the state
for (const account of line.analyticAccounts) {
const selected = record.data[`x_plan${account.planId}_id`];
account.accountId = selected[0];
account.accountDisplayName = selected[1];
account.accountColor = account.planColor;
account.accountRootPlanId = account.planId;
}
if (changes.percentage != line.percentage) {
roundDecimals(line.percentage = record.data.percentage, this.decimalPrecision.digits[1] + 2);
} else if (
this.valueColumnEnabled &&
changes[this.props.amount_field] != line[this.props.amount_field]
) {
line.percentage = roundDecimals(
record.data[this.props.amount_field] / this.props.record.data[this.props.amount_field],
this.decimalPrecision.digits[1] + 2);
}
}
get valueColumnEnabled() {
return Boolean(this.props.amount_field && this.props.record.data[this.props.amount_field]);
}
get decimalPrecision() {
return { digits: [12, this.props.record.data.analytic_precision || 2] };
}
get allowSave() {
return this.props.allow_save && this.state.formattedData.some((line) => this.lineIsValid(line));
}
get editingRecord() {
return !this.props.readonly;
}
get isDropdownOpen() {
return this.state.showDropdown && !!this.dropdownRef.el;
}
// actions
addLine() {
let maxMandatory = 0, maxOptional = 0, hasMandatory = false;
Object.values(this.planTotals()).filter((plan) => plan.value < 1).map((plan) => {
if (plan.applicability == "mandatory"){
maxMandatory = Math.max(plan.value, maxMandatory);
hasMandatory = true;
} else {
maxOptional = Math.max(plan.value, maxOptional);
}
});
let noPlanTotal = this.state.formattedData.filter((line) => !this.accountCount(line)).reduce((p, n) => p + n.percentage, 0);
const remainder = roundDecimals(1 - (hasMandatory ? maxMandatory : (maxOptional || noPlanTotal)), this.decimalPrecision.digits[1] + 2);
const lineToAdd = {
id: this.nextId++,
analyticAccounts: this.plansToArray(),
percentage: Math.max(remainder, 0) || 1,
}
this.state.formattedData.push(lineToAdd);
this.setFocusSelector(`[name=line_${this.state.formattedData.length - 1}] td:first-of-type`);
}
deleteLine(index) {
this.state.formattedData.splice(index, 1);
if (!this.state.formattedData.length) {
this.addLine();
}
}
dataToJson() {
const result = {};
this.state.formattedData = this.state.formattedData.filter((line) => this.accountCount(line));
this.state.formattedData.map((line) => {
const key = line.analyticAccounts.reduce((p, n) => p.concat(n.accountId ? n.accountId : []), []);
result[key] = (result[key] || 0) + line.percentage * 100;
});
return result;
}
async save() {
await this.props.record.update({ [this.props.name]: this.dataToJson() });
}
onSaveNew() {
this.closeAnalyticEditor();
const { record, product_field, account_field } = this.props;
this.openTemplate({ resId: false, context: {
'default_analytic_distribution': this.dataToJson(),
'default_partner_id': record.data['partner_id'] ? record.data['partner_id'][0] : undefined,
'default_product_id': product_field ? record.data[product_field][0] : undefined,
'default_account_prefix': account_field ? record.data[account_field][1].substr(0, 3) : undefined,
}});
}
forceCloseEditor() {
this.preventOpen = true;
this.closeAnalyticEditor();
this.mainRef.el.focus();
this.preventOpen = false;
}
closeAnalyticEditor() {
this.save();
this.state.showDropdown = false;
}
async openAnalyticEditor() {
if (!this.allPlans.length) {
await this.fetchAllPlans(this.props);
await this.jsonToData(this.props.record.data[this.props.name]);
}
if (!this.state.formattedData.length) {
await this.addLine();
}
this.setFocusSelector("[name='line_0'] td:first-of-type");
this.state.showDropdown = true;
}
async tagClicked(ev) {
if (this.editingRecord && !this.isDropdownOpen) {
await this.openAnalyticEditor();
}
if (this.isDropdownOpen) {
this.setFocusSelector("[name='line_0'] td:first-of-type");
this.focusToSelector();
ev.stopPropagation();
}
}
// Focus
onMainElementFocus(ev) {
if (!this.isDropdownOpen && !this.preventOpen) {
this.openAnalyticEditor();
}
}
focusToSelector() {
if (this.focusSelector && this.isDropdownOpen) {
this.focus(this.adjacentElementToFocus("next", this.dropdownRef.el.querySelector(this.focusSelector)));
}
this.focusSelector = false;
}
setFocusSelector(selector) {
this.focusSelector = selector;
}
adjacentElementToFocus(direction, el = null) {
if (!this.isDropdownOpen) {
return null;
}
if (!el) {
el = this.dropdownRef.el;
}
return direction == "next" ? getNextTabableElement(el) : getPreviousTabableElement(el);
}
focusAdjacent(direction) {
const elementToFocus = this.adjacentElementToFocus(direction);
if (elementToFocus){
this.focus(elementToFocus);
return true;
}
return false;
}
focus(el) {
if (!el) return;
el.focus();
if (["INPUT", "TEXTAREA"].includes(el.tagName)) {
if (el.selectionStart) {
el.selectionStart = 0;
el.selectionEnd = el.value.length;
}
el.select();
}
}
async onWidgetKeydown(ev) {
if (!this.editingRecord) {
return;
}
const hotkey = getActiveHotkey(ev);
switch (hotkey) {
case "enter":
case "tab": {
if (this.isDropdownOpen) {
const closestCell = ev.target.closest("td, th");
const row = closestCell.parentElement;
const line = this.state.formattedData[parseInt(row.id)];
if (this.adjacentElementToFocus("next") == this.addLineButton.el && line && this.lineIsValid(line)) {
this.addLine();
break;
}
this.focusAdjacent("next") || this.forceCloseEditor();
break;
};
return;
}
case "shift+tab": {
if (this.isDropdownOpen) {
this.focusAdjacent("previous") || this.forceCloseEditor();
break;
};
return;
}
case "escape": {
if (this.isDropdownOpen) {
this.forceCloseEditor();
break;
}
}
case "arrowdown": {
if (!this.isDropdownOpen) {
this.onMainElementFocus();
break;
}
return;
}
default: {
return;
}
}
ev.preventDefault();
ev.stopPropagation();
}
onWindowClick(ev) {
const selectors = [
".o_popover",
".modal:not(.o_inactive_modal):not(:has(.o_act_window))",
];
if (this.isDropdownOpen
&& !this.widgetRef.el.contains(ev.target)
&& !ev.target.closest(selectors.join(","))
&& !ev.target.isSameNode(document.documentElement)
) {
this.forceCloseEditor();
}
}
onWindowResized() {
// popup ui is ugly when window is resized, so close it
if (this.isDropdownOpen && !isMobileOS()) {
this.forceCloseEditor();
}
}
}
export const analyticDistribution = {
component: AnalyticDistribution,
supportedTypes: ["char", "text"],
fieldDependencies: [{ name:"analytic_precision", type: "integer" }],
supportedOptions: [
{
label: _t("Disable save"),
name: "disable_save",
type: "boolean",
},
{
label: _t("Force applicability"),
name: "force_applicability",
type: "boolean",
},
{
label: _t("Business domain"),
name: "business_domain",
type: "string",
},
{
label: _t("Product field"),
name: "product_field",
type: "field",
availableTypes: ["many2one"],
},
{
label: _t("Amount field"),
name: "amount_field",
type: "field",
availableTypes: ["monetary"],
},
{
label: _t("Account field"),
name: "account_field",
type: "field",
availableTypes: ["many2one"],
}
],
extractProps: ({ attrs, options }) => ({
business_domain: options.business_domain,
account_field: options.account_field,
product_field: options.product_field,
amount_field: options.amount_field,
business_domain_compute: attrs.business_domain_compute,
force_applicability: options.force_applicability,
allow_save: !options.disable_save,
}),
};
registry.category("fields").add("analytic_distribution", analyticDistribution);
The analytic account is selected via a dropdown, as shown in the image (the field next to the percentage). I want to add a filter so that the accounts displayed are filtered with the following conditions: if budget.lines has a budget_id and budget_general_id that match general_budget_id, then display its analytic_account_id. If budget_id and budget_general_id are empty, then display all accounts. Thank you in advance!



