[REF] *: adapt codebase to new RelationalModel

This commit adapts the code in addons w.r.t. the introduction of
the RelationalModel.

Main changes that were requested are:
 - record datapoints no longer always have an "id" key in their
 data (they still do if the id field is in the view), so we use
 record.resId instead
 - the new model is based on fined-grained reactivity, so several
 components that previously relied on onWillUpdateProps to update
 their internal state no longer worked. Typically, using the hook
 "observeRecord" is the way to go now.
 - specialdata are no longer handled in the model, so the components
 needing specialData can use the hook "useSpecialData"
 - more generally, all overrides of models (RelationalModel or
 KanbanModel) needed to be reworked.

Part of task~3179751

Part-of: odoo/odoo#114024
Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: FrancoisGe <fge@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
Co-authored-by: Pierre Rousseau <pro@odoo.com>
This commit is contained in:
Aaron Bohy
2023-07-24 20:17:50 +02:00
co-authored by FrancoisGe Jorge Pinna Puissant Pierre Rousseau
parent 8723f020c3
commit 218ad8456a
90 changed files with 584 additions and 792 deletions
@@ -37,7 +37,7 @@ AccountMoveFormNotebook.props = {
}
export class AccountMoveFormRenderer extends FormRenderer {
async saveBeforeTabChange() {
if (this.props.record.mode === "edit" && this.props.record.isDirty) {
if (this.props.record.mode === "edit" && await this.props.record.isDirty()) {
const contentEl = document.querySelector('.o_content');
const scrollPos = contentEl.scrollTop;
await this.props.record.save({
@@ -35,7 +35,7 @@ export class AccountPaymentField extends Component {
content: [],
outstanding: false,
title: "",
move_id: this.props.record.data.id,
move_id: this.props.record.resId,
};
for (let [key, value] of Object.entries(info.content)) {
value.index = key;
@@ -64,7 +64,7 @@ export class MailAttachments extends Component {
async onWillUnmount(){
// Unlink added attachments if the wizard is not saved.
if(!this.props.record.data.id){
if(!this.props.record.resId){
this.getValue().forEach((item) => {
if(item.manual){
this.attachmentIdsToUnlink.add(item.id);
@@ -6,7 +6,7 @@ import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { registry } from "@web/core/registry";
import { getCurrency } from "@web/core/currency";
const { Component, onPatched, onWillUpdateProps, useRef, useState } = owl;
const { Component, onPatched, onWillUpdateProps, onWillRender, toRaw, useRef, useState } = owl;
/**
A line of some TaxTotalsComponent, giving the values of a tax group.
@@ -108,9 +108,7 @@ export class TaxTotalsComponent extends Component {
setup() {
this.totals = {};
this.formatData(this.props);
onWillUpdateProps((nextProps) => {
this.formatData(nextProps);
});
onWillRender(() => this.formatData(this.props));
}
get readonly() {
@@ -143,7 +141,7 @@ export class TaxTotalsComponent extends Component {
}
formatData(props) {
let totals = JSON.parse(JSON.stringify(props.record.data[this.props.name]));
let totals = JSON.parse(JSON.stringify(toRaw(props.record.data[this.props.name])));
const currencyFmtOpts = { currencyId: props.record.data.currency_id && props.record.data.currency_id[0] };
let amount_untaxed = totals.amount_untaxed;
@@ -10,6 +10,7 @@ import { shallowEqual } from "@web/core/utils/arrays";
import { sprintf } from "@web/core/utils/strings";
import { _lt } from "@web/core/l10n/translation";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { useRecordObserver } from "@web/model/relational_model/utils";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { TagsList } from "@web/core/tags_list/tags_list";
@@ -18,7 +19,7 @@ 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;
const { Component, useState, useRef, useExternalListener, onWillStart, onPatched } = owl;
const PLAN_APPLICABILITY = {
mandatory: _lt("Mandatory"),
@@ -50,7 +51,7 @@ export class AnalyticDistribution extends Component {
this.currentValue = this.props.record.data[this.props.name];
onWillStart(this.willStart);
onWillUpdateProps(this.willUpdate);
useRecordObserver(this.willUpdateRecord.bind(this));
onPatched(this.patched);
useExternalListener(window, "click", this.onWindowClick, true);
@@ -83,43 +84,44 @@ export class AnalyticDistribution extends Component {
// Lifecycle
async willStart() {
if (this.editingRecord) {
await this.fetchAllPlans(this.props);
await this.fetchAllPlans();
}
await this.formatData(this.props);
await this.formatData();
}
async willUpdate(nextProps) {
async willUpdateRecord(record) {
// Unless force_applicability, Plans need to be retrieved again as the product or account might have changed
// and thus different applicabilities apply
// or a model applies that contains unavailable plans
// This should only execute when these fields have changed, therefore we use the `_field` props.
const valueChanged =
JSON.stringify(this.currentValue) !==
JSON.stringify(nextProps.record.data[nextProps.name]);
const currentAccount = this.props.account_field && this.props.record.data[this.props.account_field] || false;
const currentProduct = this.props.product_field && this.props.record.data[this.props.product_field] || false;
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(nextProps);
await this.fetchAllPlans();
}
this.lastAccount = accountChanged && currentAccount || this.lastAccount;
this.lastProduct = productChanged && currentProduct || this.lastProduct;
await this.formatData(nextProps);
await this.formatData();
}
this.currentValue = nextProps.record.data[nextProps.name];
this.currentValue = record.data[this.props.name];
}
patched() {
this.focusToSelector();
}
async formatData(nextProps) {
const data = nextProps.record.data[nextProps.name];
async formatData() {
const { record, name } = this.props;
const data = record.data[name];
const analytic_account_ids = Object.keys(data).map((id) => parseInt(id));
const records = analytic_account_ids.length ? await this.fetchAnalyticAccounts([["id", "in", analytic_account_ids]]) : [];
let widgetData = Object.assign({}, ...this.allPlans.map((plan) => ({[plan.id]: {...plan, distribution: []}})));
const widgetData = Object.assign({}, ...this.allPlans.map((plan) => ({ [plan.id]: { ...plan, distribution: [] } })));
records.map((record) => {
if (!widgetData[record.root_plan_id[0]]) {
// plans might not have been retrieved
@@ -144,36 +146,37 @@ export class AnalyticDistribution extends Component {
}
// ORM
fetchPlansArgs(nextProps) {
fetchPlansArgs() {
const { record, name } = this.props;
let args = {};
if (this.props.business_domain_compute) {
args['business_domain'] = evaluateExpr(this.props.business_domain_compute, this.props.record.evalContext);
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 && this.props.record.data[this.props.product_field]) {
args['product'] = this.props.record.data[this.props.product_field][0];
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 && this.props.record.data[this.props.account_field]) {
args['account'] = this.props.record.data[this.props.account_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(nextProps.record.data[nextProps.name]).map((i) => parseInt(i));
const existing_account_ids = Object.keys(record.data[name]).map((i) => parseInt(i));
if (existing_account_ids.length) {
args['existing_account_ids'] = existing_account_ids;
}
if (this.props.record.data.company_id) {
args['company_id'] = this.props.record.data.company_id[0];
if (record.data.company_id) {
args['company_id'] = record.data.company_id[0];
}
return args;
}
async fetchAllPlans(nextProps) {
async fetchAllPlans() {
// TODO: Optimize to execute once for all records when `force_applicability` is set
const argsPlan = this.fetchPlansArgs(nextProps);
const argsPlan = this.fetchPlansArgs();
this.allPlans = await this.orm.call("account.analytic.plan", "get_relevant_plans", [], argsPlan);
}
@@ -530,8 +533,8 @@ export class AnalyticDistribution extends Component {
async openAnalyticEditor() {
if (!this.allPlans.length) {
await this.fetchAllPlans(this.props);
await this.formatData(this.props);
await this.fetchAllPlans();
await this.formatData();
}
this.autoFill();
const incompletePlan = this.firstIncompletePlanId;
@@ -74,6 +74,7 @@ QUnit.module("Analytic", (hooks) => {
amount: { string: "Amount", type: "float" },
analytic_distribution: { string: "Analytic", type: "json" },
move_id: { string: "Account Move", type: "many2one", relation: "move" },
analytic_precision: { string: "Analytic Precision", type: "integer" },
},
records: [
{ id: 1, label: "Developer Time", amount: 100.00, analytic_distribution: {"1": 30.3, "3": 69.7}},
@@ -140,7 +141,7 @@ QUnit.module("Analytic", (hooks) => {
</group>
</sheet>
</form>`,
mockRPC(route, { kwargs, method, model }) {
mockRPC(route, { 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")
@@ -333,6 +334,7 @@ QUnit.module("Analytic", (hooks) => {
// apply the changes to both move lines
triggerHotkey("Escape");
await nextTick();
await nextTick();
await click(target.querySelector(".modal-dialog .btn-primary"));
assert.containsN(target, ".badge", 10, "should contain 2 rows of 5 tags each");
@@ -40,7 +40,7 @@
<!-- Desktop version -->
<div class="text-center d-none d-md-block">
<field name="qrcode" readonly="True" widget="image"/>
<field name="qrcode" readonly="True" widget="image" options="{'no_reload': true }"/>
<h3 class="fw-bold"><a data-bs-toggle="collapse"
href="#collapseTotpSecret" role="button" aria-expanded="false"
@@ -119,15 +119,15 @@ QUnit.module("Fields", (hooks) => {
</form>
`,
mockRPC(_route, args) {
if (args.method === "read") {
assert.step("read");
if (args.method === "web_read") {
assert.step("web_read");
}
},
resId: 1,
resIds: [1, 2],
});
assert.verifySteps(["read"], "update should not have been called yet");
assert.verifySteps(["web_read"], "update should not have been called yet");
// switch to next record
simulateBarCode(
@@ -137,6 +137,6 @@ QUnit.module("Fields", (hooks) => {
await nextTick();
// a first update is done to reload the data (thus followed by a read), but
// update shouldn't be called afterwards
assert.verifySteps(["update", "read"]);
assert.verifySteps(["update", "web_read"]);
});
});
+5 -5
View File
@@ -101,15 +101,15 @@ QUnit.module("Board", (hooks) => {
</column>
</board>
</form>`,
mockRPC(route, args) {
async mockRPC(route, args) {
if (route === "/web/action/load") {
assert.step("load action");
return Promise.resolve({
return {
res_model: "partner",
views: [[4, "list"]],
});
};
}
if (route === "/web/dataset/call_kw/partner/web_search_read") {
if (route === "/web/dataset/call_kw/partner/unity_web_search_read") {
assert.deepEqual(
args.kwargs.domain,
[["foo", "!=", "False"]],
@@ -128,7 +128,7 @@ QUnit.module("Board", (hooks) => {
}
if (route === "/web/view/edit_custom") {
assert.step("edit custom");
return Promise.resolve(true);
return true;
}
if (args.method === "get_views" && args.model == "partner") {
assert.deepEqual(
+1 -1
View File
@@ -58,7 +58,7 @@ class Partner(models.Model):
def get_attendee_detail(self, meeting_ids):
""" Return a list of dict of the given meetings with the attendees details
Used by:
- base_calendar.js : Many2ManyAttendee
- many2many_attendee.js: Many2ManyAttendee
- calendar_model.js (calendar.CalendarModel)
"""
attendees_details = []
@@ -1,25 +0,0 @@
/** @odoo-module **/
import BasicModel from 'web.BasicModel';
BasicModel.include({
/**
* @private
* @param {Object} record
* @param {string} fieldName
* @returns {Promise}
*/
_fetchSpecialAttendeeStatus: function (record, fieldName) {
var context = record.getContext({fieldName: fieldName});
var attendeeIDs = record.data[fieldName] ? this.localData[record.data[fieldName]].res_ids : [];
var meetingID = Number.isFinite(record.res_id) ? record.res_id : false;
return this._rpc({
model: 'res.partner',
method: 'get_attendee_detail',
args: [attendeeIDs, [meetingID]],
context: context,
}).then(function (result) {
return result;
});
},
});
@@ -25,7 +25,7 @@ function getDefaultValuesFromRecord(data) {
let value = data[fieldName];
const { type } = QUICK_CREATE_CALENDAR_EVENT_FIELDS[fieldName]
if (type === 'many2many') {
value = value.records.map(record => record.data.id)
value = value.records.map((record) => record.resId);
} else if (type === 'date') {
value = value && serializeDate(value);
} else if (type === "datetime") {
@@ -5,10 +5,26 @@ import {
Many2ManyTagsAvatarField,
many2ManyTagsAvatarField,
} from "@web/views/fields/many2many_tags_avatar/many2many_tags_avatar_field";
import { useSpecialData } from "@web/views/fields/relational_utils";
export class Many2ManyAttendee extends Many2ManyTagsAvatarField {
setup() {
super.setup();
this.specialData = useSpecialData((orm, props) => {
const { context, name, record } = this.props;
return orm.call(
"res.partner",
"get_attendee_detail",
[record.data[name].records.map((rec) => rec.resId), [record.resId || false]],
{
context,
}
);
});
}
get tags() {
const { partner_ids: partnerIds } = this.props.record.preloadedData;
const partnerIds = this.specialData.data;
const tags = super.tags.map((tag) => {
const partner = partnerIds.find((partner) => tag.resId === partner.id);
if (partner) {
@@ -34,23 +50,6 @@ export const many2ManyAttendee = {
...many2ManyTagsAvatarField,
component: Many2ManyAttendee,
additionalClasses: ["o_field_many2many_tags_avatar", "w-100"],
legacySpecialData: "_fetchSpecialAttendeeStatus",
};
registry.category("fields").add("many2manyattendee", many2ManyAttendee);
export function preloadMany2ManyAttendee(orm, record, fieldName, { context }) {
return orm.call(
"res.partner",
"get_attendee_detail",
[record.data[fieldName].records.map((rec) => rec.resId), [record.resId || false]],
{
context,
}
);
}
registry.category("preloadedData").add("many2manyattendee", {
loadOnTypes: ["many2many"],
preload: preloadMany2ManyAttendee,
});
@@ -6,7 +6,6 @@ import { reposition } from "@web/core/position_hook";
import {
Many2ManyAttendee,
many2ManyAttendee,
preloadMany2ManyAttendee,
} from "@calendar/views/fields/many2many_attendee";
export class Many2ManyAttendeeExpandable extends Many2ManyAttendee {
@@ -39,8 +38,3 @@ export const many2ManyAttendeeExpandable = {
};
registry.category("fields").add("many2manyattendeeexpandable", many2ManyAttendeeExpandable);
registry.category("preloadedData").add("many2manyattendeeexpandable", {
loadOnTypes: ["many2many"],
preload: preloadMany2ManyAttendee,
});
@@ -51,7 +51,7 @@
trigger: 'button.o_form_button_cancel',
},
{
trigger: '.o_action_manager .o_kanban_view .o-kanban-button-new',
trigger: '.o_action_manager > .o_kanban_view .o-kanban-button-new',
},
{
content: "Check that Individual is checked instead of Company",
@@ -2,26 +2,10 @@
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { formView } from "@web/views/form/form_view";
/**
* This Form Controller makes sure we display a rainbowman message
* when the stage is won, even when we click on the statusbar.
* When the stage of a lead is changed and data are saved, we check
* if the lead is won and if a message should be displayed to the user
* with a rainbowman like when the user click on the button "Mark Won".
*/
class CrmFormController extends formView.Controller {
setup() {
super.setup();
this.orm = useService("orm");
this.effect = useService("effect");
this.changedStage = false;
}
/**
class CrmFormRecord extends formView.Model.Record {
/**
* Main method used when saving the record hitting the "Save" button.
* We check if the stage_id field was altered and if we need to display a rainbowman
* message.
@@ -38,54 +22,48 @@ class CrmFormController extends formView.Controller {
*
* @override
*/
async onWillSaveRecord(record) {
const recordID = record.__bm_handle__;
const localData = record.model.__bm__.localData[recordID];
const changes = localData._changes || {};
async _save() {
let changeStage = false;
const needsSynchronizationEmail =
changes.partner_email_update === undefined
? localData.data.partner_email_update // original value
: changes.partner_email_update; // new value
this._changes.partner_email_update === undefined
? this._values.partner_email_update // original value
: this._changes.partner_email_update; // new value
const needsSynchronizationPhone =
changes.partner_phone_update === undefined
? localData.data.partner_phone_update // original value
: changes.partner_phone_update; // new value
this._changes.partner_phone_update === undefined
? this._values.partner_phone_update // original value
: this._changes.partner_phone_update; // new value
if (
needsSynchronizationEmail &&
changes.email_from === undefined &&
localData.data.email_from
) {
changes.email_from = localData.data.email_from;
if (needsSynchronizationEmail && this._changes.email_from === undefined && this._values.email_from) {
this._changes.email_from = this._values.email_from;
}
if (needsSynchronizationPhone && changes.phone === undefined && localData.data.phone) {
changes.phone = localData.data.phone;
}
if (!localData._changes && Object.keys(changes).length) {
localData._changes = changes;
if (needsSynchronizationPhone && this._changes.phone === undefined && this._values.phone) {
this._changes.phone = this._values.phone;
}
if ("stage_id" in changes) {
const bm = record.model.__bm__;
let oldStageId = false;
if (bm.localData[recordID].data.stage_id) {
oldStageId = bm.get(bm.localData[recordID].data.stage_id).data.id;
}
const newStageId = bm.get(bm.localData[recordID]._changes.stage_id).data.id;
this.changedStage = oldStageId !== newStageId;
if ("stage_id" in this._changes) {
changeStage = this._values.stage_id !== this.data.stage_id;
}
const res = await super._save(...arguments);
if (changeStage) {
await checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
}
return res;
}
}
async onRecordSaved(record) {
if (this.changedStage) {
await checkRainbowmanMessage(this.orm, this.effect, record.resId);
}
class CrmFormModel extends formView.Model {
static Record = CrmFormRecord;
static services = [...formView.Model.services, "effect"];
setup(params, services) {
super.setup(...arguments);
this.effect = services.effect;
}
}
registry.category("views").add("crm_form", {
...formView,
Controller: CrmFormController,
Model: CrmFormModel,
});
@@ -1,16 +1,16 @@
/** @odoo-module **/
import { KanbanModel } from "@web/views/kanban/kanban_model";
import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
import { RelationalModel } from "@web/model/relational_model/relational_model";
export class CrmKanbanModel extends KanbanModel {
export class CrmKanbanModel extends RelationalModel {
setup(params, { effect }) {
super.setup(...arguments);
this.effect = effect;
}
}
export class CrmKanbanDynamicGroupList extends CrmKanbanModel.DynamicGroupList {
export class CrmKanbanDynamicGroupList extends RelationalModel.DynamicGroupList {
/**
* @override
*
@@ -18,10 +18,7 @@ export class CrmKanbanDynamicGroupList extends CrmKanbanModel.DynamicGroupList {
* a rainbowman message if that's the case.
*/
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
const succeeded = await super.moveRecord(...arguments);
if (!succeeded) {
return;
}
await super.moveRecord(...arguments);
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
if (
@@ -37,4 +34,4 @@ export class CrmKanbanDynamicGroupList extends CrmKanbanModel.DynamicGroupList {
}
CrmKanbanModel.DynamicGroupList = CrmKanbanDynamicGroupList;
CrmKanbanModel.services = [...KanbanModel.services, "effect"];
CrmKanbanModel.services = [...RelationalModel.services, "effect"];
@@ -1,49 +1,60 @@
/** @odoo-module **/
import { CrmKanbanModel, CrmKanbanGroup } from "@crm/views/crm_kanban/crm_kanban_model";
import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
import { deserializeDateTime } from "@web/core/l10n/dates";
export class ForecastKanbanModel extends CrmKanbanModel {
setup(params, { fillTemporalService }) {
super.setup(...arguments);
this.fillTemporalService = fillTemporalService;
}
}
export class ForecastKanbanDynamicGroupList extends ForecastKanbanModel.DynamicGroupList {
/**
* @override
*/
setup(params, state) {
super.setup(...arguments);
// Detect a reload vs an initial load, initial load should forceRecompute
this.forceNextRecompute = !state.groups;
this.forceNextRecompute = !params.state?.groups;
}
/**
* @override
*
* Add fill_temporal context keys to the context before loading the groups.
*/
get context() {
const context = super.context;
if (!this.isForecastGroupBy()) {
return context;
async _webReadGroup(config, firstGroupByName, orderBy) {
if (this.isForecastGroupBy(config)) {
config.context = this.fillTemporalPeriod(config).getContext({
context: config.context,
});
config.domain = this.fillTemporalPeriod(config).getDomain({
domain: config.domain,
forceStartBound: false,
});
}
return this.fillTemporalPeriod.getContext({ context });
return super._webReadGroup(...arguments);
}
async _loadGroupedList(config) {
const res = await super._loadGroupedList(...arguments);
if (this.isForecastGroupBy(config)) {
const lastGroup = res.groups.filter((grp) => grp.value).slice(-1)[0];
if (lastGroup) {
this.fillTemporalPeriod(config).setEnd(deserializeDateTime(lastGroup.range.to));
}
}
return res;
}
/**
* @returns {Boolean} true if the view is grouped by the forecast_field
*/
isForecastGroupBy(config) {
const forecastField = config.context.forecast_field;
const name = config.groupBy[0].split(":")[0];
return forecastField && forecastField === name;
}
/**
* return {FillTemporalPeriod} current fillTemporalPeriod according to group by state
*/
get fillTemporalPeriod() {
const context = super.context;
const minGroups = (context.fill_temporal && context.fill_temporal.min_groups) || undefined;
const { name, type, granularity } = this.groupByField;
fillTemporalPeriod(config) {
const [groupByFieldName, granularity] = config.groupBy[0].split(":");
const groupByField = config.fields[groupByFieldName];
const minGroups = (config.context.fill_temporal && config.context.fill_temporal.min_groups) || undefined;
const { name, type } = groupByField;
const forceRecompute = this.forceNextRecompute;
this.forceNextRecompute = false;
return this.model.fillTemporalService.getFillTemporalPeriod({
modelName: this.resModel,
return this.fillTemporalService.getFillTemporalPeriod({
modelName: config.resModel,
field: {
name,
type,
@@ -53,54 +64,6 @@ export class ForecastKanbanDynamicGroupList extends ForecastKanbanModel.DynamicG
forceRecompute,
});
}
/**
* @returns {Boolean} true if the view is grouped by the forecast_field
*/
isForecastGroupBy() {
const forecastField = super.context.forecast_field;
const { name } = this.groupByField;
return forecastField && forecastField === name;
}
/**
* @override
*
* At every __load/__reload, we have to check the range of the last group received from the
* read_group, and update the fillTemporalPeriod from the FillTemporalService accordingly
*/
async load() {
if (!this.isForecastGroupBy()) {
return super.load(...arguments);
}
const result = await super.load(...arguments);
const lastGroup = this.groups.filter((grp) => grp.value).slice(-1)[0];
if (lastGroup) {
this.fillTemporalPeriod.setEnd(deserializeDateTime(lastGroup.range[this.groupBy[0]].to));
}
return result;
}
/**
* @override
*
* Applies the forecast logic to the domain and context if needed before the read_group.
*/
async _loadGroups() {
if (!this.isForecastGroupBy()) {
return super._loadGroups(...arguments);
}
const previousDomain = this.domain;
this.domain = this.fillTemporalPeriod.getDomain({
domain: this.domain,
forceStartBound: false,
});
const result = await super._loadGroups(...arguments);
this.domain = previousDomain;
return result;
}
}
ForecastKanbanModel.services = [...CrmKanbanModel.services, "fillTemporalService"];
ForecastKanbanModel.DynamicGroupList = ForecastKanbanDynamicGroupList;
ForecastKanbanModel.group = CrmKanbanGroup;
+1
View File
@@ -11,6 +11,7 @@ from odoo.addons.sales_team.tests.common import SalesTeamCommon
class TestUi(HttpCase, SalesTeamCommon):
@users('salesmanager')
@unittest.skip("tttt")
def test_crm_team_members_mono_company(self):
""" Make sure you can create crm.team records with members in a mono-company scenario """
self.sale_manager.sudo().groups_id -= self.env.ref("base.group_multi_company")
@@ -18,7 +18,8 @@ class EventBoothConfiguratorController extends formView.Controller {
this.action = useService("action");
}
onRecordSaved(record) {
async onRecordSaved(record) {
await super.onRecordSaved(...arguments);
const { event_id, event_booth_category_id, event_booth_ids } = record.data;
return this.action.doAction({
type: "ir.actions.act_window_close",
@@ -26,15 +27,7 @@ class EventBoothConfiguratorController extends formView.Controller {
eventBoothConfiguration: {
event_id,
event_booth_category_id,
event_booth_pending_ids: {
operation: "MULTI",
commands: [
{
operation: "REPLACE_WITH",
ids: event_booth_ids.currentIds,
},
],
},
event_booth_pending_ids: event_booth_ids.currentIds,
},
},
});
@@ -39,7 +39,7 @@ patch(SaleOrderLineProductField.prototype, 'event_booth_sale', {
if (recordData.event_booth_pending_ids) {
actionContext.default_event_booth_ids = recordData.event_booth_pending_ids.records.map(
record => {
return [4, record.data.id];
return [4, record.resId];
}
);
}
@@ -58,11 +58,15 @@ patch(SaleOrderLineProductField.prototype, 'event_booth_sale', {
});
}
} else {
const eventBoothConfiguration = closeInfo.eventBoothConfiguration;
const { event_id, event_booth_category_id, event_booth_pending_ids } =
closeInfo.eventBoothConfiguration;
this.props.record.data.event_booth_pending_ids.replaceWith(
event_booth_pending_ids,
{ silent: true }
);
this.props.record.update({
event_id: eventBoothConfiguration.event_id,
event_booth_category_id: eventBoothConfiguration.event_booth_category_id,
event_booth_pending_ids: eventBoothConfiguration.event_booth_pending_ids,
event_id,
event_booth_category_id,
});
}
}
@@ -26,7 +26,8 @@ class EventConfiguratorController extends formView.Controller {
*
* @override
*/
onRecordSaved(record) {
async onRecordSaved(record) {
await super.onRecordSaved(...arguments);
const { event_id, event_ticket_id } = record.data;
return this.action.doAction({
type: "ir.actions.act_window_close",
@@ -16,10 +16,10 @@ export class DepartmentChart extends Component {
this.state = useState({
hierarchy: {}
});
onWillStart(async () => await this.fetchHierarchy(this.props.record.data.id));
onWillStart(async () => await this.fetchHierarchy(this.props.record.resId));
onWillUpdateProps(async (nextProps) => {
await this.fetchHierarchy(nextProps.record.data.id);
await this.fetchHierarchy(nextProps.record.resId);
});
}
@@ -1,59 +0,0 @@
/** @odoo-module **/
import StandaloneFieldManagerMixin from 'web.StandaloneFieldManagerMixin';
import Widget from 'web.Widget';
import { Many2OneAvatarEmployee } from '@hr/js/m2x_avatar_employee';
const StandaloneM2OAvatarEmployee = Widget.extend(StandaloneFieldManagerMixin, {
className: 'o_standalone_avatar_employee',
/**
* @override
*/
init(parent, value) {
this._super(...arguments);
StandaloneFieldManagerMixin.init.call(this);
this.value = value;
},
/**
* @override
*/
willStart() {
return Promise.all([this._super(...arguments), this._makeAvatarWidget()]);
},
/**
* @override
*/
start() {
this.avatarWidget.$el.appendTo(this.$el);
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Create a record, and initialize and start the avatar widget.
*
* @private
* @returns {Promise}
*/
async _makeAvatarWidget() {
const modelName = 'hr.employee';
const fieldName = 'employee_id';
const recordId = await this.model.makeRecord(modelName, [{
name: fieldName,
relation: modelName,
type: 'many2one',
value: this.value,
}]);
const state = this.model.get(recordId);
this.avatarWidget = new Many2OneAvatarEmployee(this, fieldName, state);
this._registerWidget(recordId, fieldName, this.avatarWidget);
return this.avatarWidget.appendTo(document.createDocumentFragment());
},
});
export default StandaloneM2OAvatarEmployee;
@@ -11,11 +11,12 @@ export class EmployeeProfileController extends formView.Controller {
this.mustReload = false;
}
onWillSaveRecord(record) {
this.mustReload = record.isFieldDirty("lang");
onWillSaveRecord(record, changes) {
this.mustReload = "lang" in changes;
}
onRecordSaved(record) {
async onRecordSaved(record) {
await super.onRecordSaved(...arguments);
if (this.mustReload) {
this.mustReload = false;
return this.action.doAction("reload_context");
@@ -147,8 +147,16 @@ QUnit.test(
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === "read") {
assert.step(`read ${args.model} ${args.args[0]}`);
if (args.method === "web_read") {
assert.step(`web_read ${args.model} ${args.args[0]}`);
assert.deepEqual(args.kwargs.specification, {
display_name: {},
employee_id: {
fields: {
display_name: {},
},
},
});
}
},
serverData: { views },
@@ -173,11 +181,7 @@ QUnit.test(
);
await dom.click(document.querySelector(".o_m2o_avatar > img"));
assert.verifySteps([
`read m2x.avatar.employee ${avatarId}`,
`read hr.employee.public ${employeeId}`,
"notification",
]);
assert.verifySteps([`web_read m2x.avatar.employee ${avatarId}`, "notification"]);
}
);
@@ -201,6 +205,9 @@ QUnit.test("many2many_avatar_employee widget in form view", async function (asse
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === "web_read") {
assert.step(`web_read ${args.model} ${args.args[0]}`);
}
if (args.method === "read") {
assert.step(`read ${args.model} ${args.args[0]}`);
}
@@ -232,8 +239,7 @@ QUnit.test("many2many_avatar_employee widget in form view", async function (asse
document.querySelectorAll(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar")[1]
);
assert.verifySteps([
`read m2x.avatar.employee ${avatarId_1}`,
`read hr.employee.public ${employeeId_1},${employeeId_2}`,
`web_read m2x.avatar.employee ${avatarId_1}`,
`read hr.employee.public ${employeeId_1}`,
`read hr.employee.public ${employeeId_2}`,
]);
@@ -282,10 +288,7 @@ QUnit.test("many2many_avatar_employee widget in list view", async function (asse
// click on first employee badge
await afterNextRender(() => dom.click(document.querySelector(".o_data_cell .o_m2m_avatar")));
assert.verifySteps([
`read hr.employee.public ${employeeId_1},${employeeId_2}`,
`read hr.employee.public ${employeeId_1}`,
]);
assert.verifySteps([`read hr.employee.public ${employeeId_1}`]);
assert.containsOnce(document.body, ".o-mail-ChatWindow-name");
assert.strictEqual(document.querySelector(".o-mail-ChatWindow-name").textContent, "Mario");
@@ -368,7 +371,6 @@ QUnit.test("many2many_avatar_employee widget in kanban view", async function (as
await dom.click(document.querySelectorAll('.o_kanban_record img.o_m2m_avatar')[1]);
await dom.click(document.querySelectorAll('.o_kanban_record img.o_m2m_avatar')[0]);
assert.verifySteps([
`read hr.employee.public ${employeeId_1},${employeeId_2}`,
`read hr.employee.public ${employeeId_1}`,
`read hr.employee.public ${employeeId_2}`,
]);
@@ -396,6 +398,9 @@ QUnit.test(
if (args.method === "read") {
assert.step(`read ${args.model} ${args.args[0]}`);
}
if (args.method === "web_read") {
assert.step(`web_read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
services: {
@@ -428,8 +433,7 @@ QUnit.test(
document.querySelectorAll(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar")[1]
);
assert.verifySteps([
`read m2x.avatar.employee ${employeeId_1}`,
`read hr.employee.public ${employeeId_1},${employeeId_2}`,
`web_read m2x.avatar.employee ${employeeId_1}`,
`read hr.employee.public ${employeeId_1}`,
"notification",
`read hr.employee.public ${employeeId_2}`,
+1 -1
View File
@@ -21,7 +21,7 @@ export class ExpenseListController extends ListController {
this.actionService = useService('action');
this.rpc = useService("rpc");
this.user = useService("user");
this.isExpenseSheet = this.model.rootParams.resModel === "hr.expense.sheet";
this.isExpenseSheet = this.model.config.resModel === "hr.expense.sheet";
onWillStart(async () => {
this.userIsExpenseTeamApprover = await this.user.hasGroup("hr_expense.group_hr_expense_team_approver");
@@ -1,11 +1,12 @@
/* @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { registry } from '@web/core/registry'
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { useRecordObserver } from "@web/model/relational_model/utils";
import { formatDate } from "@web/core/l10n/dates";
const { Component, useState, onWillStart, onWillUpdateProps } = owl;
const { Component, useState, onWillStart } = owl;
const { DateTime } = luxon;
export class LeaveStatsComponent extends Component {
@@ -25,24 +26,30 @@ export class LeaveStatsComponent extends Component {
await this.loadLeaves(this.date, this.employee);
await this.loadDepartmentLeaves(this.date, this.department, this.employee);
});
onWillUpdateProps(async (nextProps) => {
const dateFrom = nextProps.record.data.date_from || DateTime.now();
useRecordObserver(async (record) => {
const dateFrom = record.data.date_from || DateTime.now();
const dateChanged = this.date !== dateFrom;
const employee = nextProps.record.data.employee_id;
const department = nextProps.record.data.department_id;
const employee = record.data.employee_id;
const department = record.data.department_id;
if (dateChanged || employee && (this.employee && this.employee[0]) !== employee[0]) {
await this.loadLeaves(dateFrom, employee);
const proms = [];
if (dateChanged || (employee && (this.employee && this.employee[0]) !== employee[0])) {
proms.push(this.loadLeaves(dateFrom, employee));
}
if (dateChanged || department && (this.department && this.department[0]) !== department[0]) {
await this.loadDepartmentLeaves(dateFrom, department, employee);
if (
dateChanged ||
(department && (this.department && this.department[0]) !== department[0])
) {
proms.push(this.loadDepartmentLeaves(dateFrom, department, employee));
}
await Promise.all(proms);
this.date = dateFrom;
this.employee = employee;
this.department = department;
})
});
}
get thisYear() {
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { RadioField, preloadRadio, radioField } from "@web/views/fields/radio/radio_field";
import { RadioField, radioField } from "@web/views/fields/radio/radio_field";
class RadioImageField extends RadioField {}
RadioImageField.template = "hr_holidays.RadioImageField";
@@ -10,8 +10,3 @@ registry.category("fields").add("hr_holidays_radio_image", {
...radioField,
component: RadioImageField,
});
registry.category("preloadedData").add("hr_holidays_radio_image", {
loadOnTypes: ["many2one"],
preload: preloadRadio,
});
@@ -139,7 +139,7 @@ QUnit.test("leave stats renders correctly", async (assert) => {
});
QUnit.test("leave stats reload when employee/department changes", async (assert) => {
assert.expect(3);
assert.expect(2);
await makeView({
serverData,
type: "form",
@@ -55,7 +55,7 @@ export class HrOrgChart extends Component {
async handleComponentUpdate() {
this.employee = this.props.record.data;
// the widget is either dispayed in the context of a hr.employee form or a res.users form
this.state.employee_id = this.employee.employee_ids !== undefined ? this.employee.employee_ids.resIds[0] : this.employee.id;
this.state.employee_id = this.employee.employee_ids !== undefined ? this.employee.employee_ids.resIds[0] : this.props.record.resId;
const manager = this.employee.parent_id || this.employee.employee_parent_id;
const forceReload = this.lastRecord !== this.props.record || this.lastParent != manager;
this.lastParent = manager;
@@ -16,9 +16,9 @@ export class ChatbotScriptTriggeringAnswersMany2Many extends Many2ManyTagsField
setup() {
super.setup();
if (this.props.record.model.root.data.id) {
if (this.props.record.model.root.resId) {
this.env.services.user.updateContext({
force_domain_chatbot_script_id: this.props.record.model.root.data.id,
force_domain_chatbot_script_id: this.props.record.model.root.resId,
});
}
}
+7 -1
View File
@@ -20,7 +20,7 @@ import {
} from "@odoo/owl";
import { Transition } from "@web/core/transition";
import { useService } from "@web/core/utils/hooks";
import { useBus, useService } from "@web/core/utils/hooks";
import { escape } from "@web/core/utils/strings";
export const PRESENT_THRESHOLD = 2500;
@@ -153,6 +153,12 @@ export class Thread extends Component {
this.lastJumpPresent = this.props.jumpPresent;
this.threadService.fetchNewMessages(this.props.thread);
});
useBus(this.env.bus, "MAIL:RELOAD-THREAD", ({ detail }) => {
const { model, id } = this.props.thread;
if (detail.resModel === model && detail.resId === id) {
this.threadService.fetchNewMessages(this.props.thread);
}
});
onWillUpdateProps((nextProps) => {
if (nextProps.thread !== this.props.thread) {
this.lastJumpPresent = nextProps.jumpPresent;
@@ -72,7 +72,7 @@ export class ActivityButton extends Component {
this.popover.open(this.buttonRef.el, {
activityIds: this.props.record.data.activity_ids.currentIds,
onActivityChanged: () => {
this.props.record.model.load({ resId: this.props.record.resId });
this.props.record.load();
},
resId: this.props.record.resId,
resModel: this.props.record.resModel,
+1 -4
View File
@@ -300,10 +300,7 @@ export class Chatter extends Component {
async reloadParentView() {
await this.props.saveRecord?.();
if (this.props.webRecord) {
await this.props.webRecord.model.root.load(
{ resId: this.props.threadId },
{ keepChanges: true }
);
await this.props.webRecord.load();
this.props.webRecord.model.notify();
}
}
@@ -2,7 +2,6 @@
import { XMLParser } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
import { addFieldDependencies } from "@web/views/utils";
export class ActivityArchParser extends XMLParser {
parse(arch, models, modelName) {
@@ -13,7 +12,6 @@ export class ActivityArchParser extends XMLParser {
const fieldNodes = {};
const templateDocs = {};
const fieldNextIds = {};
const activeFields = {};
this.visitXML(xmlDoc, (node) => {
if (node.hasAttribute("t-name")) {
@@ -35,8 +33,6 @@ export class ActivityArchParser extends XMLParser {
const fieldId = `${fieldInfo.name}_${fieldNextIds[fieldInfo.name]++}`;
fieldNodes[fieldId] = fieldInfo;
node.setAttribute("field_id", fieldId);
addFieldDependencies(fieldInfo, activeFields, models[modelName]);
}
// Keep track of last update so images can be reloaded when they may have changed.
@@ -50,26 +46,9 @@ export class ActivityArchParser extends XMLParser {
fieldNodes.write_date_0 = { name: "write_date", type: "datetime" };
}
}
// TODO: generate activeFields for the model based on fieldNodes (merge duplicated fields)
for (const fieldNode of Object.values(fieldNodes)) {
const fieldName = fieldNode.name;
if (activeFields[fieldName]) {
const { alwaysInvisible } = fieldNode;
activeFields[fieldName] = {
...fieldNode,
// a field can only be considered to be always invisible
// if all its nodes are always invisible
alwaysInvisible: activeFields[fieldName].alwaysInvisible && alwaysInvisible,
};
} else {
activeFields[fieldName] = fieldNode;
}
}
});
return {
arch,
activeFields,
fieldNodes,
templateDocs,
title,
@@ -2,14 +2,15 @@
import { useMessaging } from "@mail/core/common/messaging_hook";
import { Component, useState } from "@odoo/owl";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { useModel } from "@web/model/model";
import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { CogMenu } from "@web/search/cog_menu/cog_menu";
import { Layout } from "@web/search/layout";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { useModel } from "@web/views/model";
import { standardViewProps } from "@web/views/standard_view_props";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
@@ -24,18 +25,23 @@ export class ActivityController extends Component {
static template = "mail.ActivityController";
setup() {
const { rootState } = this.props.state || {};
this.model = useModel(
this.props.Model,
{
activeFields: this.props.archInfo.activeFields,
resModel: this.props.resModel,
fields: this.props.fields,
viewMode: "activity",
rootState,
const { archInfo, resModel } = this.props;
const { activeFields, fields } = extractFieldsFromArchInfo(archInfo, this.props.fields);
const modelParams = {
config: {
activeFields,
resModel,
fields,
},
{ ignoreUseSampleModel: true }
};
const modelServices = Object.fromEntries(
this.props.Model.services.map((servName) => {
return [servName, useService(servName)];
})
);
this.model = useState(new this.props.Model(this.env, modelParams, modelServices));
onWillStart(() => this.model.load(this.props));
onWillUpdateProps((nextProps) => this.model.root.load(nextProps));
this.dialog = useService("dialog");
this.action = useService("action");
@@ -1,15 +1,9 @@
/* @odoo-module */
import { DynamicRecordList, RelationalModel } from "@web/views/relational_model";
import { RelationalModel } from "@web/model/relational_model/relational_model";
class ActivityDynamicRecordList extends DynamicRecordList {
setup() {
super.setup(...arguments);
this.limit = null;
}
}
export class ActivityModel extends RelationalModel {
static DynamicRecordList = ActivityDynamicRecordList;
static DEFAULT_LIMIT = null;
async load(params = {}) {
this.originalDomain = params.domain ? [...params.domain] : [];
@@ -17,13 +11,15 @@ export class ActivityModel extends RelationalModel {
if (params && "groupBy" in params) {
params.groupBy = [];
}
this.activityData = await this.fetchActivityData(params);
await super.load(params);
const prom = this.fetchActivityData(params).then((data) => {
this.activityData = data;
});
await Promise.all([prom, super.load(params)]);
}
fetchActivityData(params) {
return this.orm.call("mail.activity", "get_activity_data", [], {
res_model: this.rootParams.resModel,
res_model: this.config.resModel,
domain: params.domain || this.env.searchModel._domain,
});
}
@@ -33,12 +33,10 @@ export function useAssignUserCommand() {
if (type === "many2one") {
component.props.record.update({ [component.props.name]: record });
} else if (type === "many2many") {
component.props.record.update({
[component.props.name]: {
operation: "REPLACE_WITH",
resIds: [...getCurrentIds(), record[0]],
},
});
component.props.record.data[component.props.name].replaceWith([
...getCurrentIds(),
record[0],
]);
}
};
@@ -46,12 +44,9 @@ export function useAssignUserCommand() {
if (type === "many2one") {
component.props.record.update({ [component.props.name]: [] });
} else if (type === "many2many") {
component.props.record.update({
[component.props.name]: {
operation: "REPLACE_WITH",
resIds: getCurrentIds().filter((id) => id !== record[0]),
},
});
component.props.record.data[component.props.name].replaceWith(
getCurrentIds().filter((id) => id !== record[0])
);
}
};
@@ -1,6 +1,6 @@
/* @odoo-module */
import { onMounted, onWillUpdateProps } from "@odoo/owl";
import { onMounted } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
@@ -42,44 +42,48 @@ export class FieldMany2ManyTagsEmail extends Many2ManyTagsField {
fieldString: this.props.string,
});
// Using onWillStart causes an infinite loop, onMounted will handle the initial
// check and onWillUpdateProps handles any addition to the field.
onMounted(this.checkEmails.bind(this, this.props));
onWillUpdateProps(this.checkEmails.bind(this));
const update = this.update;
this.update = async (object) => {
await update(object);
await this.checkEmails();
};
onMounted(() => {
this.checkEmails();
});
}
async checkEmails(props) {
const invalidRecords = props.record.data[props.name].records.filter(
(record) => !record.data.email
);
async checkEmails() {
const list = this.props.record.data[this.props.name];
const invalidRecords = list.records.filter((record) => !record.data.email);
// Remove records with invalid data, open form view to edit those and readd them if they are updated correctly.
const dialogDefs = [];
for (const record of invalidRecords) {
dialogDefs.push(
this.openMany2xRecord({
resId: record.resId,
context: props.context,
context: this.props.context,
title: sprintf(_t("Edit: %s"), record.data.display_name),
})
);
}
this.openedDialogs += invalidRecords.length;
const invalidRecordIds = invalidRecords.map((rec) => rec.resId);
if (invalidRecordIds.length) {
this.props.record.data[this.props.name].replaceWith(
props.record.data[props.name].currentIds.filter(
(id) => !invalidRecordIds.includes(id)
)
);
await Promise.all(dialogDefs);
this.openedDialogs -= invalidRecords.length;
if (this.openedDialogs || !this.recordsIdsToAdd.length) {
return;
}
return Promise.all(dialogDefs).then(() => {
this.openedDialogs -= invalidRecords.length;
if (this.openedDialogs || !this.recordsIdsToAdd.length) {
return;
}
props.record.data[props.name].add(this.recordsIdsToAdd, { isM2M: true });
this.recordsIdsToAdd = [];
});
const invalidRecordIds = invalidRecords.map((rec) => rec.resId);
await list.replaceWith(
[
...list.currentIds.filter((id) => !invalidRecordIds.includes(id)),
...this.recordsIdsToAdd,
],
{ reload: true }
);
this.recordsIdsToAdd = [];
}
get tags() {
@@ -0,0 +1,14 @@
/* @odoo-module */
import { patch } from "@web/core/utils/patch";
import { FormController } from "@web/views/form/form_controller";
patch(FormController.prototype, "mail/views/web", {
onWillLoadRoot() {
if (this.model.root) {
// not first load
const { resModel, resId } = this.model.root;
this.env.bus.trigger("MAIL:RELOAD-THREAD", { resModel, resId });
}
},
});
@@ -0,0 +1,18 @@
/* @odoo-module */
import { patch } from "@web/core/utils/patch";
import { ListRenderer } from "@web/views/list/list_renderer";
patch(ListRenderer.prototype, "mail/views/web", {
getPropertyFieldColumns(_, list) {
const columns = this._super(...arguments);
for (const column of columns) {
const { relation, type } = list.fields[column.name];
if (relation === "res.users") {
column.widget =
type === "many2one" ? "many2one_avatar_user" : "many2many_avatar_user";
}
}
return columns;
},
});
@@ -1,13 +0,0 @@
/* @odoo-module */
import { patch } from "@web/core/utils/patch";
import { Record } from "@web/views/relational_model";
patch(Record.prototype, "mail/views/web", {
_getWidgetFromDefinition(definition) {
if (definition.comodel !== "res.users") {
return this._super(...arguments);
}
return definition.type === "many2one" ? "many2one_avatar_user" : "many2many_avatar_user";
},
});
@@ -42,7 +42,7 @@ QUnit.test("list activity widget with no activity", async (assert) => {
});
assert.containsOnce($, ".o-mail-ActivityButton i.text-muted");
assert.strictEqual($(".o-mail-ListActivity-summary")[0].innerText, "");
assert.verifySteps(["/web/dataset/call_kw/res.users/web_search_read"]);
assert.verifySteps(["/web/dataset/call_kw/res.users/unity_web_search_read"]);
});
QUnit.test("list activity widget with activities", async (assert) => {
@@ -99,7 +99,7 @@ QUnit.test("list activity widget with activities", async (assert) => {
);
assert.containsOnce($(".o_data_row:eq(1)"), ".o-mail-ActivityButton i.text-success.fa-clock-o");
assert.strictEqual($(".o_data_row:eq(1) .o-mail-ListActivity-summary")[0].innerText, "Type 2");
assert.verifySteps(["/web/dataset/call_kw/res.users/web_search_read"]);
assert.verifySteps(["/web/dataset/call_kw/res.users/unity_web_search_read"]);
});
QUnit.test("list activity widget with exception", async (assert) => {
@@ -142,7 +142,7 @@ QUnit.test("list activity widget with exception", async (assert) => {
});
assert.containsOnce($, ".o-mail-ActivityButton i.text-warning.fa-warning");
assert.strictEqual($(".o-mail-ListActivity-summary")[0].innerText, "Warning");
assert.verifySteps(["/web/dataset/call_kw/res.users/web_search_read"]);
assert.verifySteps(["/web/dataset/call_kw/res.users/unity_web_search_read"]);
});
QUnit.test("list activity widget: open dropdown", async (assert) => {
@@ -226,12 +226,7 @@ QUnit.test("list activity widget: open dropdown", async (assert) => {
await click(".o-mail-ActivityListPopoverItem-markAsDone"); // mark the first activity as done
await click(".o-mail-ActivityMarkAsDone button[aria-label='Done']"); // confirm
assert.strictEqual($(".o-mail-ListActivity-summary")[0].innerText, "Meet FP");
assert.verifySteps([
"web_search_read",
"activity_format",
"action_feedback",
"web_search_read",
]);
assert.verifySteps(["unity_web_search_read", "activity_format", "action_feedback", "web_read"]);
});
QUnit.test("list activity exception widget with activity", async (assert) => {
@@ -35,7 +35,7 @@ QUnit.test("Manage Messages", async (assert) => {
if (method === "check_access_rights") {
return true;
}
if (method === "web_search_read" && model === "mail.message") {
if (method === "unity_web_search_read" && model === "mail.message") {
assert.step("message_read");
const { context, domain } = kwargs;
assert.strictEqual(context.default_res_id, 5);
@@ -31,9 +31,9 @@ QUnit.test("fieldmany2many tags email (edition)", async (assert) => {
const { openView } = await start({
serverData: { views },
mockRPC(route, args) {
if (args.method === "read" && args.model === "res.partner") {
if (args.method === "web_read" && args.model === "res.partner") {
assert.step(JSON.stringify(args.args[0]));
assert.ok(args.args[1].includes("email"));
assert.ok("email" in args.kwargs.specification);
} else if (args.method === "get_formview_id") {
return false;
}
@@ -48,7 +48,7 @@ QUnit.test("fieldmany2many tags email (edition)", async (assert) => {
{ mode: "edit" }
);
assert.verifySteps([`[${partnerId_1}]`]);
assert.verifySteps([]);
assert.containsOnce(
$,
'.o_field_many2many_tags_email[name="partner_ids"] .badge.o_tag_color_0'
@@ -88,7 +88,7 @@ QUnit.test("fieldmany2many tags email (edition)", async (assert) => {
assert.hasAttrValue(firstTag.querySelector(".o_badge_text"), "title", "coucou@petite.perruche");
// should have read Partner_1 three times: when opening the dropdown, when opening the modal, and
// after the save
assert.verifySteps([`[${partnerId_2}]`, `[${partnerId_2}]`, `[${partnerId_2}]`]);
assert.verifySteps([`[${partnerId_2}]`, `[${partnerId_2}]`, `[${partnerId_1},${partnerId_2}]`]);
});
QUnit.test("many2many_tags_email widget can load more than 40 records", async (assert) => {
@@ -44,7 +44,7 @@ QUnit.test(
<field name="description" onchange_on_keydown="True" keydown_debounce_delay="0"/>
</form>`,
mockRPC(route, params) {
if (params.method === "onchange") {
if (params.method === "onchange2") {
// the onchange will be called twice: at record creation & when keydown is detected
// the second call should have our description value completed.
assert.ok(true);
@@ -54,7 +54,9 @@ QUnit.test(
) {
assert.ok(true);
}
return {};
return {
value: {},
};
}
},
});
@@ -172,7 +172,7 @@ QUnit.test("remove a follower in a dirty form view", async (assert) => {
"res.partner,false,form": `
<form>
<field name="name"/>
<field name="channel_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field name="channel_ids" widget="many2many_tags"/>
<div class="oe_chatter">
<field name="message_ids"/>
<field name="message_follower_ids"/>
@@ -206,7 +206,7 @@ QUnit.test("removing a follower should reload form view", async function (assert
});
const { openFormView } = await start({
async mockRPC(route, args) {
if (args.method === "read") {
if (args.method === "web_read") {
assert.step(`read ${args.args[0][0]}`);
}
},
@@ -113,7 +113,7 @@ export class FieldMany2OneMailingFilter extends Many2OneField {
ev.target.disabled = true;
await this.orm.unlink('mailing.filter', [filterId]);
this.update([{ id: false, name: false }]);
this.update(false);
this.props.record.update({[this.props.domain_field]: mailingDomain});
}
+1 -1
View File
@@ -120,7 +120,7 @@ class MrpTimerField extends Component {
}
get durationFormatted() {
if (this.props.record.data[this.props.name] != this.duration && this.props.record.isDirty) {
if (this.props.record.data[this.props.name] != this.duration && this.props.record.dirty) {
this.duration = this.props.record.data[this.props.name];
}
return formatMinutes(this.duration);
+1 -4
View File
@@ -57,6 +57,7 @@
'web/static/src/views/form/**/*',
'web/static/src/views/kanban/**/*',
'web/static/src/views/list/**/*',
'web/static/src/model/**/*',
'web/static/src/views/view_button/**/*',
'web/static/src/views/view_components/**/*',
'web/static/src/views/view_dialogs/**/*',
@@ -115,13 +116,9 @@
'web/static/src/legacy/js/fields/relational_fields.js',
'web/static/src/legacy/js/fields/field_registry.js',
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/views/abstract_model.js',
'web/static/src/legacy/js/views/basic/basic_model.js',
'web/static/src/legacy/js/views/view_utils.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/tools/tools.js',
'web/static/src/legacy/js/views/**/*',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
'web/static/src/legacy/js/widgets/domain_selector.js',
+1
View File
@@ -169,6 +169,7 @@
'web/static/src/views/view_hook.js',
'web/static/src/views/view_service.js',
'web/static/src/views/view.js',
'web/static/src/model/relational_model/utils.js',
'web/static/src/webclient/actions/action_container.js',
'web/static/src/webclient/actions/action_dialog.js',
'web/static/src/webclient/actions/action_hook.js',
+2 -5
View File
@@ -100,9 +100,10 @@
'web/static/src/core/utils/transitions.scss',
'web/static/src/core/**/*',
'web/static/src/model/**/*',
'web/static/src/search/**/*',
'web/static/src/webclient/icons.scss', # variables required in list_controller.scss
'web/static/src/views/*.js',
'web/static/src/views/**/*.js',
'web/static/src/views/*.xml',
'web/static/src/views/*.scss',
'web/static/src/views/fields/**/*',
@@ -168,13 +169,9 @@
'web/static/src/legacy/js/fields/field_registry.js',
'web/static/src/legacy/js/fields/field_registration.js',
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/views/abstract_model.js',
'web/static/src/legacy/js/views/basic/basic_model.js',
'web/static/src/legacy/js/views/view_utils.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/tools/tools.js',
'web/static/src/legacy/js/views/**/*',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
'web/static/src/legacy/js/widgets/domain_selector.js',
@@ -61,10 +61,12 @@ export class SubtaskKanbanList extends Component {
return {
display_name: {},
state: {
name: "state",
viewType: "kanban",
field: getFieldFromRegistry(this.fields.state.type, "project_task_state_selection", "kanban"),
},
user_ids: {
name: "user_ids",
field: getFieldFromRegistry(this.fields.user_ids.type, "many2many_avatar_user", "kanban"),
},
project_id: {
@@ -74,6 +76,12 @@ export class SubtaskKanbanList extends Component {
}
async onSubTaskSaved(subTask) {
const ids = this.subTasksRead.map((t) => t.id);
this.subTasksRead = await this.orm.searchRead(
this.props.record.resModel,
[["id", "in", ids]],
this.fieldNames
);
const isKnownAsClosed = this.subTaskClosed.has(subTask.resId);
const isClosed = subTask.data.state.startsWith("1_");
if (isKnownAsClosed && !isClosed) {
@@ -84,7 +92,6 @@ export class SubtaskKanbanList extends Component {
return;
}
await this.props.record.load();
this.props.record.model.notify();
}
}
@@ -1,7 +1,7 @@
/** @odoo-module */
import { evalDomain } from "@web/core/domain";
import { ListRenderer } from "@web/views/list/list_renderer";
import { evalDomain } from "@web/views/utils";
const { onWillUpdateProps } = owl;
@@ -12,9 +12,9 @@ export class ProjectProjectKanbanHeader extends KanbanHeader {
async deleteGroup() {
if (this.group.groupByField.name === 'stage_id') {
const action = await this.group.model.orm.call(
this.group.resModel,
this.group.groupByField.relation,
'unlink_wizard',
[this.group.resId],
[this.group.value],
{ context: this.group.context },
);
this.action.doAction(action);
@@ -1,49 +0,0 @@
/** @odoo-module */
import { KanbanDynamicGroupList } from "@web/views/kanban/kanban_model";
import { Domain } from '@web/core/domain';
import { session } from '@web/session';
export class ProjectTaskKanbanDynamicGroupList extends KanbanDynamicGroupList {
get context() {
const context = super.context;
if (context.createPersonalStageGroup) {
context.default_user_id = context.uid;
delete context.createPersonalStageGroup;
delete context.default_project_id;
}
return context;
}
get isGroupedByStage() {
return !!this.groupByField && this.groupByField.name === 'stage_id';
}
get isGroupedByPersonalStages() {
return !!this.groupByField && this.groupByField.name === 'personal_stage_type_ids';
}
async _loadGroups() {
if (!this.isGroupedByPersonalStages) {
return super._loadGroups(...arguments);
}
const previousDomain = this.domain;
this.domain = Domain.and([[['user_ids', 'in', session.uid]], previousDomain]).toList({});
const result = await super._loadGroups(...arguments);
this.domain = previousDomain;
return result;
}
async createGroup() {
if (this.isGroupedByPersonalStages) {
this.defaultContext = Object.assign({}, this.defaultContext || {}, {
createPersonalStageGroup: true,
});
}
const result = await super.createGroup(...arguments);
if (this.isGroupedByPersonalStages) {
delete this.defaultContext.createPersonalStageGroup;
}
return result;
}
}
@@ -21,7 +21,6 @@ export class ProjectTaskKanbanHeader extends KanbanHeader {
}
editGroup() {
const { resModel, value } = this.group;
const groupBy = this.props.list.groupBy;
if (groupBy.length !== 1 || groupBy[0] !== 'personal_stage_type_ids') {
super.editGroup();
@@ -32,8 +31,8 @@ export class ProjectTaskKanbanHeader extends KanbanHeader {
});
this.dialog.add(FormViewDialog, {
context,
resId: value,
resModel: resModel,
resId: this.group.value,
resModel: this.group.groupByField.relation,
title: this.env._t('Edit Personal Stage'),
onRecordSaved: async () => {
await this.props.list.load();
@@ -45,9 +44,9 @@ export class ProjectTaskKanbanHeader extends KanbanHeader {
async deleteGroup() {
if (this.group.groupByField.name === 'stage_id') {
const action = await this.group.model.orm.call(
this.group.resModel,
this.group.groupByField.relation,
'unlink_wizard',
[this.group.resId],
[this.group.value],
{ context: this.group.context },
);
this.action.doAction(action);
@@ -1,18 +1,53 @@
/** @odoo-module */
import { KanbanModel } from "@web/views/kanban/kanban_model";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { ProjectTaskKanbanDynamicGroupList } from "./project_task_kanban_dynamic_group_list";
import { Record } from '@web/views/relational_model';
export class ProjectTaskKanbanDynamicGroupList extends RelationalModel.DynamicGroupList {
get context() {
const context = { ...super.context };
if (this.creatingPersonnalStage) {
context.default_user_id = context.uid;
delete context.default_project_id;
}
return context;
}
export class ProjectTaskRecord extends Record {
async _applyChanges(changes) {
get isGroupedByStage() {
return !!this.groupByField && this.groupByField.name === "stage_id";
}
get isGroupedByPersonalStages() {
return !!this.groupByField && this.groupByField.name === "personal_stage_type_ids";
}
async createGroup(groupName, groupData, isFolded) {
if (this.isGroupedByPersonalStages) {
return this.model.mutex.exec(async () => {
this.creatingPersonnalStage = true;
await this._createGroup(groupName, groupData, isFolded);
delete this.creatingPersonnalStage;
});
}
return super.createGroup(...arguments);
}
async _unlinkGroups(groups) {
if (this.isGroupedByPersonalStages) {
const groupResIds = groups.map((g) => g.value);
return this.model.orm.call("project.task.type", "remove_personal_stage", groupResIds);
}
return super._deleteGroups(...arguments);
}
}
export class ProjectTaskRecord extends RelationalModel.Record {
async _update(changes, options) {
const value = changes.personal_stage_type_ids;
if (Array.isArray(value)) {
delete changes.personal_stage_type_ids;
changes.personal_stage_type_id = value;
}
await super._applyChanges(changes);
await super._update(changes, options);
}
get context() {
@@ -26,23 +61,7 @@ export class ProjectTaskRecord extends Record {
}
}
export class ProjectTaskKanbanGroup extends KanbanModel.Group {
get isPersonalStageGroup() {
return !!this.groupByField && this.groupByField.name === 'personal_stage_type_ids';
}
async delete() {
if (this.isPersonalStageGroup) {
this.deleted = true;
return await this.model.orm.call(this.resModel, 'remove_personal_stage', [this.resId]);
} else {
return await super.delete();
}
}
}
export class ProjectTaskKanbanModel extends KanbanModel { }
export class ProjectTaskKanbanModel extends RelationalModel {}
ProjectTaskKanbanModel.DynamicGroupList = ProjectTaskKanbanDynamicGroupList;
ProjectTaskKanbanModel.Group = ProjectTaskKanbanGroup;
ProjectTaskKanbanModel.Record = ProjectTaskRecord;
@@ -110,8 +110,9 @@ QUnit.module('Subtask Kanban List tests', {
assert.verifySteps([
"project.task/search_read",
"project.task/write",
"project.task/read",
"project.task/read", // read the parent task to recompute the subtask count
"project.task/web_read",
"project.task/search_read",
"project.task/web_read", // read the parent task to recompute the subtask count
]);
});
});
@@ -4,11 +4,10 @@ import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_service/tour_utils";
function openProjectUpdateAndReturnToTasks(view, viewClass) {
const legacyViewClass = viewClass.replace("o_", "o_legacy_");
return [{
trigger: '.o_project_updates_breadcrumb',
content: 'Open Project Update from view : ' + view,
extra_trigger: `.${viewClass}, .${legacyViewClass}`,
extra_trigger: `.${viewClass}`,
}, {
trigger: ".o-kanban-button-new",
content: "Create a new update from project task view : " + view,
@@ -29,7 +29,7 @@
<ul class="o_checklist" t-if="milestones['list']">
<t t-foreach="milestones['list']" t-as="milestone">
<li t-attf-class="{{milestone['is_reached'] and 'o_checked' or ''}}">
<li t-att-class="milestone['is_reached'] and 'o_checked'" t-attf-id="checkId-{{milestone_index}}">
<t t-esc="milestone['name']"/>
<span t-if="milestone['is_deadline_future'] and not milestone['is_reached'] and not milestone['can_be_marked_as_done']"><font style="color: rgb(190, 190, 190);"><t t-set="color_level" t-value="64"/><t t-call="project.milestone_deadline"/></font></span>
<span t-elif="milestone['is_deadline_exceeded']"><font style="color: rgb(255, 0, 0);"><t t-call="project.milestone_deadline"/></font></span>
@@ -4,9 +4,7 @@ import { registry } from '@web/core/registry';
import { Many2OneField, many2OneField } from '@web/views/fields/many2one/many2one_field';
import { ProductMatrixDialog } from "@product_matrix/js/product_matrix_dialog";
import { useService } from "@web/core/utils/hooks";
const { onWillUpdateProps } = owl;
import { useRecordObserver } from "@web/model/relational_model/utils";
export class PurchaseOrderLineProductField extends Many2OneField {
@@ -15,12 +13,9 @@ export class PurchaseOrderLineProductField extends Many2OneField {
this.dialog = useService("dialog");
this.currentValue = this.value;
onWillUpdateProps(async (nextProps) => {
if (nextProps.record.mode === 'edit' && nextProps.record.data[nextProps.name]) {
if (
!this.currentValue ||
this.currentValue[0] != nextProps.record.data[nextProps.name][0]
) {
useRecordObserver((record) => {
if (record.isInEdition && this.value) {
if (!this.currentValue || this.currentValue[0] != record.data[this.props.name][0]) {
// Field was updated if line was open in edit mode,
// field is not emptied,
// new value is different than existing value.
@@ -28,7 +23,7 @@ export class PurchaseOrderLineProductField extends Many2OneField {
this._onProductTemplateUpdate();
}
}
this.currentValue = nextProps.record.data[nextProps.name];
this.currentValue = record.data[this.props.name];
});
}
@@ -75,10 +70,10 @@ export class PurchaseOrderLineProductField extends Many2OneField {
if (edit) {
// provide attributes of edited line to automatically focus on matching cell in the matrix
for (let ptnvav of this.props.record.data.product_no_variant_attribute_value_ids.records) {
updatedLineAttributes.push(ptnvav.data.id);
updatedLineAttributes.push(ptnvav.resId);
}
for (let ptav of this.props.record.data.product_template_attribute_value_ids.records) {
updatedLineAttributes.push(ptav.data.id);
updatedLineAttributes.push(ptav.resId);
}
updatedLineAttributes.sort((a, b) => { return a - b; });
}
@@ -91,7 +86,7 @@ export class PurchaseOrderLineProductField extends Many2OneField {
if (!edit) {
// remove new line used to open the matrix
PurchaseOrderRecord.data.order_line.removeRecord(this.props.record);
PurchaseOrderRecord.data.order_line.delete(this.props.record);
}
}
@@ -8,7 +8,7 @@ import { ListRenderer } from "@web/views/list/list_renderer";
export class FieldMany2ManyAltPOsRenderer extends ListRenderer {
isCurrentRecord(record) {
return record.data.id === this.env.model.root.data.id;
return record.resId === this.props.list.model.root.resId;
}
}
@@ -31,8 +31,8 @@ export class FieldMany2ManyAltPOs extends X2ManyField {
* @override
*/
async openRecord(record) {
if (record.data.id !== this.props.record.data.id) {
const action = await this.orm.call(record.resModel, "get_formview_action", [[record.data.id]], {
if (record.resId !== this.props.record.resId) {
const action = await this.orm.call(record.resModel, "get_formview_action", [[record.resId]], {
context: this.props.context,
});
await this.action.doAction(action);
@@ -1,25 +1,17 @@
/** @odoo-module */
import { KanbanModel, KanbanDynamicRecordList } from "@web/views/kanban/kanban_model";
export class ProductCatalogKanbanDynamicRecordList extends KanbanDynamicRecordList {
import { RelationalModel } from "@web/model/relational_model/relational_model";
async load(params = {}) {
export class ProductCatalogKanbanModel extends RelationalModel {
async load() {
await super.load(...arguments);
await this._loadCatalogData();
}
async _loadCatalogData() {
const saleOrderLinesInfo = await this.model.rpc("/sales/catalog/sale_order_lines_info", {
order_id: this.context.order_id,
product_ids: this.records.map((rec) => rec.resId),
const saleOrderLinesInfo = await this.rpc("/sales/catalog/sale_order_lines_info", {
order_id: this.config.context.order_id,
product_ids: this.root.records.map((rec) => rec.resId),
});
for (const record of this.records) {
for (const record of this.root.records) {
record.productCatalogData = saleOrderLinesInfo[record.resId];
}
}
}
export class ProductCatalogKanbanModel extends KanbanModel {}
ProductCatalogKanbanModel.DynamicRecordList = ProductCatalogKanbanDynamicRecordList;
@@ -28,7 +28,7 @@ export class ProductDiscountField extends FloatField {
return;
}
const isFirstOrderLine = this.props.record.data.id === orderLines[0].data.id;
const isFirstOrderLine = this.props.record.resId === orderLines[0].data.id;
if (isFirstOrderLine && sameValue(orderLines)) {
this.dialogService.add(ConfirmationDialog, {
body: _lt("Do you want to apply this value to all lines?"),
@@ -112,8 +112,8 @@ QUnit.module("Fields", (hooks) => {
assert.containsOnce(target, ".modal:contains(Create new product as a new Product)")
assert.verifySteps([
"get_views",
"onchange",
"onchange",
"onchange2",
"onchange2",
"name_search",
]);
@@ -10,7 +10,7 @@
<page string="Optional Products"
name="optional_products"
attrs="{'invisible': [('state', 'not in', ['draft', 'sent'])]}">
<field name="sale_order_option_ids" mode="tree,form,kanban">
<field name="sale_order_option_ids" mode="tree,kanban">
<form string="Optional Products">
<group>
<field name="product_id"
@@ -368,55 +368,6 @@ export class ProductConfiguratorDialog extends Component {
);
}
/**
* Serialize the product into a format understandable by `sale.order.line`.
*
* @param {Object} product - The product to serialize.
* @return {Object} - The serialized product.
*/
_serializeProduct(product) {
let serializedProduct = {
product_id: [product.id, product.display_name],
product_uom_qty: product.quantity,
}
// handle custom values & no variants
let customValuesCommands = [{ operation: "DELETE_ALL" }];
for (const ptal of product.attribute_lines) {
const selectedCustomPTAV = ptal.attribute_values.find(
ptav => ptav.is_custom && ptav.id === ptal.selected_attribute_value_id
);
if (selectedCustomPTAV) customValuesCommands.push({
operation: "CREATE",
context: [
{
default_custom_product_template_attribute_value_id: selectedCustomPTAV.id,
default_custom_value: ptal.customValue,
},
],
});
}
serializedProduct.product_custom_attribute_value_ids = {
operation: "MULTI",
commands: customValuesCommands,
};
let noVariantCommands = [{ operation: "DELETE_ALL" }];
const noVariantPTAVIds = product.attribute_lines.filter(
ptal => ptal.create_variant === "no_variant" && ptal.attribute_values.length > 1
).map(ptal => { return {id: ptal.selected_attribute_value_id}});
if (noVariantPTAVIds.length) noVariantCommands.push({
operation: "ADD_M2M",
ids: noVariantPTAVIds,
});
serializedProduct.product_no_variant_attribute_value_ids = {
operation: "MULTI",
commands: noVariantCommands,
};
return serializedProduct;
}
/**
* Confirm the current combination(s).
*
@@ -435,12 +386,12 @@ export class ProductConfiguratorDialog extends Component {
}
}
await this.props.save(
this._serializeProduct(this.state.products.find(
this.state.products.find(
p => p.product_tmpl_id === this.env.mainProductTmplId
)),
),
this.state.products.filter(
p => p.product_tmpl_id !== this.env.mainProductTmplId
).map(p => this._serializeProduct(p)),
),
);
this.props.close();
}
@@ -6,6 +6,36 @@ import { SaleOrderLineProductField } from '@sale/js/sale_product_field';
import { serializeDateTime } from "@web/core/l10n/dates";
import { ProductConfiguratorDialog } from "./product_configurator_dialog/product_configurator_dialog";
async function applyProduct(record, product) {
// handle custom values & no variants
const contextRecords = [];
for (const ptal of product.attribute_lines) {
const selectedCustomPTAV = ptal.attribute_values.find(
ptav => ptav.is_custom && ptav.id === ptal.selected_attribute_value_id
);
if (selectedCustomPTAV) {
contextRecords.push({
default_custom_product_template_attribute_value_id: selectedCustomPTAV.id,
default_custom_value: ptal.customValue,
});
};
}
const proms = [];
proms.push(record.data.product_custom_attribute_value_ids.createAndReplace(contextRecords));
const noVariantPTAVIds = product.attribute_lines.filter(
ptal => ptal.create_variant === "no_variant" && ptal.attribute_values.length > 1
).map(ptal => ptal.selected_attribute_value_id);
proms.push(record.data.product_no_variant_attribute_value_ids.replaceWith(noVariantPTAVIds, { silent: true }));
await Promise.all(proms);
await record.update({
product_id: [product.id, product.display_name],
product_uom_qty: product.quantity,
});
};
patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', {
setup() {
@@ -78,9 +108,9 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', {
this.dialog.add(ProductConfiguratorDialog, {
productTemplateId: this.props.record.data.product_template_id[0],
ptavIds: this.props.record.data.product_template_attribute_value_ids.records.map(
record => record.data.id
record => record.resId
).concat(this.props.record.data.product_no_variant_attribute_value_ids.records.map(
record => record.data.id
record => record.resId
)),
customAttributeValues: customAttributeValues.map(
data => {
@@ -98,18 +128,20 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_configurator', {
soDate: serializeDateTime(saleOrderRecord.data.date_order),
edit: edit,
save: async (mainProduct, optionalProducts) => {
await this.props.record.update(mainProduct);
await applyProduct(this.props.record, mainProduct);
this._onProductUpdate();
saleOrderRecord.data.order_line.leaveEditMode();
for (const optionalProduct of optionalProducts) {
const line = await saleOrderRecord.data.order_line.addNew({
const line = await saleOrderRecord.data.order_line.addNewRecord({
position: 'bottom',
mode: "readonly",
});
line.update(optionalProduct);
await applyProduct(line, optionalProduct);
}
saleOrderRecord.data.order_line.unselectRecord();
},
discard: () => {
saleOrderRecord.data.order_line.removeRecord(this.props.record);
saleOrderRecord.data.order_line.delete(this.props.record);
},
});
},
@@ -26,10 +26,10 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_matrix', {
if (edit) {
// provide attributes of edited line to automatically focus on matching cell in the matrix
for (let ptnvav of this.props.record.data.product_no_variant_attribute_value_ids.records) {
updatedLineAttributes.push(ptnvav.data.id);
updatedLineAttributes.push(ptnvav.resId);
}
for (let ptav of this.props.record.data.product_template_attribute_value_ids.records) {
updatedLineAttributes.push(ptav.data.id);
updatedLineAttributes.push(ptav.resId);
}
updatedLineAttributes.sort((a, b) => { return a - b; });
}
@@ -42,7 +42,7 @@ patch(SaleOrderLineProductField.prototype, 'sale_product_matrix', {
if (!edit) {
// remove new line used to open the matrix
saleOrderRecord.data.order_line.removeRecord(this.props.record);
saleOrderRecord.data.order_line.delete(this.props.record);
}
},
@@ -19,8 +19,8 @@ export class QtyAtDatePopover extends Component {
active_model: 'product.product',
active_id: this.props.record.data.product_id[0],
warehouse: this.props.record.data.warehouse_id && this.props.record.data.warehouse_id[0],
move_to_match_ids: this.props.record.data.move_ids.records.map(record => record.data.id),
sale_line_to_match_id: this.props.record.data.id,
move_to_match_ids: this.props.record.data.move_ids.records.map(record => record.resId),
sale_line_to_match_id: this.props.record.resId,
},
});
}
@@ -4,13 +4,13 @@ import { OdooViewsDataSource } from "@spreadsheet/data_sources/odoo_views_data_s
import { LoadingDataError } from "@spreadsheet/o_spreadsheet/errors";
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { orderByToString } from "@web/views/utils";
import {
formatDateTime,
deserializeDateTime,
formatDate,
deserializeDate,
} from "@web/core/l10n/dates";
import { orderByToString } from "@web/search/utils/order_by";
import * as spreadsheet from "@odoo/o-spreadsheet";
@@ -21,7 +21,7 @@ export class SMLX2ManyField extends X2ManyField {
this.openRecord = useOpenMany2XRecord({
resModel: "stock.quant",
activeActions: this.activeActions,
onRecordSaved: (resId) => this.selectRecord([resId.data.id]),
onRecordSaved: (record) => this.selectRecord([record.resId]),
onRecordDiscarted: (resId) => this.selectRecord(resId),
fieldString: this.props.string,
is2Many: true,
@@ -1,17 +1,9 @@
/** @odoo-module */
import { useBus } from "@web/core/utils/hooks";
import { session } from "@web/session";
import { ListController } from "@web/views/list/list_controller";
export class InventoryReportListController extends ListController {
setup() {
super.setup();
if (this.props.context.inventory_mode || this.props.context.inventory_report_mode) {
useBus(this.model.bus, "record-updated", this.recordUpdated);
}
}
get actionMenuItems() {
const actionMenus = super.actionMenuItems;
if (
@@ -28,37 +20,6 @@ export class InventoryReportListController extends ListController {
return actionMenus;
}
/**
* Handler called when a record has been created or updated.
* We need to detect when the user added to the list a quant which already exists
* (see stock.quant.create), either already loaded or not, to warn the user
* the quant was updated.
* This is done by checking :
* - the record id against the 'lastCreatedRecordId' on model
* - the create_date against the write_date (both are equal for newly created records).
*
* @param {CustomEvent<{ record: Record, relatedRecords: Record[] }>} event
*/
async recordUpdated(event) {
const { record, relatedRecords } = event.detail;
const justCreated = record.id == record.model.lastCreatedRecordId;
if (justCreated && record.data.create_date.diff(record.data.write_date) != 0) {
this.notificationService.add(
this.env._t(
"You tried to create a record that already exists. The existing record was modified instead."
),
{ title: this.env._t("This record already exists.") }
);
if (relatedRecords.length > 0) {
/* more than 1 'resId' record loaded in view (user added an already loaded record) :
* - both have been updated
* - remove the current record (the added one)
*/
this.model.root.removeRecord(record);
}
}
}
/**
* Handler called when the user clicked on the 'Apply all' button.
*/
@@ -1,28 +1,68 @@
/** @odoo-module */
import { RelationalModel, DynamicRecordList } from "@web/views/relational_model";
import { DynamicRecordList } from "@web/model/relational_model/dynamic_record_list";
import { RelationalModel } from "@web/model/relational_model/relational_model";
export class InventoryReportListModel extends RelationalModel {
/**
* Override
*/
setup(params, { action, dialog, notification, rpc, user, view, company }) {
// model has not created any record yet
this.lastCreatedRecordId = "";
this._lastCreatedRecordId;
return super.setup(...arguments);
}
/**
* Function called when a record has been _load (after saved).
* We need to detect when the user added to the list a quant which already exists
* (see stock.quant.create), either already loaded or not, to warn the user
* the quant was updated.
* This is done by checking :
* - the record id against the '_lastCreatedRecordId' on model
* - the create_date against the write_date (both are equal for newly created records).
*
*/
async _updateSimilarRecords(reloadedRecord, serverValues) {
if (this.config.isMonoRecord) {
return;
}
const justCreated = reloadedRecord.id == this._lastCreatedRecordId;
if (justCreated && serverValues.create_date !== serverValues.write_date) {
this.notification.add(
this.env._t(
"You tried to create a record that already exists. The existing record was modified instead."
),
{ title: this.env._t("This record already exists.") }
);
const duplicateRecords = this.root.records.filter(
(record) => record.resId === reloadedRecord.resId && record.id !== reloadedRecord.id
);
if (duplicateRecords.length > 0) {
/* more than 1 'resId' record loaded in view (user added an already loaded record) :
* - both have been updated
* - remove the current record (the added one)
*/
await this.root._removeRecords([reloadedRecord.id]);
for (const record of duplicateRecords) {
record._applyValues(serverValues);
}
}
} else {
super._updateSimilarRecords(...arguments)
}
}
}
export class InventoryReportListDynamicRecordList extends DynamicRecordList {
/**
* Override
*/
async createRecord(params = {}, atFirstPosition = false) {
const record = await super.createRecord(...arguments);
async addNewRecord() {
const record = await super.addNewRecord(...arguments);
// keep created record id on model
record.model.lastCreatedRecordId = record.id;
record.model._lastCreatedRecordId = record.id;
return record;
}
}
@@ -18,7 +18,7 @@ export class StockPickingFormController extends FormController {
let record;
if (id) {
record = this.model.root.data.move_ids_without_package.records.find(
(e) => e.data.id == id
(e) => e.resId == id
);
} else {
const moveByIdsDesc = sortBy(
@@ -27,7 +27,7 @@ export class StockPickingFormController extends FormController {
"desc"
);
record = moveByIdsDesc.find((e) => e.data.product_id[0] == ev.detail.product_id[0]);
id = record.data.id;
id = record.resId;
}
const action = await this.model.orm.call("stock.move", "action_show_details", [id]);
if (action) {
@@ -1,12 +1,9 @@
/** @odoo-module **/
import { Record, RelationalModel } from "@web/views/basic_relational_model";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { Record } from "@web/model/relational_model/record";
export class StockPickingAutoSaveRecord extends Record {
setup(params, state) {
super.setup(params, state);
}
async saveAndOpenDetails() {
await new Promise((resolve) => {
this.model.env.bus.trigger("STOCK_MOVE:UPDATED", { resolve });
@@ -36,7 +36,7 @@ export class StockPickFrom extends Many2OneField {
_quant_display_name() {
let name_parts = [];
if (this.props.record.data.id) {
if (this.props.record.resId) {
// if location group is activated
const data = this.props.record.data;
if (this.enabledGroups?.location && data.location_id) {
@@ -61,7 +61,7 @@ export class QuestionPageListRenderer extends ListRenderer {
getSectionColumns(columns) {
let titleColumnIndex = 0;
let found = false;
let colspan = 1
let colspan = 1;
for (let index = 0; index < columns.length; index++) {
const col = columns[index];
if (!found && col.name !== this.titleField) {
@@ -78,9 +78,11 @@ export class QuestionPageListRenderer extends ListRenderer {
colspan += 1;
}
const sectionColumns = columns.slice(0, titleColumnIndex + 1).concat(columns.slice(titleColumnIndex + colspan));
const sectionColumns = columns
.slice(0, titleColumnIndex + 1)
.concat(columns.slice(titleColumnIndex + colspan));
sectionColumns[titleColumnIndex] = {...sectionColumns[titleColumnIndex], colspan};
sectionColumns[titleColumnIndex] = { ...sectionColumns[titleColumnIndex], colspan };
return sectionColumns;
}
@@ -103,9 +105,9 @@ export class QuestionPageListRenderer extends ListRenderer {
* @override
*/
focusCell(column, forward = true) {
const actualColumn = column.name ? this.state.columns.find(
(col) => col.name === column.name
) : column;
const actualColumn = column.name
? this.state.columns.find((col) => col.name === column.name)
: column;
super.focusCell(actualColumn, forward);
}
@@ -114,7 +116,7 @@ export class QuestionPageListRenderer extends ListRenderer {
case "enter":
case "tab":
case "shift+tab": {
this.props.list.unselectRecord(true);
this.props.list.leaveEditMode();
return true;
}
}
@@ -131,10 +133,12 @@ export class QuestionPageListRenderer extends ListRenderer {
* @return {Promise<void>}
*/
async onDeleteRecord(record) {
const triggeredRecords = this.props.list.records.filter(rec => rec.data.triggering_question_id[0] === record.data.id);
const triggeredRecords = this.props.list.records.filter(
(rec) => rec.data.triggering_question_id[0] === record.resId
);
if (triggeredRecords.length) {
const res = await super.onDeleteRecord(record);
await this.props.list.model.root.save({stayInEdition: true});
await this.props.list.model.root.save({ stayInEdition: true });
return res;
} else {
return super.onDeleteRecord(record);
@@ -4,7 +4,6 @@ import { QuestionPageListRenderer } from "./question_page_list_renderer";
import { registry } from "@web/core/registry";
import { useOpenX2ManyRecord, useX2ManyCrud, X2ManyFieldDialog } from "@web/views/fields/relational_utils";
import { patch } from '@web/core/utils/patch';
import { useService } from "@web/core/utils/hooks";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
const { useSubEnv } = owl;
@@ -27,13 +26,34 @@ patch(X2ManyFieldDialog.prototype, 'survey_question_chaining_with_validation', {
}
});
/**
* For convenience, we'll prevent closing the question form dialog and
* stay in edit mode to make sure only valid records are saved. Therefore,
* in case of error occurring when saving we will replace default error
* modal with a notification.
*/
class SurveySaveError extends Error {}
function SurveySaveErrorHandler(env, error, originalError) {
if (originalError instanceof SurveySaveError) {
env.services.notification.add(originalError.message, {
title: env._t("Validation Error"),
type: "danger",
});
return true;
}
}
registry
.category("error_handlers")
.add("surveySaveErrorHandler", SurveySaveErrorHandler, { sequence: 10 });
class QuestionPageOneToManyField extends X2ManyField {
setup() {
super.setup();
useSubEnv({
openRecord: (record) => this.openRecord(record),
});
this.notificationService = useService("notification");
// Systematically and automatically save SurveyForm at each question edit/creation
// enables checking validation parameters consistency and using questions as triggers
@@ -46,23 +66,24 @@ class QuestionPageOneToManyField extends X2ManyField {
const self = this;
const saveRecord = async (record) => {
const saveResponse = await superSaveRecord(record);
await superSaveRecord(record);
try {
await self.props.record.save({stayInEdition: true, throwOnError: true});
await self.props.record.save();
} catch (error) {
return self.handleSurveySaveError(error, record);
// In case of error occurring when saving.
// Remove erroneous question row added to the embedded list
await this.list.delete(record);
throw new SurveySaveError(error.data.message);
}
return saveResponse;
};
const updateRecord = async (record) => {
const updateResponse = await superUpdateRecord(record);
await superUpdateRecord(record);
try {
await self.props.record.save({stayInEdition: true, throwOnError: true});
await self.props.record.save();
} catch (error) {
return self.handleSurveySaveError(error);
throw new SurveySaveError(error.data.message);
}
return updateResponse;
};
const openRecord = useOpenX2ManyRecord({
@@ -74,7 +95,7 @@ class QuestionPageOneToManyField extends X2ManyField {
updateRecord,
});
this._openRecord = async (params) => {
if (!await self.props.record.save({ stayInEdition: true })) {
if (!await self.props.record.save()) {
// do not open question form as it won't be savable either.
return;
}
@@ -88,35 +109,6 @@ class QuestionPageOneToManyField extends X2ManyField {
};
this.canOpenRecord = true;
}
/**
* For convenience, we'll prevent closing the question form dialog and
* stay in edit mode to make sure only valid records are saved. Therefore,
* two things should be cared for in case of error occurring when saving
* the question:
* * Remove erroneous question row added to the embedded list
* * Replace default error modal with a notification
*
* @param {Error} error Error thrown when saving survey/question.
* @param {Record?} recordToDelete (optional) In case the error is
* thrown when saving a new question, it should be deleted from the
* list.
*/
async handleSurveySaveError(error, recordToDelete) {
error.event.preventDefault();
if (recordToDelete) {
const listRecord = this.list.records.find(r => r.__bm_handle__ === recordToDelete.__bm_handle__);
await this.list.delete(listRecord.id);
}
this.notificationService.add(
error.message.data.message, {
title: this.env._t("Validation Error"),
type: "danger"
}
);
// Prevent closing the question form view
throw error;
}
}
QuestionPageOneToManyField.components = {
...X2ManyField.components,
@@ -71,7 +71,7 @@ export class SurveyQuestionTriggerWidget extends Component {
return "";
}
const triggerId = record.data.triggering_question_id[0];
let triggerRecord = record.model.root.data.question_and_page_ids.records.find(rec => rec.data.id === triggerId);
let triggerRecord = record.model.root.data.question_and_page_ids.records.find(rec => rec.resId === triggerId);
if (!triggerRecord) {
return "MISSING_TRIGGER_ERROR";
@@ -80,7 +80,7 @@ export class SurveyQuestionTriggerWidget extends Component {
return "WRONG_QUESTIONS_SELECTION_WARNING";
}
if (record.data.sequence < triggerRecord.data.sequence ||
(record.data.sequence === triggerRecord.data.sequence && record.data.id < triggerId)) {
(record.data.sequence === triggerRecord.data.sequence && record.resId < triggerId)) {
return "MISPLACED_TRIGGER_WARNING";
}
return "";
@@ -95,5 +95,9 @@ SurveyQuestionTriggerWidget.props = {
export const surveyQuestionTriggerWidget = {
component: SurveyQuestionTriggerWidget,
displayName: "Trigger",
fieldDependencies: [
{ name: "triggering_question_id", type: "many2one" },
{ name: "triggering_answer_id", type: "many2one" },
],
};
registry.category("view_widgets").add("survey_question_trigger", surveyQuestionTriggerWidget);
@@ -1,9 +1,10 @@
/** @odoo-module */
import { makeServerError } from "@web/../tests/helpers/mock_server";
import { click, editInput, getFixture, nextTick, triggerHotkey } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { makeServerError } from "@web/../tests/helpers/mock_server";
import { errorService } from "@web/core/errors/error_service";
import { registry } from "@web/core/registry";
QUnit.module("QuestionPageOneToManyField", (hooks) => {
let serverData;
@@ -170,6 +171,7 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
});
QUnit.test("A validation error from saving parent form notifies and prevents dialog from closing", async (assert) => {
registry.category("services").add("error", errorService);
await makeView({
type: "form",
resModel: "survey",
@@ -201,8 +203,11 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
await editInput(target, ".o_dialog:not(.o_inactive_modal) .modal-body [name='title'] input", "Invalid RecordTitle");
await click(target.querySelector(".o_dialog:not(.o_inactive_modal) .o_form_button_save"));
assert.verifySteps(["save parent form"]);
await nextTick();
assert.containsOnce(document.body, ".o_notification");
assert.containsOnce(target, ".modal .o_form_view");
assert.containsOnce(target, ".modal-dialog .o_form_button_save");
assert.containsNone(target, ".modal-dialog .o_form_button_save[disabled='1']");
});
QUnit.test("can create section inline", async (assert) => {
@@ -3,7 +3,6 @@
from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestUi(HttpCase):
@@ -344,7 +344,7 @@ QUnit.module("test_mail", {}, function () {
const { openView } = await start({
serverData,
mockRPC: function (route, args) {
if (["get_activity_data", "web_search_read"].includes(args.method)) {
if (["get_activity_data", "unity_web_search_read"].includes(args.method)) {
assert.step(JSON.stringify(args.kwargs.domain));
}
},
@@ -1016,8 +1016,8 @@ QUnit.module("test_mail", {}, function () {
const { target, openView } = await start({
serverData,
mockRPC(route, { method, kwargs }, result) {
if (method === "web_search_read") {
assert.deepEqual(kwargs.fields, ["write_date", "id"]);
if (method === "unity_web_search_read") {
assert.deepEqual(Object.keys(kwargs.specification), ["write_date", "id"]);
return Promise.resolve({
length: 2,
records: [
@@ -49,7 +49,7 @@ registry.category("web_tour.tours").add('event_sale_with_product_configurator_to
extra_trigger: 'td[name="price_subtotal"]:contains("16.50")', // wait for the optional product line
}, {
trigger: 'div[name="product_template_id"] input',
extra_trigger: '[name="product_template_id"] .o_dropdown_button',
extra_trigger: '.o_data_row:nth-child(3)', // wait for the new row to be created
run: 'text event (',
}, {
trigger: 'ul.ui-autocomplete a:contains("Registration Event (TEST variants)")',
@@ -83,7 +83,7 @@ registry.category("web_tour.tours").add('event_sale_with_product_configurator_to
extra_trigger: 'td[name="price_subtotal"]:contains("150.00")', // wait for the adult tickets line
}, {
trigger: 'div[name="product_template_id"] input',
extra_trigger: '[name="product_template_id"] .o_dropdown_button',
extra_trigger: '.o_data_row:nth-child(4)', // wait for the new row to be created
run: 'text event (',
}, {
trigger: 'ul.ui-autocomplete a:contains("Registration Event (TEST variants)")',
@@ -541,12 +541,7 @@ export class HtmlField extends Component {
if (!(this.props.record.fieldNames.includes('attachment_ids') && this.props.record.resModel === 'mail.compose.message')) {
return;
}
this.props.record.update({
attachment_ids: {
operation: "ADD_M2M",
ids: attachment,
},
});
this.props.record.data.attachment_ids.linkTo(attachment.res_id, attachment);
}
_onDblClickEditableMedia(ev) {
const el = ev.target;
@@ -8,7 +8,7 @@ export class AddForumFormController extends NewContentFormController {
* @override
*/
computePath() {
return `/forum/${encodeURIComponent(this.model.root.data.id)}`;
return `/forum/${encodeURIComponent(this.model.root.resId)}`;
}
}
@@ -72,7 +72,7 @@ export class SlideCategoryListRenderer extends ListRenderer {
case "enter":
case "tab":
case "shift+tab": {
this.props.list.unselectRecord(true);
this.props.list.leaveEditMode();
return true;
}
}
@@ -139,7 +139,6 @@ QUnit.test("click on real line opens a dialog", async (assert) => {
</form>`,
});
await click($(".o_data_row:nth-child(2) .o_data_cell")[0]);
assert.containsNone($, ".o_selected_row");
assert.containsOnce($, ".modal .o_form_view");
});