[MERGE][REF] web: new RelationalModel
Before this PR, we had several implementations of a relational model. The form view used the BasicRelationalModel, which was a wrapper around the BasicModel. The list view used the RelationalModel (a simpler version that didn't support complex usecases of x2many fields). The kanban view used the KanbanModel, an extension of RelationalModel. This PR removes all these, and replaces them by a single model. It is called RelationalModel, and as a similar structure as its predecessor (with datapoints). It now handles tricky x2many usecases of the form view though. This PR also comes with several important changes: 1) The new model is based on unity reads [1]. This means that all data required by the view is fetched at once, and datapoints are created with their data (whereas before, they were responsible to fetch their data). There's an exception for special data: they are no longer handler by the model. A hook "useSpecialData" has been created, and it allows fields (components) to fetch additional data they require. 2) The new model uses onchange2 [2]. With onchange2, we send and receive a diff, instead of the whole state of the record. Morever, onchange2 uses unity, which means that returned commands for x2manys (e.g. command 4, LINK) contain the values of the corresponding records, so we don't need to fetch them in a separate call afterwards. 3) The new model no longer triggers deep renderings (render(true)). It is instead based on fine-grained reactivity (i.e. only what depends on what has changed is re-rendered). We introduced a hook "observeRecord" which allows a component to subscribe itself to some keys in a record datapoint, and register a callback to execute when those keys change (e.g. to fetch data). [1] odoo/odoo@7d2baaa0c7 [2] odoo/odoo@f5e6494da3 Task~3179751 closes odoo/odoo#114024 Related: odoo/enterprise#43125 Signed-off-by: Géry Debongnie <ged@odoo.com> 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:
co-authored by
Aaron Bohy
FrancoisGe
Jorge Pinna Puissant
Pierre Rousseau
commit
d4fe919db5
@@ -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;
|
||||
|
||||
+30
-27
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Component, 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,16 @@ 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 }
|
||||
);
|
||||
};
|
||||
this.model = useState(useModel(this.props.Model, modelParams));
|
||||
|
||||
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])
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+29
-25
@@ -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});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { batched } from "@point_of_sale/utils";
|
||||
import { batched } from "@web/core/utils/timing";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
import { Reactive, effect } from "@web/core/utils/reactive";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { markRaw, reactive } from "@odoo/owl";
|
||||
import { roundPrecision as round_pr, floatIsZero } from "@web/core/utils/numbers";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
|
||||
import { batched, deduceUrl } from "@point_of_sale/utils";
|
||||
import { deduceUrl } from "@point_of_sale/utils";
|
||||
import { Reactive } from "@web/core/utils/reactive";
|
||||
import { HWPrinter } from "@point_of_sale/app/printer/hw_printer";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
@@ -20,6 +20,7 @@ import { sprintf } from "@web/core/utils/strings";
|
||||
import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen";
|
||||
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
||||
import { renderToString } from "@web/core/utils/render";
|
||||
import { batched } from "@web/core/utils/timing";
|
||||
|
||||
/* Returns an array containing all elements of the given
|
||||
* array corresponding to the rule function {agg} and without duplicates
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
/** @odoo-module */
|
||||
|
||||
/**
|
||||
* Creates a batched version of a callback so that all calls to it in the same
|
||||
* microtick will only call the original callback once.
|
||||
*
|
||||
* @param callback the callback to batch
|
||||
* @returns a batched version of the original callback
|
||||
*/
|
||||
export function batched(callback) {
|
||||
let called = false;
|
||||
return async (...args) => {
|
||||
// This await blocks all calls to the callback here, then releases them sequentially
|
||||
// in the next microtick. This line decides the granularity of the batch.
|
||||
await Promise.resolve();
|
||||
if (!called) {
|
||||
called = true;
|
||||
// so that only the first call to the batched function calls the original callback.
|
||||
// Schedule this before calling the callback so that calls to the batched function
|
||||
// within the callback will proceed only after resetting called to false, and have
|
||||
// a chance to execute the callback again
|
||||
Promise.resolve().then(() => (called = false));
|
||||
callback(...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* comes from o_spreadsheet.js
|
||||
* https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
import { batched } from "@point_of_sale/utils";
|
||||
import { batched } from "@web/core/utils/timing";
|
||||
|
||||
export class SelfOrderBus {
|
||||
static serviceDependencies = ["self_order", "orm", "bus_service"];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Order } from "./models/order";
|
||||
import { Product } from "./models/product";
|
||||
import { Line } from "./models/line";
|
||||
import { ConnectionLostError, RPCError } from "@web/core/network/rpc_service";
|
||||
import { batched } from "@point_of_sale/utils";
|
||||
import { batched } from "@web/core/utils/timing";
|
||||
|
||||
export class SelfOrder {
|
||||
constructor(...args) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
-49
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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?"),
|
||||
|
||||
@@ -34,7 +34,13 @@ QUnit.module("Fields", (hooks) => {
|
||||
},
|
||||
'sale.order.line': {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
order_id: {
|
||||
string: "Order Reference",
|
||||
type: "many2one",
|
||||
relation: "sale.order",
|
||||
relation_field: "order_line",
|
||||
},
|
||||
product_template_id: {
|
||||
string: "Product",
|
||||
type: "many2one",
|
||||
relation: "product.template",
|
||||
@@ -112,8 +118,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"
|
||||
|
||||
+3
-52
@@ -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,
|
||||
|
||||
+6
-2
@@ -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: [
|
||||
|
||||
+2
-2
@@ -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)")',
|
||||
|
||||
@@ -177,6 +177,7 @@ This module provides the core of the Odoo Web Client.
|
||||
|
||||
'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/**/*',
|
||||
@@ -228,7 +229,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/utils.js',
|
||||
'web/static/src/legacy/pseudo_web_client.js',
|
||||
'web/static/src/legacy/web_client.js',
|
||||
'web/static/src/legacy/js/_deprecated/*',
|
||||
'web/static/src/legacy/js/chrome/*',
|
||||
'web/static/src/legacy/js/components/*',
|
||||
'web/static/src/legacy/js/control_panel/*',
|
||||
@@ -241,7 +241,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'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/colorpicker_dialog.js',
|
||||
'web/static/src/legacy/js/widgets/date_picker.js',
|
||||
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
|
||||
|
||||
@@ -104,11 +104,17 @@ class Base(models.AbstractModel):
|
||||
return vals
|
||||
|
||||
for field_name, field_spec in specification.items():
|
||||
if not field_spec:
|
||||
field = self._fields.get(field_name)
|
||||
if field is None:
|
||||
continue
|
||||
field = self._fields[field_name]
|
||||
|
||||
if field.type == 'many2one' and 'fields' in field_spec:
|
||||
if field.type == 'many2one':
|
||||
if 'fields' not in field_spec:
|
||||
for values in values_list:
|
||||
if isinstance(values[field_name], NewId):
|
||||
values[field_name] = values[field_name].origin
|
||||
continue
|
||||
|
||||
co_records = self[field_name]
|
||||
if 'context' in field_spec:
|
||||
co_records = co_records.with_context(**field_spec['context'])
|
||||
@@ -128,9 +134,13 @@ class Base(models.AbstractModel):
|
||||
for values in values_list:
|
||||
if values[field_name] is False:
|
||||
continue
|
||||
values[field_name] = many2one_data[values[field_name]]
|
||||
vals = many2one_data[values[field_name]]
|
||||
values[field_name] = vals['id'] and vals
|
||||
|
||||
elif field.type in ('one2many', 'many2many'):
|
||||
if not field_spec:
|
||||
continue
|
||||
|
||||
co_records = self[field_name]
|
||||
|
||||
if 'order' in field_spec and field_spec['order']:
|
||||
@@ -164,6 +174,9 @@ class Base(models.AbstractModel):
|
||||
values[field_name] = [x2many_data.get(id_) or {'id': id_} for id_ in values[field_name]]
|
||||
|
||||
elif field.type in ('reference', 'many2one_reference'):
|
||||
if not field_spec:
|
||||
continue
|
||||
|
||||
values_by_id = {
|
||||
vals['id']: vals
|
||||
for vals in values_list
|
||||
|
||||
@@ -174,6 +174,18 @@ export class Domain {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array[] | boolean} modifier
|
||||
* @param {Object} evalContext
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function evalDomain(modifier, evalContext) {
|
||||
if (modifier && typeof modifier !== "boolean") {
|
||||
modifier = new Domain(modifier).contains(evalContext);
|
||||
}
|
||||
return Boolean(modifier);
|
||||
}
|
||||
|
||||
/** @type {Condition} */
|
||||
const TRUE_LEAF = [1, "=", 1];
|
||||
/** @type {Condition} */
|
||||
|
||||
@@ -31,7 +31,7 @@ export class Reactive {
|
||||
* Creates a side-effect that runs based on the content of reactive objects.
|
||||
*
|
||||
* @template {object[]} T
|
||||
* @param {(...args: [...T]) => void} cb callback for the effect
|
||||
* @param {(...args: [...T]) => X} cb callback for the effect
|
||||
* @param {[...T]} deps the reactive objects that the effect depends on
|
||||
*/
|
||||
export function effect(cb, deps) {
|
||||
|
||||
@@ -3,6 +3,25 @@
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { onWillUnmount, useComponent } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* Creates a batched version of a callback so that all calls to it in the same
|
||||
* time frame will only call the original callback once.
|
||||
* @param callback the callback to batch
|
||||
* @param synchronize this function decides the granularity of the batch (a microtick by default)
|
||||
* @returns a batched version of the original callback
|
||||
*/
|
||||
export function batched(callback, synchronize = () => Promise.resolve()) {
|
||||
let scheduled = false;
|
||||
return async (...args) => {
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
await synchronize();
|
||||
scheduled = false;
|
||||
callback(...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a new debounced version of the passed function (func)
|
||||
* which will postpone its execution until after 'delay' milliseconds
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/** @odoo-module */
|
||||
import Dialog from "web.Dialog";
|
||||
import core from "web.core";
|
||||
|
||||
/**
|
||||
* An extension of Dialog Widget to render warnings.
|
||||
*/
|
||||
export const WarningDialog = Dialog.extend({
|
||||
template: 'web.LegacyWarningDialog',
|
||||
/**
|
||||
* @param {Object} error
|
||||
* @param {string} error.message the message in Warning Dialog
|
||||
*
|
||||
* @constructor
|
||||
*/
|
||||
init: function (parent, options, error) {
|
||||
options.size = options.size || "medium";
|
||||
this._super.apply(this, [parent, options]);
|
||||
this.message = error.message;
|
||||
core.bus.off('close_dialogs', this);
|
||||
},
|
||||
/**
|
||||
* Focuses the ok button.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
open: function () {
|
||||
this._super({shouldFocusButtons: true});
|
||||
},
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
/** @odoo-module alias=web.AbstractModel **/
|
||||
|
||||
/**
|
||||
* An AbstractModel is the M in MVC. We tend to think of MVC more on the server
|
||||
* side, but we are talking here on the web client side.
|
||||
*
|
||||
* The duties of the Model are to fetch all relevant data, and to make them
|
||||
* available for the rest of the view. Also, every modification to that data
|
||||
* should pass through the model.
|
||||
*
|
||||
* Note that the model is not a widget, it does not need to be rendered or
|
||||
* appended to the dom. However, it inherits from the EventDispatcherMixin,
|
||||
* in order to be able to notify its parent by bubbling events up.
|
||||
*
|
||||
* The model is able to generate sample (fake) data when there is no actual data
|
||||
* in database. This feature can be activated by instantiating the model with
|
||||
* param "useSampleModel" set to true. In this case, the model instantiates a
|
||||
* duplicated version of itself, parametrized to call a SampleServer (JS)
|
||||
* instead of doing RPCs. Here is how it works: the main model first load the
|
||||
* data normally (from database), and then checks whether the result is empty or
|
||||
* not. If it is, it asks the sample model to load with the exact same params,
|
||||
* and it thus enters in "sample" mode. The model keeps doing this at reload,
|
||||
* but only if the (re)load params haven't changed: as soon as a param changes,
|
||||
* the "sample" mode is left, and it never enters it again in the future (in the
|
||||
* lifetime of the model instance). To access those sample data from the outside,
|
||||
* 'get' must be called with the option "withSampleData" set to true. In
|
||||
* this case, if the main model is in "sample" mode, it redirects the call to the
|
||||
* sample model.
|
||||
*/
|
||||
|
||||
import fieldUtils from 'web.field_utils';
|
||||
import mvc from 'web.mvc';
|
||||
|
||||
|
||||
var AbstractModel = mvc.Model.extend({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {any} _
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
get(_, options) {
|
||||
return this.__get(...arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
load(params) {
|
||||
this.loadParams = params;
|
||||
return this.__load(...arguments);
|
||||
},
|
||||
/**
|
||||
* When something changes, the data may need to be refetched. This is the
|
||||
* job for this method: reloading (only if necessary) all the data and
|
||||
* making sure that they are ready to be redisplayed.
|
||||
*
|
||||
* @param {any} _
|
||||
* @param {Object} [params]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async reload(_, params) {
|
||||
return this.__reload(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
__get() {
|
||||
return {};
|
||||
},
|
||||
/**
|
||||
* To override to do the initial load of the data (this function is supposed
|
||||
* to be called only once).
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async __load() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Processes date(time) and selection field values sent by the server.
|
||||
* Converts data(time) values to moment instances.
|
||||
* Converts false values of selection fields to 0 if 0 is a valid key,
|
||||
* because the server doesn't make a distinction between false and 0, and
|
||||
* always sends false when value is 0.
|
||||
*
|
||||
* @param {Object} field the field description
|
||||
* @param {*} value
|
||||
* @returns {*} the processed value
|
||||
*/
|
||||
_parseServerValue: function (field, value) {
|
||||
if (field.type === 'date' || field.type === 'datetime') {
|
||||
// process date(time): convert into a moment instance
|
||||
value = fieldUtils.parse[field.type](value, field, {isUTC: true});
|
||||
} else if (field.type === 'selection' && value === false) {
|
||||
// process selection: convert false to 0, if 0 is a valid key
|
||||
var hasKey0 = field.selection.find(option => option[0] === 0);
|
||||
value = hasKey0 ? 0 : value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
/**
|
||||
* To override to reload data (this function may be called several times,
|
||||
* after the initial load has been done).
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async __reload() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
export default AbstractModel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
/** @odoo-module alias=web.FieldManagerMixin **/
|
||||
|
||||
/**
|
||||
* The FieldManagerMixin is a mixin, designed to do the plumbing between field
|
||||
* widgets and a basicmodel. Field widgets can be used outside of a view. In
|
||||
* that case, someone needs to listen to events bubbling up from the widgets and
|
||||
* calling the correct methods on the model. This is the field_manager's job.
|
||||
*/
|
||||
|
||||
import { pick } from '@web/core/utils/objects';
|
||||
import BasicModel from 'web.BasicModel';
|
||||
import concurrency from 'web.concurrency';
|
||||
|
||||
var FieldManagerMixin = {
|
||||
custom_events: {
|
||||
field_changed: '_onFieldChanged',
|
||||
load: '_onLoad',
|
||||
mutexify: '_onMutexify',
|
||||
},
|
||||
/**
|
||||
* A FieldManagerMixin can be initialized with an instance of a basicModel.
|
||||
* If not, it will simply uses its own.
|
||||
*
|
||||
* @param {BasicModel} [model]
|
||||
*/
|
||||
init: function (model) {
|
||||
this.model = model || new BasicModel(this);
|
||||
this.mutex = new concurrency.Mutex();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply changes by notifying the basic model, then saving the data if
|
||||
* necessary, and finally, confirming the changes to the UI.
|
||||
*
|
||||
* @todo find a way to remove ugly 3rd argument...
|
||||
*
|
||||
* @param {string} dataPointID
|
||||
* @param {Object} changes
|
||||
* @param {OdooEvent} event
|
||||
* @returns {Promise} resolves when the change has been done, and the UI
|
||||
* updated
|
||||
*/
|
||||
_applyChanges: function (dataPointID, changes, event) {
|
||||
var self = this;
|
||||
var options = pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType', 'allowWarning');
|
||||
return this.model.notifyChanges(dataPointID, changes, options)
|
||||
.then(function (result) {
|
||||
if (event.data.force_save) {
|
||||
return self.model.save(dataPointID).then(function () {
|
||||
return self._confirmSave(dataPointID);
|
||||
}).guardedCatch(function () {
|
||||
return self._rejectSave(dataPointID);
|
||||
});
|
||||
} else if (options.notifyChange !== false) {
|
||||
return self._confirmChange(dataPointID, result, event);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* This method will be called whenever a field value has changed (and has
|
||||
* been confirmed by the model).
|
||||
*
|
||||
* @abstract
|
||||
* @param {string} id basicModel Id for the changed record
|
||||
* @param {string[]} fields the fields (names) that have been changed
|
||||
* @param {OdooEvent} event the event that triggered the change
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_confirmChange: function (id, fields, event) {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* This method will be called whenever a save has been triggered by a change
|
||||
* in some controlled field value. For example, when a priority widget is
|
||||
* being changed in a readonly form.
|
||||
*
|
||||
* @see _onFieldChanged
|
||||
* @abstract
|
||||
* @param {string} id The basicModel ID for the saved record
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_confirmSave: function (id) {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* This method will be called whenever a save has been triggered by a change
|
||||
* and has failed. For example, when a statusbar button is clicked in a
|
||||
* readonly form view.
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
* @param {string} id The basicModel ID for the saved record
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
_rejectSave: function (id) {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This is the main job of the FMM: deciding what to do when a controlled
|
||||
* field changes. Most of the time, it notifies the model that a change
|
||||
* just occurred, then confirm the change.
|
||||
*
|
||||
* @param {OdooEvent} event
|
||||
*/
|
||||
_onFieldChanged: function (event) {
|
||||
// in case of field changed in relational record (e.g. in the form view
|
||||
// of a one2many subrecord), the field_changed event must be stopped as
|
||||
// soon as is it handled by a field_manager (i.e. the one of the
|
||||
// subrecord's form view), otherwise it bubbles up to the main form view
|
||||
// but its model doesn't have any data related to the given dataPointID
|
||||
event.stopPropagation();
|
||||
return this._applyChanges(event.data.dataPointID, event.data.changes, event)
|
||||
.then(event.data.onSuccess || function () {})
|
||||
.guardedCatch(event.data.onFailure || function () {});
|
||||
},
|
||||
/**
|
||||
* Some widgets need to trigger a reload of their data. For example, a
|
||||
* one2many with a pager needs to be able to fetch the next page. To do
|
||||
* that, it can trigger a load event. This will then ask the model to
|
||||
* actually reload the data, then call the on_success callback.
|
||||
*
|
||||
* @param {OdooEvent} event
|
||||
* @param {number} [event.data.limit]
|
||||
* @param {number} [event.data.offset]
|
||||
* @param {function} [event.data.on_success] callback
|
||||
*/
|
||||
_onLoad: function (event) {
|
||||
var self = this;
|
||||
event.stopPropagation(); // prevent other field managers from handling this request
|
||||
var data = event.data;
|
||||
if (!data.on_success) { return; }
|
||||
var params = {};
|
||||
if ('limit' in data) {
|
||||
params.limit = data.limit;
|
||||
}
|
||||
if ('offset' in data) {
|
||||
params.offset = data.offset;
|
||||
}
|
||||
this.mutex.exec(function () {
|
||||
return self.model.reload(data.id, params).then(function (db_id) {
|
||||
data.on_success(self.model.get(db_id));
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {function} ev.data.action the function to execute in the mutex
|
||||
*/
|
||||
_onMutexify: function (ev) {
|
||||
ev.stopPropagation(); // prevent other field managers from handling this request
|
||||
this.mutex.exec(ev.data.action);
|
||||
},
|
||||
};
|
||||
|
||||
export default FieldManagerMixin;
|
||||
@@ -1,60 +0,0 @@
|
||||
/** @odoo-module alias=web.StandaloneFieldManagerMixin **/
|
||||
|
||||
import FieldManagerMixin from 'web.FieldManagerMixin';
|
||||
|
||||
/**
|
||||
* The StandaloneFieldManagerMixin is a mixin, designed to be used by a widget
|
||||
* that instanciates its own field widgets.
|
||||
*
|
||||
* @mixin
|
||||
* @name StandaloneFieldManagerMixin
|
||||
* @mixes FieldManagerMixin
|
||||
* @property {Function} _confirmChange
|
||||
* @property {Function} _registerWidget
|
||||
*/
|
||||
var StandaloneFieldManagerMixin = Object.assign({}, FieldManagerMixin, {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
FieldManagerMixin.init.apply(this, arguments);
|
||||
|
||||
// registeredWidgets is a dict of all field widgets used by the widget
|
||||
this.registeredWidgets = {};
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This method will be called whenever a field value has changed (and has
|
||||
* been confirmed by the model).
|
||||
*
|
||||
* @private
|
||||
* @param {string} id basicModel Id for the changed record
|
||||
* @param {string[]} fields the fields (names) that have been changed
|
||||
* @param {OdooEvent} event the event that triggered the change
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_confirmChange: function (id, fields, event) {
|
||||
var result = FieldManagerMixin._confirmChange.apply(this, arguments);
|
||||
var record = this.model.get(id);
|
||||
for (const [fieldName, widget] of Object.entries(this.registeredWidgets[id])) {
|
||||
if (fields.includes(fieldName)) {
|
||||
widget.reset(record, event);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_registerWidget: function (datapointID, fieldName, widget) {
|
||||
if (!this.registeredWidgets[datapointID]) {
|
||||
this.registeredWidgets[datapointID] = {};
|
||||
}
|
||||
this.registeredWidgets[datapointID][fieldName] = widget;
|
||||
},
|
||||
});
|
||||
|
||||
export default StandaloneFieldManagerMixin;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user