[REF] web: new RelationalModel

Before this commit, 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 commit 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 commit 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

Part-of: odoo/odoo#114024
Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: FrancoisGe <fge@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
Co-authored-by: Pierre Rousseau <pro@odoo.com>
This commit is contained in:
Aaron Bohy
2023-07-24 20:17:50 +02:00
co-authored by FrancoisGe Jorge Pinna Puissant Pierre Rousseau
parent 7bfbd75d57
commit 8723f020c3
159 changed files with 9099 additions and 18611 deletions
+1 -2
View File
@@ -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',
+12
View File
@@ -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} */
+1 -1
View File
@@ -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;
@@ -1,112 +0,0 @@
/** @odoo-module alias=web.viewUtils **/
import { unique } from '@web/core/utils/arrays';
import { omit } from '@web/core/utils/objects';
import * as dom from 'web.dom';
import * as utils from 'web.utils';
var viewUtils = {
/**
* Since a date field can have a granularity in a groupby (date_field:granularity),
* when we require only the field name, we have to split out the
* eventual granularity
*
* @param {string} groupedBy the groupby with the eventual granularity
* @returns {string}
*/
getGroupByField: function(groupedBy) {
return groupedBy && groupedBy.split(':')[0];
},
/**
* Returns the value of a group dataPoint, i.e. the value of the groupBy
* field for the records in that group.
*
* @param {Object} group dataPoint of type list, corresponding to a group
* @param {string} groupedBy the value of the groupby, i.e.
* field_name:granularity for date/datetime
* @returns {string | integer | false}
*/
getGroupValue: function (group, groupedBy) {
var groupedByField = group.fields[this.getGroupByField(groupedBy)];
switch (groupedByField.type) {
case 'many2one':
return group.res_id || false;
case 'selection':
var descriptor = groupedByField.selection.find(option => option[1] === group.value);
return descriptor && descriptor[0];
case 'char':
case 'boolean':
return group.value;
// for a date/datetime field, we take the last moment of the group as the group value
case 'date':
case 'datetime':
const [format, granularity] = groupedByField.type === 'date' ?
["YYYY-MM-DD", 'day'] : ["YYYY-MM-DD HH:mm:ss", 'second'];
return group.range[groupedBy] ?
moment.utc(group.range[groupedBy].to).subtract(1, granularity).format(format) : false;
default:
return false; // other field types are not handled
}
},
/**
* States whether or not the quick create feature is available for the given
* datapoint, depending on its groupBy field.
*
* @param {Object} list dataPoint of type list
* @returns {Boolean} true iff the kanban quick create feature is available
*/
isQuickCreateEnabled: function (list) {
var groupByField = list.groupedBy[0] && viewUtils.getGroupByField(list.groupedBy[0]);
if (!groupByField) {
return false;
}
var dateTypes = ['date', 'datetime'];
if (!list.fields[groupByField].readonly &&
dateTypes.includes(list.fields[groupByField].type)) {
return list.fieldsInfo && list.fieldsInfo[list.viewType][groupByField] &&
list.fieldsInfo[list.viewType][groupByField].allowGroupRangeValue;
}
var availableTypes = ['char', 'boolean', 'many2one', 'selection'];
return availableTypes.includes(list.fields[groupByField].type);
},
/**
* @param {string} arch view arch
* @returns {Object} parsed arch
*/
parseArch: function (arch) {
var doc = $.parseXML(arch).documentElement;
var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
return utils.xml_to_json(doc, stripWhitespaces);
},
/**
* Renders a button according to a given arch node element.
*
* @param {Object} node
* @param {Object} [options]
* @param {string} [options.extraClass]
* @param {boolean} [options.textAsTitle=false]
* @returns {jQuery}
*/
renderButtonFromNode: function (node, options) {
var btnOptions = {
attrs: omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options', 'effect'),
icon: node.attrs.icon,
};
if (options && options.extraClass) {
var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
btnOptions.attrs.class = unique(classes.concat(options.extraClass.split(' '))).join(' ');
}
var str = (node.attrs.string || '').replace(/_/g, '');
if (str) {
if (options && options.textAsTitle) {
btnOptions.attrs.title = str;
} else {
btnOptions.text = str;
}
}
return dom.renderButton(btnOptions);
},
};
export default viewUtils;
@@ -2,8 +2,8 @@
import { useBus, useService } from "@web/core/utils/hooks";
import { SEARCH_KEYS } from "@web/search/with_search/with_search";
import { buildSampleORM } from "@web/views/sample_server";
import { useSetupView } from "@web/views/view_hook";
import { buildSampleORM } from "./sample_server";
import { EventBus, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl";
@@ -20,7 +20,6 @@ export class Model {
this.env = env;
this.orm = services.orm;
this.bus = new EventBus();
this.useSampleModel = false; // will be set to true by the "useModel" hook if necessary
this.setup(params, services);
}
@@ -81,12 +80,36 @@ function getSearchParams(props) {
* @param {T} ModelClass
* @param {Object} params
* @param {Object} [options]
* @param {Function} [options.onUpdate]
* @param {Function} [options.onWillStart] callback executed before the first load of the model
* @param {boolean} [options.ignoreUseSampleModel]
* @param {Function} [options.beforeFirstLoad]
* @returns {InstanceType<T>}
*/
export function useModel(ModelClass, params, options = {}) {
const component = useComponent();
const services = {};
for (const key of ModelClass.services) {
services[key] = useService(key);
}
services.orm = services.orm || useService("orm");
const model = new ModelClass(component.env, params, services);
onWillStart(async () => {
await options.beforeFirstLoad?.();
return model.load(component.props);
});
onWillUpdateProps((nextProps) => model.load(nextProps));
return model;
}
/**
* @template {typeof Model} T
* @param {T} ModelClass
* @param {Object} params
* @param {Object} [options]
* @param {Function} [options.onUpdate]
* @param {Function} [options.onWillStart]
* @param {Function} [options.onWillStartAfterLoad]
* @returns {InstanceType<T>}
*/
export function useModelWithSampleData(ModelClass, params, options = {}) {
const component = useComponent();
if (!(ModelClass.prototype instanceof Model)) {
throw new Error(`the model class should extend Model`);
@@ -98,6 +121,7 @@ export function useModel(ModelClass, params, options = {}) {
services.orm = services.orm || useService("orm");
const model = new ModelClass(component.env, params, services);
useBus(
model.bus,
"update",
@@ -108,33 +132,31 @@ export function useModel(ModelClass, params, options = {}) {
);
const globalState = component.props.globalState || {};
const localState = component.props.state || {};
let useSampleModel = Boolean(
"useSampleModel" in globalState
? globalState.useSampleModel
: component.props.useSampleModel
);
model.useSampleModel = !options.ignoreUseSampleModel ? useSampleModel : false;
model.useSampleModel = useSampleModel;
const orm = model.orm;
let sampleORM = globalState.sampleORM;
let sampleORM = localState.sampleORM;
const user = useService("user");
let started = false;
async function load(props) {
const searchParams = getSearchParams(props);
await model.load(searchParams);
if (!options.ignoreUseSampleModel) {
if (useSampleModel && !model.hasData()) {
sampleORM =
sampleORM ||
buildSampleORM(component.props.resModel, component.props.fields, user);
sampleORM.setGroups(model.getGroups());
// Load data with sampleORM then restore real ORM.
model.orm = sampleORM;
await model.load(searchParams);
model.orm = orm;
} else {
useSampleModel = false;
model.useSampleModel = useSampleModel;
}
if (useSampleModel && !model.hasData()) {
sampleORM =
sampleORM || buildSampleORM(component.props.resModel, component.props.fields, user);
// Load data with sampleORM then restore real ORM.
model.orm = sampleORM;
await model.load(searchParams);
model.orm = orm;
} else {
useSampleModel = false;
model.useSampleModel = useSampleModel;
}
if (started) {
model.notify();
@@ -151,15 +173,16 @@ export function useModel(ModelClass, params, options = {}) {
started = true;
});
onWillUpdateProps((nextProps) => {
if (!options.ignoreUseSampleModel) {
useSampleModel = false;
}
useSampleModel = false;
load(nextProps);
});
useSetupView({
getGlobalState() {
return { sampleORM, useSampleModel };
return { useSampleModel };
},
getLocalState: () => {
return { sampleORM };
},
});
@@ -0,0 +1,92 @@
/* @odoo-module */
import { evalDomain } from "@web/core/domain";
import { Reactive } from "@web/core/utils/reactive";
import { getId } from "./utils";
/**
* @typedef Params
* @property {string} resModel
* @property {Object} context
* @property {{[key: string]: FieldInfo}} activeFields
* @property {{[key: string]: Field}} fields
*/
/**
* @typedef Field
* @property {string} name
* @property {string} type
* @property {[string,string][]} [selection]
*/
/**
* @typedef FieldInfo
* @property {string} context
* @property {boolean} invisible
* @property {boolean} readonly
* @property {boolean} required
* @property {boolean} onChange
*/
export class DataPoint extends Reactive {
/**
* @param {import("./relational_model").RelationalModel} model
* @param {import("./relational_model").Config"} config
* @param {any} data
* @param {Object} [options]
*/
constructor(model, config, data, options) {
super(...arguments);
this.id = getId("datapoint");
this.model = model;
this._config = config;
this.setup(config, data, options);
}
/**
* @abstract
* @param {Object} params
* @param {Object} state
*/
setup() {}
get activeFields() {
return this.config.activeFields;
}
get fields() {
return this.config.fields;
}
get fieldNames() {
return Object.keys(this.activeFields).filter(
(fieldName) => !this.fields[fieldName].relatedPropertyField
);
}
get resModel() {
return this.config.resModel;
}
get config() {
return this._config;
}
get context() {
return this.config.context;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
/**
* @param {string} fieldName
* @returns {boolean}
*/
isFieldReadonly(fieldName) {
const activeField = this.activeFields[fieldName];
const { readonly } = activeField || this.fields[fieldName];
return readonly && evalDomain(readonly, this.evalContext);
}
}
@@ -0,0 +1,291 @@
/* @odoo-module */
//@ts-check
import { Domain } from "@web/core/domain";
import { DynamicList } from "./dynamic_list";
export class DynamicGroupList extends DynamicList {
static type = "DynamicGroupList";
/**
* @param {import("./relational_model").Config} config
* @param {Object} data
*/
setup(config, data) {
super.setup(...arguments);
this.isGrouped = true;
/** @type {import("./group").Group[]} */
this.groups = data.groups.map((g) => this._createGroupDatapoint(g));
this.count = data.length;
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
get groupBy() {
return this.config.groupBy;
}
get groupByField() {
return this.fields[this.groupBy[0].split(":")[0]];
}
get hasData() {
if (this.count === 0) {
return false;
}
return this.groups.some((group) => group.hasData);
}
/**
* List of loaded records inside groups.
* @returns {import("./record").Record[]}
*/
get records() {
return this.groups
.filter((group) => !group.isFolded)
.map((group) => group.records)
.flat();
}
/**
* @returns {number}
*/
get recordCount() {
return this.groups.reduce((acc, group) => acc + group.count, 0);
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
async createGroup(groupName, groupData, isFolded) {
if (!this.groupByField || this.groupByField.type !== "many2one") {
throw new Error("Cannot create a group on a non many2one group field");
}
await this.model.mutex.exec(() => this._createGroup(groupName, groupData, isFolded));
}
async deleteGroups(groups) {
await this.model.mutex.exec(() => this._deleteGroups(groups));
}
/**
* @param {string} dataRecordId
* @param {string} dataGroupId
* @param {string} refId
* @param {string} targetGroupId
*/
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
if (dataGroupId === targetGroupId) {
// move a record inside the same group
targetGroup.list.records = await targetGroup.list._resequence(
targetGroup.list.records,
this.resModel,
dataRecordId,
refId
);
return;
}
// move record from a group to another group
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
const recordIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId);
const record = sourceGroup.list.records[recordIndex];
// step 1: move record to correct position
const refIndex = targetGroup.list.records.findIndex((r) => r.id === refId);
const oldIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId);
sourceGroup._removeRecords([record.id]);
targetGroup._addRecord(record, refIndex + 1);
// step 2: update record value
const value =
targetGroup.groupByField.type === "many2one"
? [targetGroup.value, targetGroup.displayName]
: targetGroup.value;
const revert = () => {
targetGroup._removeRecords([record.id]);
sourceGroup._addRecord(record, oldIndex);
};
try {
await record.update({ [targetGroup.groupByField.name]: value });
const res = await record.save({ noReload: true });
if (!res) {
revert();
}
} catch (e) {
// revert changes
revert();
throw e;
}
if (!targetGroup.isFolded) {
targetGroup.list.records = await targetGroup.list._resequence(
targetGroup.list.records,
this.resModel,
dataRecordId,
refId
);
}
}
async resequence(movedGroupId, targetGroupId) {
if (!this.groupByField || this.groupByField.type !== "many2one") {
throw new Error("Cannot resequence a group on a non many2one group field");
}
return this.model.mutex.exec(async () => {
this.groups = await this._resequence(
this.groups,
this.groupByField.relation,
movedGroupId,
targetGroupId
);
});
}
async sortBy(fieldName) {
if (!this.groups.length) {
return;
}
if (this.groups.every((group) => group.isFolded)) {
// all groups are folded
if (this.groupByField.name !== fieldName) {
// grouped by another field than fieldName
if (!(fieldName in this.groups[0].aggregates)) {
// fieldName has no aggregate values
return;
}
}
}
return super.sortBy(fieldName);
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
async _createGroup(groupName, groupData = {}, isFolded = false) {
groupData = { ...groupData, name: groupName };
const [id] = await this.model.orm.create(this.groupByField.relation, [groupData], {
context: this.context,
});
const lastGroup = this.groups.at(-1);
// This is almost a copy/past of the code in relational_model.js
// Maybe we can create an addGroup method in relational_model.js
// and call it from here and from relational_model.js
const commonConfig = {
resModel: this.config.resModel,
fields: this.config.fields,
activeFields: this.config.activeFields,
};
const context = {
...this.context,
[`default_${this.groupByField.name}`]: id,
};
const nextConfigGroups = { ...this.config.groups };
const domain = Domain.and([this.domain, [[this.groupByField.name, "=", id]]]).toList();
nextConfigGroups[id] = {
...commonConfig,
context,
groupByFieldName: this.groupByField.name,
isFolded,
initialDomain: domain,
list: {
...commonConfig,
context,
domain: domain,
groupBy: [],
orderBy: this.orderBy,
},
};
this.model._updateConfig(this.config, { groups: nextConfigGroups }, { noReload: true });
const data = {
count: 0,
length: 0,
records: [],
__domain: domain,
[this.groupByField.name]: [id, groupName],
value: id,
displayName: groupName,
};
const group = this._createGroupDatapoint(data);
if (lastGroup) {
const groups = [...this.groups, group];
this.groups = await this._resequence(
groups,
this.groupByField.relation,
group.id,
lastGroup.id
);
} else {
this.groups.push(group);
}
}
_createGroupDatapoint(data) {
return new this.model.constructor.Group(this.model, this.config.groups[data.value], data);
}
async _deleteGroups(groups) {
const shouldReload = groups.some((g) => g.count > 0);
await this._unlinkGroups(groups);
if (shouldReload) {
const configGroups = { ...this.config.groups };
for (const group of groups) {
delete configGroups[group.value];
}
const response = await this.model._updateConfig(this.config, { groups: configGroups });
this.groups = response.groups.map((group) => this._createGroupDatapoint(group));
this.count = response.length;
} else {
for (const group of groups) {
this._removeGroup(group);
}
}
}
_getDPresId(group) {
return group.value;
}
_getDPHandleField(group) {
return group[this.handleField];
}
async _load(offset, limit, orderBy, domain) {
const response = await this.model._updateConfig(this.config, {
offset,
limit,
orderBy,
domain,
});
this.groups = response.groups.map((group) => this._createGroupDatapoint(group));
this.count = response.length;
}
_removeGroup(group) {
const index = this.groups.findIndex((g) => g.id === group.id);
this.groups.splice(index, 1);
this.count--;
}
_removeRecords(recordIds) {
const proms = [];
for (const group of this.groups) {
proms.push(group._removeRecords(recordIds));
}
return Promise.all(proms);
}
_unlinkGroups(groups) {
const groupResIds = groups.map((g) => g.value);
return this.model.orm.unlink(this.groupByField.relation, groupResIds, {
context: this.context,
});
}
}
@@ -0,0 +1,388 @@
/* @odoo-module */
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { DataPoint } from "./datapoint";
import { Record } from "./record";
const DEFAULT_HANDLE_FIELD = "sequence";
export class DynamicList extends DataPoint {
/**
* @param {import("./relational_model").Config} config
*/
setup(config) {
super.setup(...arguments);
this.handleField = Object.keys(this.activeFields).find(
(fieldName) => this.activeFields[fieldName].isHandle
);
if (!this.handleField && DEFAULT_HANDLE_FIELD in this.fields) {
this.handleField = DEFAULT_HANDLE_FIELD;
}
this.isDomainSelected = false;
this.evalContext = this.context;
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
get groupBy() {
return [];
}
get orderBy() {
return this.config.orderBy;
}
get domain() {
return this.config.domain;
}
get editedRecord() {
return this.records.find((record) => record.isInEdition);
}
get limit() {
return this.config.limit;
}
get offset() {
return this.config.offset;
}
get selection() {
return this.records.filter((record) => record.selected);
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
archive(isSelected) {
return this.model.mutex.exec(() => this._toggleArchive(isSelected, true));
}
canResequence() {
return !!this.handleField;
}
deleteRecords(records = []) {
return this.model.mutex.exec(() => this._deleteRecords(records));
}
async enterEditMode(record) {
if (this.editedRecord === record) {
return true;
}
const canProceed = await this.leaveEditMode();
if (canProceed) {
this.model._updateConfig(record.config, { mode: "edit" }, { noReload: true });
}
return canProceed;
}
/**
* @param {boolean} [isSelected]
* @returns {Promise<number[]>}
*/
async getResIds(isSelected) {
let resIds;
if (isSelected) {
if (this.isDomainSelected) {
resIds = await this.model.orm.search(this.resModel, this.domain, {
limit: this.model.activeIdsLimit,
context: this.context,
});
} else {
resIds = this.selection.map((r) => r.resId);
}
} else {
resIds = this.records.map((r) => r.resId);
}
return resIds;
}
async leaveEditMode({ discard } = {}) {
if (this.editedRecord) {
let canProceed = true;
if (discard) {
await this.editedRecord.discard();
if (this.editedRecord && this.editedRecord.isNew) {
this._removeRecords([this.editedRecord.id]);
}
} else {
if (!this.model._urgentSave) {
await this.editedRecord.checkValidity();
if (!this.editedRecord) {
return true;
}
}
if (this.editedRecord.isNew && !this.editedRecord.dirty) {
this._removeRecords([this.editedRecord.id]);
} else {
canProceed = await this.editedRecord.save();
}
}
if (canProceed && this.editedRecord) {
this.model._updateConfig(
this.editedRecord.config,
{ mode: "readonly" },
{ noReload: true }
);
} else {
return canProceed;
}
}
return true;
}
load(params = {}) {
const limit = params.limit === undefined ? this.limit : params.limit;
const offset = params.offset === undefined ? this.offset : params.offset;
const orderBy = params.orderBy === undefined ? this.orderBy : params.orderBy;
const domain = params.domain === undefined ? this.domain : params.domain;
return this.model.mutex.exec(() => this._load(offset, limit, orderBy, domain));
}
async multiSave(record) {
return this.model.mutex.exec(() => this._multiSave(record));
}
selectDomain(value) {
return this.model.mutex.exec(() => {
this.isDomainSelected = value;
});
}
sortBy(fieldName) {
return this.model.mutex.exec(() => {
let orderBy = [...this.orderBy];
if (orderBy.length && orderBy[0].name === fieldName) {
orderBy[0] = { name: orderBy[0].name, asc: !orderBy[0].asc };
} else {
orderBy = orderBy.filter((o) => o.name !== fieldName);
orderBy.unshift({
name: fieldName,
asc: true,
});
}
return this._load(this.offset, this.limit, orderBy, this.domain);
});
}
unarchive(isSelected) {
return this.model.mutex.exec(() => this._toggleArchive(isSelected, false));
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
async _deleteRecords(records) {
let resIds;
if (records.length) {
resIds = records.map((r) => r.resId);
} else {
resIds = await this.getResIds(true);
records = this.records.filter((r) => resIds.includes(r.resId));
}
const unlinked = await this.model.orm.unlink(this.resModel, resIds, {
context: this.context,
});
if (!unlinked) {
return false;
}
if (
this.isDomainSelected &&
resIds.length === this.model.activeIdsLimit &&
resIds.length < this.count
) {
const msg = sprintf(
_t(`Only the first %s records have been deleted (out of %s selected)`),
resIds.length,
this.count
);
this.model.notification.add(msg, { title: _t("Warning") });
}
await this._removeRecords(records.map((r) => r.id));
return unlinked;
}
_leaveSampleMode() {
if (this.model.useSampleModel) {
this.model.useSampleModel = false;
return this._load(this.offset, this.limit, this.orderBy, this.domain);
}
}
async _multiSave(record) {
const changes = record._getChanges();
if (!Object.keys(changes).length) {
return;
}
const validSelection = this.selection.filter((record) => {
return Object.keys(changes).every((fieldName) => {
if (record._isReadonly(fieldName)) {
return false;
} else if (record._isRequired(fieldName) && !changes[fieldName]) {
return false;
}
return true;
});
});
const canProceed = await this.model.hooks.onWillSaveMulti(record, changes, validSelection);
if (canProceed === false) {
return false;
}
if (validSelection.length === 0) {
this.model.dialog.add(AlertDialog, {
body: _t("No valid record to save"),
confirm: () => this.leaveEditMode({ discard: true }),
});
return false;
} else {
const resIds = validSelection.map((r) => r.resId);
const context = this.context;
try {
await this.model.orm.write(this.resModel, resIds, changes, { context });
} catch (e) {
record._discard();
this.model._updateConfig(record.config, { mode: "readonly" }, { noReload: true });
throw e;
}
const records = await this.model._loadRecords({ ...this.config, resIds });
for (const record of validSelection) {
const serverValues = records.find((r) => r.id === record.resId);
record._applyValues(serverValues);
this.model._updateSimilarRecords(record, serverValues);
}
record._discard();
this.model._updateConfig(record.config, { mode: "readonly" }, { noReload: true });
}
this.model.hooks.onSavedMulti(validSelection);
return true;
}
async _resequence(originalList, resModel, movedId, targetId) {
if (this.resModel === resModel && !this.canResequence()) {
return originalList;
}
const handleField = this.handleField;
const dataPoints = [...originalList];
const order = this.orderBy.find((o) => o.name === handleField);
const asc = !order || order.asc;
// Find indices
const fromIndex = dataPoints.findIndex((d) => d.id === movedId);
let toIndex = 0;
if (targetId !== null) {
const targetIndex = dataPoints.findIndex((d) => d.id === targetId);
toIndex = fromIndex > targetIndex ? targetIndex + 1 : targetIndex;
}
const getSequence = (dp) => dp && this._getDPHandleField(dp, handleField);
// Determine which records/groups need to be modified
const firstIndex = Math.min(fromIndex, toIndex);
const lastIndex = Math.max(fromIndex, toIndex) + 1;
let reorderAll = dataPoints.some(
(dp) => this._getDPHandleField(dp, handleField) === undefined
);
if (!reorderAll) {
let lastSequence = (asc ? -1 : 1) * Infinity;
for (let index = 0; index < dataPoints.length; index++) {
const sequence = getSequence(dataPoints[index]);
if (
((index < firstIndex || index >= lastIndex) &&
((asc && lastSequence >= sequence) ||
(!asc && lastSequence <= sequence))) ||
(index >= firstIndex && index < lastIndex && lastSequence === sequence)
) {
reorderAll = true;
}
lastSequence = sequence;
}
}
// Perform the resequence in the list of records/groups
const [dp] = dataPoints.splice(fromIndex, 1);
dataPoints.splice(toIndex, 0, dp);
// Creates the list of records/groups to modify
let toReorder = dataPoints;
if (!reorderAll) {
toReorder = toReorder.slice(firstIndex, lastIndex).filter((r) => r.id !== movedId);
if (fromIndex < toIndex) {
toReorder.push(dp);
} else {
toReorder.unshift(dp);
}
}
if (!asc) {
toReorder.reverse();
}
const resIds = toReorder.map((d) => this._getDPresId(d)).filter((id) => id && !isNaN(id));
const sequences = toReorder.map(getSequence);
const offset = sequences.length && Math.min(...sequences);
// Try to write new sequences on the affected records/groups
const params = {
model: resModel,
ids: resIds,
context: this.context,
field: handleField,
};
if (offset) {
params.offset = offset;
}
const wasResequenced = await this.model.rpc("/web/dataset/resequence", params);
if (!wasResequenced) {
return originalList;
}
// Read the actual values set by the server and update the records/groups
const kwargs = { context: this.context };
const result = await this.model.orm.read(resModel, resIds, [handleField], kwargs);
for (const dpData of result) {
const dp = dataPoints.find((d) => this._getDPresId(d) === dpData.id);
if (dp instanceof Record) {
dp._applyValues(dpData);
} else {
dp[handleField] = dpData[handleField];
}
}
return dataPoints;
}
async _toggleArchive(isSelected, state) {
const method = state ? "action_archive" : "action_unarchive";
const context = this.context;
const resIds = await this.getResIds(isSelected);
const action = await this.model.orm.call(this.resModel, method, [resIds], { context });
if (
this.isDomainSelected &&
resIds.length === this.model.activeIdsLimit &&
resIds.length < this.count
) {
const msg = sprintf(
_t("Of the %s records selected, only the first %s have been archived/unarchived."),
resIds.length,
this.count
);
this.model.notification.add(msg, { title: _t("Warning") });
}
const reload = () => this.model.load();
if (action && Object.keys(action).length) {
this.model.action.doAction(action, {
onClose: reload,
});
} else {
return reload();
}
}
}
@@ -0,0 +1,184 @@
/* @odoo-module */
import { DynamicList } from "./dynamic_list";
export class DynamicRecordList extends DynamicList {
static type = "DynamicRecordList";
/**
* @param {import("./relational_model").Config} config
* @param {Object} data
*/
setup(config, data) {
super.setup(config);
/** @type {import("./record").Record[]} */
this.records = data.records.map((r) => this._createRecordDatapoint(r));
this._updateCount(data);
}
// -------------------------------------------------------------------------
// Getter
// -------------------------------------------------------------------------
get hasData() {
return this.count > 0;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
/**
* @param {number} resId
* @param {boolean} [atFirstPosition]
* @returns {Promise<Record>} the newly created record
*/
addExistingRecord(resId, atFirstPosition) {
return this.model.mutex.exec(async () => {
const record = this._createRecordDatapoint({});
await record._load({ resId });
this._addRecord(record, atFirstPosition ? 0 : this.records.length);
return record;
});
}
/**
* @param {boolean} [atFirstPosition=false]
* @returns {Promise<Record>}
*/
addNewRecord(atFirstPosition = false) {
return this.model.mutex.exec(async () => {
await this._leaveSampleMode();
return this._addNewRecord(atFirstPosition);
});
}
/**
* Performs a search_count with the current domain to set the count. This is
* useful as web_search_read limits the count for performance reasons, so it
* might sometimes be less than the real number of records matching the domain.
**/
async fetchCount() {
this.count = await this.model._updateCount(this.config);
this.hasLimitedCount = false;
return this.count;
}
moveRecord(dataRecordId, _dataGroupId, refId, _targetGroupId) {
return this.resequence(dataRecordId, refId);
}
removeRecord(record) {
if (!record.isNew) {
throw new Error("removeRecord can't be called on an existing record");
}
const index = this.records.findIndex((r) => r === record);
if (index < 0) {
return;
}
this.records.splice(index, 1);
this.count--;
return record;
}
async resequence(movedRecordId, targetRecordId) {
return this.model.mutex.exec(
async () =>
(this.records = await this._resequence(
this.records,
this.resModel,
movedRecordId,
targetRecordId
))
);
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
async _addNewRecord(atFirstPosition) {
const values = await this.model._loadNewRecord({
resModel: this.resModel,
activeFields: this.activeFields,
fields: this.fields,
context: this.context,
});
const record = this._createRecordDatapoint(values, "edit");
this._addRecord(record, atFirstPosition ? 0 : this.records.length);
return record;
}
_addRecord(record, index) {
this.records.splice(Number.isInteger(index) ? index : this.records.length, 0, record);
this.count++;
}
_createRecordDatapoint(data, mode = "readonly") {
return new this.model.constructor.Record(
this.model,
{
context: this.context,
activeFields: this.activeFields,
resModel: this.resModel,
fields: this.fields,
resId: data.id || false,
resIds: data.id ? [data.id] : [],
isMonoRecord: true,
mode,
},
data,
{ manuallyAdded: !data.id }
);
}
_getDPresId(record) {
return record.resId;
}
_getDPHandleField(record) {
return record.data[this.handleField];
}
async _load(offset, limit, orderBy, domain) {
const response = await this.model._updateConfig(this.config, {
offset,
limit,
orderBy,
domain,
});
this.records = response.records.map((r) => this._createRecordDatapoint(r));
this._updateCount(response);
}
_removeRecords(recordIds) {
const _records = this.records.filter((r) => !recordIds.includes(r.id));
if (this.offset && !_records.length) {
// we weren't on the first page, and we removed all records of the current page
const offset = Math.max(this.offset - this.limit, 0);
return this._load(offset, this.limit, this.orderBy, this.domain);
}
const nbRemovedRecords = this.records.length - _records.length;
if (nbRemovedRecords > 0) {
if (this.count > this.offset + this.limit) {
// we removed some records, and there are other pages after the current one
return this._load(this.offset, this.limit, this.orderBy, this.domain);
} else {
// we are on the last page and there are still records remaining
this.count -= nbRemovedRecords;
this.records = _records;
}
}
}
_updateCount(data) {
const length = data.length;
if (length >= this.config.countLimit + 1) {
this.hasLimitedCount = true;
this.count = this.config.countLimit;
} else {
this.hasLimitedCount = false;
this.count = length;
}
}
}
@@ -0,0 +1,132 @@
/* @odoo-module */
import { Domain } from "@web/core/domain";
import { DataPoint } from "./datapoint";
/**
* @typedef Params
* @property {string[]} groupBy
*/
export class Group extends DataPoint {
static type = "Group";
/**
* @param {import("./relational_model").Config} config
*/
setup(config, data) {
super.setup(...arguments);
this.groupByField = this.fields[config.groupByFieldName];
this.range = data.range;
this._rawValue = data[this.groupByField.name];
/** @type {number} */
this.count = data.count;
this.value = data.value;
this.displayName = data.displayName;
this.aggregates = data.aggregates;
let List;
if (config.list.groupBy.length) {
List = this.model.constructor.DynamicGroupList;
} else {
List = this.model.constructor.DynamicRecordList;
}
/** @type {import("./dynamic_group_list").DynamicGroupList | import("./dynamic_record_list").DynamicRecordList} */
this.list = new List(this.model, config.list, data);
if (config.record) {
this.record = new this.model.constructor.Record(this.model, config.record, data.values);
}
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
get hasData() {
return this.list.hasData;
}
get isFolded() {
return this.config.isFolded;
}
get records() {
return this.list.records;
}
get serverValue() {
const { type } = this.groupByField;
switch (type) {
case "many2one":
return this.value || false;
case "many2many": {
return this.value ? [this.value] : false;
}
default: {
return this._rawValue || false;
}
}
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
async addExistingRecord(resId, atFirstPosition = false) {
const record = await this.list.addExistingRecord(resId, atFirstPosition);
this.count++;
return record;
}
async addNewRecord(_unused, atFirstPosition = false) {
const canProceed = await this.model.root.leaveEditMode({ discard: true });
if (canProceed) {
const record = await this.list.addNewRecord(atFirstPosition);
if (record) {
this.count++;
}
}
}
async applyFilter(filter) {
if (filter) {
await this.list.load({
domain: Domain.and([this.config.initialDomain, filter]).toList(),
});
} else {
await this.list.load({ domain: this.config.initialDomain });
}
this.model._updateConfig(this.config, { extraDomain: filter }, { noReload: true });
}
deleteRecords(records) {
return this.model.mutex.exec(() => this._deleteRecords(records));
}
async toggle() {
if (this.config.isFolded) {
await this.list.load();
}
this.model._updateConfig(
this.config,
{ isFolded: !this.config.isFolded },
{ noReload: true }
);
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
_addRecord(record, index) {
this.list._addRecord(record, index);
this.count++;
}
async _deleteRecords(records) {
await this.list._deleteRecords(records);
this.count -= records.length;
}
async _removeRecords(recordIds) {
const idsToRemove = recordIds.filter((id) => this.list.records.some((r) => r.id === id));
await this.list._removeRecords(idsToRemove);
this.count -= idsToRemove.length;
}
}
@@ -0,0 +1,901 @@
/* @odoo-module */
import { markRaw, markup, toRaw } from "@odoo/owl";
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Domain, evalDomain } from "@web/core/domain";
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
import { _t } from "@web/core/l10n/translation";
import { pick } from "@web/core/utils/objects";
import { escape } from "@web/core/utils/strings";
import { DataPoint } from "./datapoint";
import { getFieldContext, parseServerValue } from "./utils";
export class Record extends DataPoint {
static type = "Record";
/**
* @param {import("./relational_model").Config} config
* @param {Object} data
* @param {Object} [options={}]
* @param {boolean} [options.manuallyAdded]
* @param {Function} [options.onUpdate]
* @param {Record} [options.parentRecord]
* @param {string} [options.virtualId]
*/
setup(config, data, options = {}) {
this._manuallyAdded = options.manuallyAdded === true;
this._onUpdate = options.onUpdate || (() => {});
this._parentRecord = options.parentRecord;
this._virtualId = options.virtualId || false;
// Be careful that pending changes might not have been notified yet, so the "dirty" flag may
// be false even though there are changes in a field. Consider calling "isDirty()" instead.
this.dirty = false;
this.selected = false;
this._invalidFields = new Set();
this._unsetRequiredFields = markRaw(new Set());
this._closeInvalidFieldsNotification = () => {};
let savePoint;
const missingFields = this.fieldNames.filter((fieldName) => !(fieldName in data));
const vals = this._parseServerValues({
...this._getDefaultValues(missingFields),
...data,
});
if (this.resId) {
this._values = markRaw(vals);
this._changes = markRaw({});
savePoint = {};
} else {
this._values = markRaw({});
this._changes = markRaw(vals);
savePoint = { ...this._changes };
}
this._savePoint = markRaw(savePoint);
this.data = { ...this._values, ...this._changes };
const parentRecord = this._parentRecord;
if (parentRecord) {
this.evalContext = {
get parent() {
return parentRecord.evalContext;
},
};
} else {
this.evalContext = {};
}
this._setEvalContext();
}
// -------------------------------------------------------------------------
// Getter
// -------------------------------------------------------------------------
get canBeAbandoned() {
return this.isNew && !this.dirty && this._manuallyAdded;
}
get hasData() {
return true;
}
get isActive() {
if ("active" in this.activeFields) {
return this.data.active;
} else if ("x_active" in this.activeFields) {
return this.data.x_active;
}
return true;
}
get isInEdition() {
if (this.config.mode === "readonly") {
return false;
} else {
return this.config.mode === "edit" || !this.resId;
}
}
get isNew() {
return !this.resId;
}
get isValid() {
return !this._invalidFields.size;
}
get resId() {
return this.config.resId;
}
get resIds() {
return this.config.resIds;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
archive() {
return this.model.mutex.exec(() => this._toggleArchive(true));
}
async checkValidity() {
if (!this._urgentSave) {
await this._askChanges();
}
return this._checkValidity();
}
delete() {
return this.model.mutex.exec(async () => {
const unlinked = await this.model.orm.unlink(this.resModel, [this.resId], {
context: this.context,
});
if (!unlinked) {
return false;
}
const resIds = this.resIds.slice();
const index = resIds.indexOf(this.resId);
resIds.splice(index, 1);
const resId = resIds[Math.min(index, resIds.length - 1)] || false;
if (resId) {
await this._load({ resId, resIds });
} else {
this.model._updateConfig(this.config, { resId: false }, { noReload: true });
this.dirty = false;
this._changes = this._parseServerValues(this._getDefaultValues());
this._values = {};
this.data = { ...this._changes };
this._setEvalContext();
}
});
}
discard() {
return this.model.mutex.exec(() => this._discard());
}
duplicate() {
return this.model.mutex.exec(async () => {
const kwargs = { context: this.context };
const index = this.resIds.indexOf(this.resId);
const resId = await this.model.orm.call(this.resModel, "copy", [this.resId], kwargs);
const resIds = this.resIds.slice();
resIds.splice(index + 1, 0, resId);
await this._load({ resId, resIds, mode: "edit" });
});
}
/**
* @param {string} fieldName
*/
getFieldDomain(fieldName) {
const { domain } = this.fields[fieldName];
return domain ? new Domain(domain).toList(this.evalContext) : [];
}
async isDirty() {
await this._askChanges();
return this.dirty;
}
/**
* @param {string} fieldName
*/
isFieldInvalid(fieldName) {
return this._invalidFields.has(fieldName);
}
load(resId = this.resId, context = this.context) {
let mode = this.config.mode;
if (!resId) {
mode = "edit";
}
return this.model.mutex.exec(() => this._load({ resId, mode, context }));
}
openInvalidFieldsNotification() {
if (this._invalidFields.size) {
const items = [...this._invalidFields].map((fieldName) => {
return `<li>${escape(this.fields[fieldName].string || fieldName)}</li>`;
}, this);
this._closeInvalidFieldsNotification = this.model.notification.add(
markup(`<ul>${items.join("")}</ul>`),
{
title: _t("Invalid fields: "),
type: "danger",
}
);
}
}
async save(options) {
await this._askChanges();
return this.model.mutex.exec(() => this._save(options));
}
async setInvalidField(fieldName) {
this.dirty = true;
return this._setInvalidField(fieldName);
}
switchMode(mode) {
return this.model.mutex.exec(() => this._switchMode(mode));
}
toggleSelection(selected) {
return this.model.mutex.exec(() => {
if (typeof selected === "boolean") {
this.selected = selected;
} else {
this.selected = !this.selected;
}
});
}
unarchive() {
return this.model.mutex.exec(() => this._toggleArchive(false));
}
update(changes) {
if (this.model._urgentSave) {
return this._update(changes);
}
return this.model.mutex.exec(() => this._update(changes));
}
urgentSave() {
this.model._urgentSave = true;
this.model.bus.trigger("WILL_SAVE_URGENTLY");
this._save({ noReload: true });
return this.isValid;
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
_addSavePoint() {
Object.assign(this._savePoint, this._changes);
for (const fieldName in this._changes) {
if (["one2many", "many2many"].includes(this.fields[fieldName].type)) {
this._changes[fieldName]._addSavePoint();
}
}
}
_applyChanges(changes) {
for (const fieldName in changes) {
this._changes[fieldName] = changes[fieldName];
this.data[fieldName] = changes[fieldName];
}
this._setEvalContext();
this._removeInvalidFields(Object.keys(changes));
}
_applyDefaultValues() {
const fieldNames = this.fieldNames.filter((fieldName) => {
return !(fieldName in this.data);
});
const defaultValues = this._getDefaultValues(fieldNames);
if (this.isNew) {
this._applyChanges(this._parseServerValues(defaultValues));
} else {
this._applyValues(defaultValues);
}
}
_applyValues(values) {
Object.assign(this._values, this._parseServerValues(values));
Object.assign(this.data, this._values, this._changes);
this._setEvalContext();
}
// FIXME: move to model?
_askChanges() {
const proms = [];
this.model.bus.trigger("NEED_LOCAL_CHANGES", { proms });
return Promise.all(proms);
}
_checkValidity({ silent } = {}) {
const unsetRequiredFields = [];
for (const fieldName in this.activeFields) {
const fieldType = this.fields[fieldName].type;
if (this._isInvisible(fieldName)) {
continue;
}
switch (fieldType) {
case "boolean":
case "float":
case "integer":
case "monetary":
continue;
case "one2many":
case "many2many": {
const list = this.data[fieldName];
if (
(this._isRequired(fieldName) && !list.count) ||
!list.records.every((r) => !r.dirty || r._checkValidity({ silent }))
) {
unsetRequiredFields.push(fieldName);
}
break;
}
default:
if (!this.data[fieldName] && this._isRequired(fieldName)) {
unsetRequiredFields.push(fieldName);
}
}
}
if (silent) {
return !unsetRequiredFields.length;
}
for (const fieldName of Array.from(this._unsetRequiredFields)) {
this._invalidFields.delete(fieldName);
}
this._unsetRequiredFields.clear();
for (const fieldName of unsetRequiredFields) {
this._unsetRequiredFields.add(fieldName);
this._setInvalidField(fieldName);
}
return !this._invalidFields.size;
}
_computeDataContext() {
const dataContext = {};
const data = toRaw(this.data);
for (const fieldName in data) {
const value = data[fieldName];
const field = this.fields[fieldName];
if (["char", "text"].includes(field.type)) {
dataContext[fieldName] = value !== "" ? value : false;
} else if (["one2many", "many2many"].includes(field.type)) {
dataContext[fieldName] = value.resIds;
} else if (value && field.type === "date") {
dataContext[fieldName] = serializeDate(value);
} else if (value && field.type === "datetime") {
dataContext[fieldName] = serializeDateTime(value);
} else if (value && field.type === "many2one") {
dataContext[fieldName] = value[0];
} else if (value && field.type === "reference") {
dataContext[fieldName] = `${value.resModel},${value.resId}`;
} else if (field.type === "properties") {
dataContext[fieldName] = value.filter(
(property) => !property.definition_deleted !== false
);
} else {
dataContext[fieldName] = value;
}
}
dataContext.id = this.resId || false;
return dataContext;
}
_createStaticListDatapoint(data, fieldName) {
const { related, limit, defaultOrderBy } = this.activeFields[fieldName];
const config = {
resModel: this.fields[fieldName].relation,
activeFields: (related && related.activeFields) || {},
fields: (related && related.fields) || {},
relationField: this.fields[fieldName].relation_field || false,
offset: 0,
resIds: data.map((r) => r.id),
orderBy: defaultOrderBy || [],
limit: limit || Number.MAX_SAFE_INTEGER,
context: {}, // will be set afterwards, see "_updateContext" in "_setEvalContext"
};
let staticList;
const options = {
onUpdate: ({ withoutOnchange } = {}) =>
this._update({ [fieldName]: staticList }, { withoutOnchange }),
parent: this,
};
staticList = new this.model.constructor.StaticList(this.model, config, data, options);
return staticList;
}
_discard() {
this.dirty = false;
for (const fieldName in this._changes) {
if (["one2many", "many2many"].includes(this.fields[fieldName].type)) {
this._changes[fieldName]._discard();
}
}
this._changes = { ...this._savePoint };
this.data = { ...this._values, ...this._changes };
this._setEvalContext();
this._invalidFields.clear();
this._closeInvalidFieldsNotification();
this._closeInvalidFieldsNotification = () => {};
}
_formatServerValue(fieldType, value) {
if (fieldType === "date") {
return value ? serializeDate(value) : false;
} else if (fieldType === "datetime") {
return value ? serializeDateTime(value) : false;
} else if (fieldType === "char" || fieldType === "text") {
return value !== "" ? value : false;
} else if (fieldType === "many2one") {
return value ? value[0] : false;
} else if (fieldType === "reference") {
return value && value.resModel && value.resId
? `${value.resModel},${value.resId}`
: false;
} else if (fieldType === "properties") {
return value.map((property) => {
let value;
if (property.type === "many2one") {
value = property.value;
} else if (
(property.type === "date" || property.type === "datetime") &&
typeof property.value === "string"
) {
// TO REMOVE: need refactoring PropertyField to use the same format as the server
value = property.value;
} else {
value = this._formatServerValue(property.type, property.value);
}
return {
...property,
value,
};
});
}
return value;
}
_getChanges(changes = this._changes, { withReadonly } = {}) {
const result = {};
for (const [fieldName, value] of Object.entries(changes)) {
const field = this.fields[fieldName];
if (fieldName === "id") {
continue;
}
if (
!withReadonly &&
fieldName in this.activeFields &&
this._isReadonly(fieldName) &&
!this.activeFields[fieldName].forceSave
) {
continue;
}
if (field.relatedPropertyField) {
continue;
}
if (field.type === "one2many" || field.type === "many2many") {
const commands = value._getCommands({ withReadonly });
if (commands.length) {
result[fieldName] = commands;
}
} else {
result[fieldName] = this._formatServerValue(field.type, value);
}
}
return result;
}
_getDefaultValues(fieldNames = this.fieldNames) {
const defaultValues = {};
for (const fieldName of fieldNames) {
switch (this.fields[fieldName].type) {
case "integer":
case "float":
case "monetary":
defaultValues[fieldName] = fieldName === "id" ? false : 0;
break;
case "one2many":
case "many2many":
defaultValues[fieldName] = [];
break;
default:
defaultValues[fieldName] = false;
}
}
return defaultValues;
}
_isInvisible(fieldName) {
const invisible = this.activeFields[fieldName].invisible;
return invisible ? evalDomain(invisible, this.evalContext) : false;
}
_isReadonly(fieldName) {
const readonly = this.activeFields[fieldName].readonly;
return readonly ? evalDomain(readonly, this.evalContext) : false;
}
_isRequired(fieldName) {
const required = this.activeFields[fieldName].required;
return required ? evalDomain(required, this.evalContext) : false;
}
async _load(nextConfig = {}) {
// FIXME: do not allow to change resId? maybe add a new method on model to re-generate a
// new root for the new resId
const values = await this.model._updateConfig(this.config, nextConfig);
if (this.resId) {
this.model._updateSimilarRecords(this, values);
this._values = this._parseServerValues(values);
this._changes = {};
} else {
this._values = {};
this._changes = this._parseServerValues({ ...this._getDefaultValues(), ...values });
}
this.dirty = false;
this.data = { ...this._values, ...this._changes };
this._setEvalContext();
this._invalidFields.clear();
}
_parseServerValues(serverValues, currentValues = {}) {
const parsedValues = {};
if (!serverValues) {
return parsedValues;
}
for (const fieldName in serverValues) {
const value = serverValues[fieldName];
if (!this.activeFields[fieldName]) {
continue;
}
const field = this.fields[fieldName];
if (field.type === "one2many" || field.type === "many2many") {
let staticList = currentValues[fieldName];
let valueIsCommandList = true;
// value can be a list of records or a list of commands (new record)
valueIsCommandList = value.length > 0 && Array.isArray(value[0]);
if (!staticList) {
let data = valueIsCommandList ? [] : value;
if (data.length > 0 && typeof data[0] === "number") {
data = data.map((resId) => {
return { id: resId };
});
}
staticList = this._createStaticListDatapoint(data, fieldName);
}
if (valueIsCommandList) {
staticList._applyCommands(value);
}
parsedValues[fieldName] = staticList;
} else {
parsedValues[fieldName] = parseServerValue(field, value);
if (field.type === "properties") {
for (const property of parsedValues[fieldName]) {
const fieldPropertyName = `${fieldName}.${property.name}`;
if (property.type === "one2many" || property.type === "many2many") {
const staticList = this._createStaticListDatapoint(
property.value.map((record) => ({
id: record[0],
display_name: record[1],
})),
fieldPropertyName
);
parsedValues[fieldPropertyName] = staticList;
} else if (property.type === "many2one") {
parsedValues[fieldPropertyName] =
property.value.length && property.value[1] === null
? [property.value[0], this.model.env._t("No Access")]
: property.value;
} else {
parsedValues[fieldPropertyName] = property.value ?? false;
}
}
}
}
}
return parsedValues;
}
async _preprocessChanges(changes) {
await Promise.all([
this._preprocessMany2oneChanges(changes),
this._preprocessReferenceChanges(changes),
]);
}
async _preprocessReferenceChanges(changes) {
const proms = [];
for (const [fieldName, value] of Object.entries(changes)) {
if (this.fields[fieldName].type !== "reference") {
continue;
}
if (!value) {
changes[fieldName] = false;
continue;
}
const id = value.resId;
const displayName = value.displayName;
if (!id && !displayName) {
changes[fieldName] = false;
continue;
}
const context = getFieldContext(this, fieldName);
if (!id && displayName !== undefined) {
proms.push(
this.model.orm
.call(value.resModel, "name_create", [displayName], {
context,
})
.then((result) => {
changes[fieldName] = result
? {
resModel: value.resModel,
resId: result[0],
displayName,
}
: false;
})
);
} else if (id && displayName === undefined) {
const kwargs = {
context,
specification: { display_name: {} },
};
proms.push(
this.model.orm
.call(value.resModel, "web_read", [[id]], kwargs)
.then((records) => {
changes[fieldName] = {
resModel: value.resModel,
resId: id,
displayName: records[0].display_name,
};
})
);
} else {
changes[fieldName] = value;
}
}
return Promise.all(proms);
}
async _preprocessMany2oneChanges(changes) {
const proms = [];
for (const [fieldName, value] of Object.entries(changes)) {
if (this.fields[fieldName].type !== "many2one") {
continue;
}
if (!value) {
changes[fieldName] = false;
continue;
}
const [id, displayName] = value;
if (!id && !displayName) {
changes[fieldName] = [false, ""];
continue;
}
const activeField = this.activeFields[fieldName];
if (!activeField) {
changes[fieldName] = value;
continue;
}
const relation = this.fields[fieldName].relation;
const context = getFieldContext(this, fieldName);
if (!id && displayName !== undefined) {
proms.push(
this.model.orm
.call(relation, "name_create", [displayName], {
context,
})
.then((result) => {
changes[fieldName] = result ? result : [false, ""];
})
);
} else if (id && displayName === undefined) {
const kwargs = {
context,
specification: { display_name: {} },
};
proms.push(
this.model.orm.call(relation, "web_read", [[id]], kwargs).then((records) => {
changes[fieldName] = [records[0].id, records[0].display_name || ""];
})
);
} else {
changes[fieldName] = value;
}
}
return Promise.all(proms);
}
_removeInvalidFields(fieldNames) {
for (const fieldName of fieldNames) {
this._invalidFields.delete(fieldName);
}
}
async _save({ noReload, onError } = {}) {
// before saving, abandon new invalid, untouched records in x2manys
for (const fieldName in this.activeFields) {
if (["one2many", "many2many"].includes(this.fields[fieldName].type)) {
this.data[fieldName]._abandonRecords();
}
}
if (!this._checkValidity()) {
this.openInvalidFieldsNotification();
return false;
}
const changes = this._getChanges();
const creation = !this.resId;
delete changes.id; // id never changes, and should not be written
if (!creation && !Object.keys(changes).length) {
return true;
}
const canProceed = await this.model.hooks.onWillSaveRecord(this, changes);
if (canProceed === false) {
return false;
}
const kwargs = { context: this.context };
let resId = this.resId;
try {
if (creation) {
[resId] = await this.model.orm.create(this.resModel, [changes], kwargs);
} else {
await this.model.orm.write(this.resModel, [resId], changes, kwargs);
}
} catch (e) {
if (onError) {
return onError(e, { discard: () => this._discard() });
}
if (!this.isInEdition) {
await this._load({ resId });
}
throw e;
}
if (!noReload) {
const nextConfig = { resId };
if (creation) {
nextConfig.resIds = this.resIds.concat([resId]);
}
await this._load(nextConfig);
} else {
this.model._updateConfig(this.config, { resId }, { noReload: true });
this._values = { ...this._values, ...this._changes };
if ("id" in this.activeFields) {
this._values.id = resId;
}
this._changes = {};
this.data = { ...this._values };
this.dirty = false;
}
await this.model.hooks.onRecordSaved(this, changes);
return true;
}
/**
* For owl reactivity, it's better to only update the keys inside the evalContext
* instead of replacing the evalContext itself, because a lot of components are
* registered to the evalContext (but not necessarily keys inside it), and would
* be uselessly re-rendered if we replace it by a brand new object.
*/
_setEvalContext() {
Object.assign(this.evalContext, {
...this.context,
active_id: this.resId || false,
active_ids: this.resId ? [this.resId] : [],
active_model: this.resModel,
current_company_id: this.model.company.currentCompany.id,
...this._computeDataContext(),
});
for (const [fieldName, value] of Object.entries(toRaw(this.data))) {
if (
this.fields[fieldName].type === "one2many" ||
this.fields[fieldName].type === "many2many"
) {
value._updateContext(getFieldContext(this, fieldName));
}
}
}
async _setInvalidField(fieldName) {
const canProceed = this.model.hooks.onWillSetInvalidField(this, fieldName);
if (canProceed === false) {
return;
}
if (this.selected && this.model.multiEdit && !this._invalidFields.has(fieldName)) {
await this.model.dialog.add(AlertDialog, {
body: _t("No valid record to save"),
confirm: async () => {
await this.discard();
this.switchMode("readonly");
},
});
}
this._invalidFields.add(fieldName);
}
_switchMode(mode) {
this.model._updateConfig(this.config, { mode }, { noReload: true });
if (mode === "readonly") {
this._invalidFields.clear();
}
}
/**
* @param {boolean} state archive the records if true, otherwise unarchive them
*/
async _toggleArchive(state) {
const method = state ? "action_archive" : "action_unarchive";
const context = this.context;
const resId = this.resId;
const action = await this.model.orm.call(this.resModel, method, [[resId]], { context });
if (action && Object.keys(action).length) {
this.model.action.doAction(action, { onClose: () => this._load({ resId }) });
} else {
return this._load({ resId });
}
}
async _update(changes, { withoutOnchange, withoutParentUpdate } = {}) {
this.dirty = true;
const prom = this._preprocessChanges(changes);
if (prom && !this.model._urgentSave) {
await prom;
}
if (this.selected && this.model.multiEdit) {
this._applyChanges(changes);
return this.model.root._multiSave(this);
}
for (const [fieldName, value] of Object.entries(changes)) {
const field = this.fields[fieldName];
if (field && field.relatedPropertyField) {
const propertyFieldName = field.relatedPropertyField.fieldName;
changes[propertyFieldName] = this.data[propertyFieldName].map((property) =>
property.name === field.propertyName ? { ...property, value } : property
);
}
}
const onChangeFields = Object.keys(changes).filter(
(fieldName) => this.activeFields[fieldName] && this.activeFields[fieldName].onChange
);
if (onChangeFields.length && !this.model._urgentSave && !withoutOnchange) {
const localChanges = this._getChanges(
{ ...this._changes, ...changes },
{ withReadonly: true }
);
if (this.config.relationField) {
localChanges[this.config.relationField] = this._parentRecord._getChanges();
}
const otherChanges = await this.model._onchange(this.config, {
changes: localChanges,
fieldNames: onChangeFields,
evalContext: this.evalContext,
});
Object.assign(changes, this._parseServerValues(otherChanges, this.data));
}
// changes inside the record set as value for a many2one field must trigger the onchange,
// but can't be considered as changes on the parent record, so here we detect if many2one
// fields really changed, and if not, we delete them from changes
for (const fieldName in changes) {
if (this.fields[fieldName].type === "many2one") {
const curVal = toRaw(this.data[fieldName]);
const nextVal = changes[fieldName];
if (curVal && nextVal && curVal[0] === nextVal[0] && curVal[1] === nextVal[1]) {
delete changes[fieldName];
}
}
}
if (Object.keys(changes).length > 0) {
const initialChanges = pick(this._changes, ...Object.keys(changes));
this._applyChanges(changes);
try {
await this._onUpdate(changes, { withoutParentUpdate });
} catch (e) {
this._applyChanges(initialChanges);
throw e;
}
await this.model.hooks.onRecordChanged(this, this._getChanges());
}
}
}
@@ -0,0 +1,657 @@
/* @odoo-module */
// @ts-check
import { EventBus, markRaw } from "@odoo/owl";
import { makeContext } from "@web/core/context";
import { Domain } from "@web/core/domain";
import { WarningDialog } from "@web/core/errors/error_dialogs";
import { registry } from "@web/core/registry";
import { shallowEqual, unique } from "@web/core/utils/arrays";
import { KeepLast, Mutex } from "@web/core/utils/concurrency";
import { orderByToString } from "@web/search/utils/order_by";
import { Model } from "../model";
import { DynamicGroupList } from "./dynamic_group_list";
import { DynamicRecordList } from "./dynamic_record_list";
import { Group } from "./group";
import { Record } from "./record";
import { StaticList } from "./static_list";
import {
getFieldsSpec,
createPropertyActiveField,
getAggregatesFromGroupData,
getDisplayNameFromGroupData,
getValueFromGroupData,
isRelational,
} from "./utils";
/**
* @typedef Params
* @property {Config} config
* @property {State} [state]
* @property {Hooks} [hooks]
* @property {number} [limit]
* @property {number} [countLimit]
* @property {number} [groupsLimit]
* @property {string[]} [defaultOrderBy]
* @property {string[]} [defaultGroupBy]
* @property {number} [maxGroupByDepth]
* @property {boolean} [multiEdit]
* @property {Object} [groupByInfo]
*/
/**
* @typedef Config
* @property {string} resModel
* @property {Object} fields
* @property {Object} activeFields
* @property {object} context
* @property {boolean} isMonoRecord
* @property {boolean} isRoot
* @property {Array} [domain]
* @property {Array} [groupBy]
* @property {Array} [orderBy]
* @property {number} [resId]
* @property {number[]} [resIds]
* @property {string} [mode]
* @property {number} [limit]
* @property {number} [offset]
* @property {number} [countLimit]
* @property {number} [groupsLimit]
* @property {Object} [groups]
* @property {boolean} [openGroupsByDefault]
*/
/**
* @typedef Hooks
* @property {Function} [onWillLoadRoot]
* @property {Function} [onWillSaveRecord]
* @property {Function} [onRecordSaved]
* @property {Function} [onWillSaveMulti]
* @property {Function} [onSavedMulti]
* @property {Function} [onWillSetInvalidField]
* @property {Function} [onRecordChanged]
*/
/**
* @typedef State
* @property {Config} config
* @property {Object} specialDataCaches
*/
const DEFAULT_HOOKS = {
onWillLoadRoot: () => {},
onWillSaveRecord: () => {},
onRecordSaved: () => {},
onWillSaveMulti: () => {},
onSavedMulti: () => {},
onWillSetInvalidField: () => {},
onRecordChanged: () => {},
};
export class FetchRecordError extends Error {
constructor(resIds) {
super(`Can't fetch record(s) ${resIds}. They might have been deleted.`);
}
}
export function fetchRecordErrorHandler(env, error, originalError) {
if (originalError instanceof FetchRecordError) {
env.services.notification.add(originalError.message, { sticky: true, type: "danger" });
return true;
}
}
const errorHandlerRegistry = registry.category("error_handlers");
errorHandlerRegistry.add("fetchRecordErrorHandler", fetchRecordErrorHandler);
export class RelationalModel extends Model {
static services = ["action", "company", "dialog", "notification", "orm", "rpc", "user"];
static Record = Record;
static Group = Group;
static DynamicRecordList = DynamicRecordList;
static DynamicGroupList = DynamicGroupList;
static StaticList = StaticList;
static DEFAULT_LIMIT = 80;
static DEFAULT_COUNT_LIMIT = 10000;
static DEFAULT_GROUP_LIMIT = 80;
static DEFAULT_OPEN_GROUP_LIMIT = 10;
static MAX_NUMBER_OPENED_GROUPS = 10;
/**
* @param {Params} params
*/
setup(params, { action, company, dialog, notification, rpc, user }) {
this.action = action;
this.company = company;
this.dialog = dialog;
this.notification = notification;
this.rpc = rpc;
this.user = user;
this.bus = new EventBus();
this.keepLast = markRaw(new KeepLast());
this.mutex = markRaw(new Mutex());
/** @type {Config} */
this.config = {
isMonoRecord: false,
...params.config,
isRoot: true,
};
/** @type {Hooks} */
this.hooks = Object.assign({}, DEFAULT_HOOKS, params.hooks);
this.initialLimit = params.limit || this.constructor.DEFAULT_LIMIT;
this.initialGroupsLimit = params.groupsLimit;
this.initialCountLimit = params.countLimit || this.constructor.DEFAULT_COUNT_LIMIT;
this.defaultOrderBy = params.defaultOrderBy;
this.defaultGroupBy = params.defaultGroupBy;
this.maxGroupByDepth = params.maxGroupByDepth;
this.groupByInfo = params.groupByInfo || {};
this.multiEdit = params.multiEdit;
this.activeIdsLimit = params.activeIdsLimit || Number.MAX_SAFE_INTEGER;
this.specialDataCaches = markRaw(params.state?.specialDataCaches || {});
this._urgentSave = false;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
exportState() {
return {
config: this.config,
specialDataCaches: this.specialDataCaches,
};
}
hasData() {
return this.root.hasData;
}
/**
* @param {Object} [params={}]
* @param {Comparison | null} [params.comparison]
* @param {Context} [params.context]
* @param {DomainListRepr} [params.domain]
* @param {string[]} [params.groupBy]
* @param {Object[]} [params.orderBy]
* @returns {Promise<void>}
*/
async load(params = {}) {
const config = this._getNextConfig(this.config, params);
const data = await this.keepLast.add(this._loadData(config));
this.root = this._createRoot(config, data);
this.config = config;
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
_applyProperties(records, config) {
for (const record of records) {
for (const fieldName in record) {
const field = config.fields[fieldName];
if (fieldName !== "id" && field.type === "properties") {
const parent = record[field.definition_record];
const relatedPropertyField = {
fieldName,
};
if (parent) {
relatedPropertyField.id = parent.id;
relatedPropertyField.displayName = parent.display_name;
}
for (const property of record[fieldName]) {
const propertyFieldName = `${fieldName}.${property.name}`;
if (!config.fields[propertyFieldName]) {
config.fields[propertyFieldName] = {
...property,
name: propertyFieldName,
relatedPropertyField,
propertyName: property.name,
relation: property.comodel,
};
config.activeFields[propertyFieldName] =
createPropertyActiveField(property);
}
}
}
}
}
}
/**
*
* @param {Config} config
* @param {*} data
* @returns {DataPoint}
*/
_createRoot(config, data) {
if (config.isMonoRecord) {
return new this.constructor.Record(this, config, data);
}
if (config.groupBy.length) {
return new this.constructor.DynamicGroupList(this, config, data);
}
return new this.constructor.DynamicRecordList(this, config, data);
}
/**
* @param {*} params
* @returns {Config}
*/
_getNextConfig(currentConfig, params) {
const currentGroupBy = currentConfig.groupBy;
const config = Object.assign({}, currentConfig);
config.context = "context" in params ? params.context : config.context;
if (!currentConfig.isMonoRecord) {
config.domain = "domain" in params ? params.domain : config.domain;
config.comparison = "comparison" in params ? params.comparison : config.comparison;
// groupBy
config.groupBy = "groupBy" in params ? params.groupBy : config.groupBy;
// apply default groupBy if any
if (this.defaultGroupBy && !config.groupBy.length) {
config.groupBy = [this.defaultGroupBy];
}
// restrict the number of groupbys if requested
if (this.maxGroupByDepth) {
config.groupBy = config.groupBy.slice(0, this.maxGroupByDepth);
}
// orderBy
config.orderBy = "orderBy" in params ? params.orderBy : config.orderBy;
// re-apply previous orderBy if not given (or no order)
if (!config.orderBy.length) {
config.orderBy = currentConfig.orderBy || [];
}
// apply default order if no order
if (this.defaultOrderBy && !config.orderBy.length) {
config.orderBy = this.defaultOrderBy;
}
// keep current root config if any, if the groupBy parameter is the same
if (!shallowEqual(config.groupBy || [], currentGroupBy || [])) {
delete config.groups;
}
}
if (!config.isMonoRecord && this.root) {
// always reset the offset to 0 when reloading from above
config.offset = 0;
}
return config;
}
/**
*
* @param {Config} config
*/
async _loadData(config) {
if (config.isRoot) {
this.hooks.onWillLoadRoot();
}
if (config.isMonoRecord) {
const evalContext = {
...config.context,
active_id: config.resId,
active_ids: [config.resId],
active_model: config.resModel,
current_company_id: this.company.currentCompany.id,
};
if (!config.resId) {
return this._loadNewRecord(config, { evalContext });
}
const records = await this._loadRecords(
{
...config,
resIds: [config.resId],
},
evalContext
);
return records[0];
}
if (config.resIds) {
// static list
const resIds = config.resIds.slice(config.offset, config.offset + config.limit);
return this._loadRecords({ ...config, resIds });
}
if (config.groupBy.length) {
return this._loadGroupedList(config);
}
Object.assign(config, {
limit: config.limit || this.initialLimit,
countLimit: "countLimit" in config ? config.countLimit : this.initialCountLimit,
offset: config.offset || 0,
});
if (config.countLimit !== Number.MAX_SAFE_INTEGER) {
config.countLimit = Math.max(config.countLimit, config.offset + config.limit);
}
return this._loadUngroupedList({
...config,
context: {
...config.context,
current_company_id: this.company.currentCompany.id,
},
});
}
/**
* @param {Config} config
*/
async _loadGroupedList(config) {
config.offset = config.offset || 0;
config.limit = config.limit || this.initialGroupsLimit;
if (!config.limit) {
config.limit = config.openGroupsByDefault
? this.constructor.DEFAULT_OPEN_GROUP_LIMIT
: this.constructor.DEFAULT_GROUP_LIMIT;
}
config.groups = config.groups || {};
const firstGroupByName = config.groupBy[0].split(":")[0];
const orderBy = config.orderBy.filter(
(o) =>
o.name === firstGroupByName ||
(o.name in config.activeFields &&
config.fields[o.name].group_operator !== undefined)
);
const response = await this._webReadGroup(config, firstGroupByName, orderBy);
const { groups, length } = response;
const groupBy = config.groupBy.slice(1);
const groupByField = config.fields[config.groupBy[0].split(":")[0]];
const commonConfig = {
resModel: config.resModel,
fields: config.fields,
activeFields: config.activeFields,
};
let groupRecordConfig;
const groupRecordResIds = [];
if (this.groupByInfo[firstGroupByName]) {
groupRecordConfig = {
...this.groupByInfo[firstGroupByName],
resModel: config.fields[firstGroupByName].relation,
context: {},
};
}
const proms = [];
let nbOpenGroups = 0;
for (const group of groups) {
// When group_by_no_leaf key is present FIELD_ID_count doesn't exist
// we have to get the count from `__count` instead
// see _read_group_raw in models.py
group.count = group.__count || group[`${firstGroupByName}_count`];
group.length = group.count;
group.range = group.__range ? group.__range[config.groupBy[0]] : null;
delete group.__count;
delete group[`${firstGroupByName}_count`];
delete group.__range;
group.value = getValueFromGroupData(group, groupByField, group[config.groupBy[0]]);
group.displayName = getDisplayNameFromGroupData(groupByField, group[config.groupBy[0]]);
group.aggregates = getAggregatesFromGroupData(group, config.fields);
// delete group[config.groupBy[0]];
if (!config.groups[group.value]) {
config.groups[group.value] = {
...commonConfig,
groupByFieldName: groupByField.name,
isFolded: "__fold" in group ? group.__fold : !config.openGroupsByDefault,
extraDomain: false,
value: group.value,
list: {
...commonConfig,
groupBy,
},
};
if (groupRecordConfig) {
config.groups[group.value].record = {
...groupRecordConfig,
resId: group.value ?? false,
};
}
}
if (groupRecordConfig) {
const resId = config.groups[group.value].record.resId;
if (resId) {
groupRecordResIds.push(resId);
}
}
const groupConfig = config.groups[group.value];
groupConfig.list.orderBy = config.orderBy;
groupConfig.initialDomain = group.__domain;
if (groupConfig.extraDomain) {
groupConfig.list.domain = Domain.and([
group.__domain,
groupConfig.extraDomain,
]).toList();
} else {
groupConfig.list.domain = group.__domain;
}
const context = {
...config.context,
[`default_${firstGroupByName}`]: group.value,
};
groupConfig.list.context = context;
groupConfig.context = context;
if (groupBy.length) {
group.groups = [];
} else {
group.records = [];
}
if (isRelational(config.fields[firstGroupByName]) && !group.value) {
groupConfig.isFolded = true;
}
if (!groupConfig.isFolded) {
nbOpenGroups++;
if (nbOpenGroups > this.constructor.MAX_NUMBER_OPENED_GROUPS) {
groupConfig.isFolded = true;
}
}
if (!groupConfig.isFolded && group.count > 0) {
const prom = this._loadData(groupConfig.list).then((response) => {
if (groupBy.length) {
group.groups = response ? response.groups : [];
} else {
group.records = response ? response.records : [];
}
});
proms.push(prom);
}
}
if (groupRecordConfig && Object.keys(groupRecordConfig.activeFields).length) {
const prom = this._loadRecords({
...groupRecordConfig,
resIds: groupRecordResIds,
}).then((records) => {
for (const group of groups) {
group.values = records.find((r) => group.value && r.id === group.value);
}
});
proms.push(prom);
}
await Promise.all(proms);
// if a group becomes empty at some point (e.g. we dragged its last record out of it), and the view is reloaded
// with the same domain and groupbys, we want to keep the empty group in the UI
if (
config.currentGroups &&
config.currentGroups.params ===
JSON.stringify([config.domain, config.groupBy, config.offset, config.limit])
) {
const currentGroups = config.currentGroups.groups;
for (const group of currentGroups) {
if (
config.groups[group.value] &&
!groups.some((g) => JSON.stringify(g.value) === JSON.stringify(group.value))
) {
groups.push(Object.assign({}, group, { count: 0, length: 0, records: [] }));
}
}
}
config.currentGroups = {
params: JSON.stringify([config.domain, config.groupBy, config.offset, config.limit]),
groups,
};
return { groups, length };
}
/**
* @param {Config} config
* @param {Object} [params={}]
* @returns Promise<Object>
*/
_loadNewRecord(config, params = {}) {
return this._onchange(config, params);
}
/**
*
* @param {Config} config
* @param {object} evalContext
* @returns
*/
async _loadRecords(config, evalContext = config.context) {
const { resModel, resIds, activeFields, fields, context } = config;
if (!resIds.length) {
return [];
}
const kwargs = {
context: { bin_size: true, ...context },
specification: getFieldsSpec(activeFields, fields, evalContext),
};
const records = await this.orm.call(resModel, "web_read", [resIds], kwargs);
if (!records.length) {
throw new FetchRecordError(resIds);
}
this._applyProperties(records, config);
return records;
}
/**
* Load records from the server for an ungrouped list. Return the result
* of unity read RPC.
*
* @param {Config} config
* @returns
*/
async _loadUngroupedList(config) {
const kwargs = {
specification: getFieldsSpec(config.activeFields, config.fields, config.context),
domain: config.domain,
offset: config.offset,
order: orderByToString(config.orderBy),
limit: config.limit,
context: { bin_size: true, ...config.context },
count_limit:
config.countLimit !== Number.MAX_SAFE_INTEGER ? config.countLimit + 1 : undefined,
};
const response = await this.orm.call(config.resModel, "unity_web_search_read", [], kwargs);
this._applyProperties(response.records, config);
return response;
}
/**
* @param {Config} config
* @param {Object} param
* @param {Object} [param.changes={}]
* @param {string[]} [param.fieldNames=[]]
* @param {Object} [param.evalContext=config.context]
* @returns Promise<Object>
*/
async _onchange(config, { changes = {}, fieldNames = [], evalContext = config.context }) {
const { fields, activeFields, resModel, resId } = config;
let context = config.context;
if (fieldNames.length === 1) {
const fieldContext = config.activeFields[fieldNames[0]].context;
context = makeContext([context, fieldContext], evalContext);
}
const spec = getFieldsSpec(activeFields, fields, evalContext, { withInvisible: true });
const args = [resId ? [resId] : [], changes, fieldNames, spec];
const response = await this.orm.call(resModel, "onchange2", args, { context });
if (response.warning) {
const { type, title, message, className, sticky } = response.warning;
if (type === "dialog") {
this.dialog.add(WarningDialog, { title, message });
} else {
this.notification.add(message, {
className,
sticky,
title,
type: "warning",
});
}
}
return response.value;
}
/**
*
* @param {Config} config
* @param {Partial<Config>} patch
* @param {Object} [options]
* @param {boolean} [options.noReload=false]
*/
async _updateConfig(config, patch, options = {}) {
const tmpConfig = { ...config, ...patch };
let response;
if (!options.noReload) {
response = await this._loadData(tmpConfig);
}
Object.assign(config, tmpConfig);
return response;
}
/**
*
* @param {Config} config
* @returns {Promise<number>}
*/
async _updateCount(config) {
const count = await this.keepLast.add(this.orm.searchCount(config.resModel, config.domain));
config.countLimit = Number.MAX_SAFE_INTEGER;
return count;
}
/**
* When grouped by a many2many field, the same record may be displayed in
* several groups. When one of these records is edited, we want all other
* occurrences to be updated. The purpose of this function is to find and
* update all occurrences of a record that has been reloaded, in a grouped
* list view.
*/
_updateSimilarRecords(reloadedRecord, serverValues) {
if (this.config.isMonoRecord || !this.config.groupBy.length) {
return;
}
for (const record of this.root.records) {
if (record === reloadedRecord) {
continue;
}
if (record.resId === reloadedRecord.resId) {
record._applyValues(serverValues);
}
}
}
async _webReadGroup(config, firstGroupByName, orderBy) {
return this.orm.webReadGroup(
config.resModel,
config.domain,
unique([...Object.keys(config.activeFields), firstGroupByName]),
[config.groupBy[0]],
{
orderby: orderByToString(orderBy),
lazy: true, // maybe useless
offset: config.offset,
limit: config.limit,
context: config.context,
}
);
}
}
@@ -0,0 +1,890 @@
/* @odoo-module */
import { x2ManyCommands } from "@web/core/orm_service";
import { intersection } from "@web/core/utils/arrays";
import { pick } from "@web/core/utils/objects";
import { DataPoint } from "./datapoint";
import { fromUnityToServerValues, getId, patchActiveFields } from "./utils";
import { markRaw } from "@odoo/owl";
function compareFieldValues(v1, v2, fieldType) {
if (fieldType === "many2one") {
v1 = v1 ? v1[1] : false;
v2 = v2 ? v2[1] : false;
}
return v1 < v2;
}
function compareRecords(r1, r2, orderBy, fields) {
const { name, asc } = orderBy[0];
const v1 = asc ? r1.data[name] : r2.data[name];
const v2 = asc ? r2.data[name] : r1.data[name];
if (compareFieldValues(v1, v2, fields[name].type)) {
return -1;
}
if (compareFieldValues(v2, v1, fields[name].type)) {
return 1;
}
if (orderBy.length > 1) {
return compareRecords(r1, r2, orderBy.slice(1));
}
return 0;
}
export class StaticList extends DataPoint {
static type = "StaticList";
/**
* @param {import("./relational_model").Config} config
* @param {Object} data
* @param {Object} [options={}]
* @param {Function} [options.onUpdate]
* @param {Record} [options.parent]
*/
setup(config, data, options = {}) {
this._parent = options.parent;
this._onUpdate = options.onUpdate;
this._cache = markRaw({});
this._commands = [];
this._savePoint = markRaw({});
this._unknownRecordCommands = {}; // tracks update commands on records we haven't fetched yet
this._currentIds = [...this.resIds];
this._needsReordering = false;
this._tmpIncreaseLimit = 0;
// In kanban and non editable list views, x2many records can be opened in a form view in
// dialog, which may contain other fields than the kanban or list view. The next set keeps
// tracks of records we already opened in dialog and thus for which we already modified the
// config to add the form view's fields in activeFields.
this._extendedRecords = new Set();
this.records = data
.slice(this.offset, this.limit)
.map((r) => this._createRecordDatapoint(r));
this.count = this.resIds.length;
this.handleField = Object.keys(this.activeFields).find(
(fieldName) => this.activeFields[fieldName].isHandle
);
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
get currentIds() {
return this._currentIds;
}
get editedRecord() {
return this.records.find((record) => record.isInEdition);
}
get evalContext() {
return {
...this.config.context,
parent: this._parent.evalContext,
};
}
get limit() {
return this.config.limit;
}
get offset() {
return this.config.offset;
}
get orderBy() {
return this.config.orderBy;
}
get resIds() {
return this.config.resIds;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
/**
* Adds a new record to an x2many relation. If params.record is given, adds
* given record (use case: after saving the form dialog in a, e.g., non
* editable x2many list). Otherwise, do an onchange to get the initial
* values and create a new Record (e.g. after clicking on Add a line in an
* editable x2many list).
*
* @param {Object} params
* @param {"top"|"bottom"} [params.position]
* @param {Object} [params.activeFields=this.activeFields]
* @param {boolean} [params.withoutParent=false]
*/
addNewRecord(params) {
return this.model.mutex.exec(async () => {
const { activeFields, context, mode, position, withoutParent } = params;
const record = await this._createNewRecordDatapoint({
activeFields,
context,
position,
withoutParent,
manuallyAdded: true,
mode,
});
await this._addRecord(record, { position });
await this._onUpdate({ withoutOnchange: !record._checkValidity({ silent: true }) });
return record;
});
}
canResequence() {
return this.handleField && this.orderBy.length && this.orderBy[0].name === this.handleField;
}
/**
* TODO: We should probably delete this function.
* It is only used for the product configurator.
* It will take a list of contexts containing default
* values used to create the new records in the static list.
* It will then delete the old records from the static list
* and replace them with the new ones we have just created.
*/
createAndReplace(contextRecords) {
return this.model.mutex.exec(async () => {
const proms = [];
for (const context of contextRecords) {
proms.push(
this._createNewRecordDatapoint({
context,
manuallyAdded: true,
})
);
}
this.records = await Promise.all(proms);
this._commands = this.records.map((record) => [
x2ManyCommands.CREATE,
record._virtualId,
]);
this._currentIds = this.records.map((record) => record._virtualId);
});
}
async delete(record) {
await this._applyCommands([[x2ManyCommands.DELETE, record.resId || record._virtualId]]);
this._onUpdate();
}
async enterEditMode(record) {
const canProceed = await this.leaveEditMode();
if (canProceed) {
await record.switchMode("edit");
}
return canProceed;
}
/**
* This method is meant to be used in a very specific usecase: when an x2many record is viewed
* or edited through a form view dialog (e.g. x2many kanban or non editable list). In this case,
* the form typically contains different fields than the kanban or list, so we need to "extend"
* the fields and activeFields. If the record opened in a form view dialog already exists, we
* modify it's config to add the new fields. If it is a new record, we create it with the
* extended config.
*
* @param {Object} params
* @param {Object} params.activeFields
* @param {Object} params.fields
* @param {Object} [params.context]
* @param {boolean} [params.withoutParent]
* @param {Record} [record]
* @returns {Record}
*/
extendRecord(params, record) {
return this.model.mutex.exec(async () => {
// extend fields and activeFields of the list with those given in params
Object.assign(this.fields, params.fields);
const activeFields = { ...params.activeFields };
for (const fieldName in this.activeFields) {
if (fieldName in activeFields) {
patchActiveFields(activeFields[fieldName], this.activeFields[fieldName]);
} else {
activeFields[fieldName] = this.activeFields[fieldName];
}
}
if (record) {
// case 1: the record already exists
if (this._extendedRecords.has(record.id)) {
// case 1.1: the record has already been extended
// -> simply switch it to edit and store a savepoint
record._switchMode("edit");
record._addSavePoint();
return record;
}
// case 1.2: the record is extended for the first time, and it now potentially has
// more fields than before (or x2many fields displayed differently)
// -> if it isn't a new record, load it to retrieve the values of new fields
// -> generate default values for new fields
// -> recursively update the config of the record and it's sub datapoints
// -> apply the loaded values in the case of a not new record
// -> store a savepoint
// These operations must be done in that specific order to ensure that the model is
// mutated only once (in a tick), and that datapoints have the correct config to
// handle field values they receive.
const config = {
...record.config,
...params,
activeFields,
};
let data = {};
if (!record.isNew) {
const evalContext = Object.assign({}, record.evalContext, config.context);
const resIds = [record.resId];
[data] = await this.model._loadRecords({ ...config, resIds }, evalContext);
}
this.model._updateConfig(record.config, config, { noReload: true });
record._applyDefaultValues();
for (const fieldName in record.activeFields) {
if (["one2many", "many2many"].includes(record.fields[fieldName].type)) {
if (activeFields[fieldName].related) {
const list = record.data[fieldName];
const patch = {
activeFields: activeFields[fieldName].related.activeFields,
fields: activeFields[fieldName].related.fields,
};
for (const subRecord of Object.values(list._cache)) {
this.model._updateConfig(subRecord.config, patch, {
noReload: true,
});
}
this.model._updateConfig(list.config, patch, { noReload: true });
}
}
}
record._applyValues(data);
record._addSavePoint();
} else {
// case 2: the record is a new record
// -> simply create one with the extended config
record = await this._createNewRecordDatapoint({
activeFields,
context: params.context,
withoutParent: params.withoutParent,
manuallyAdded: true,
});
}
// mark the record as being extended, to go through case 1.1 next time
this._extendedRecords.add(record.id);
return record;
});
}
async forget(record) {
await this._applyCommands([[x2ManyCommands.FORGET, record.resId]]);
this._onUpdate();
}
leaveEditMode({ discard, canAbandon, validate } = {}) {
return this.model.mutex.exec(async () => {
if (this.editedRecord) {
await this.editedRecord._askChanges();
const isValid = this.editedRecord._checkValidity();
if (!isValid && validate) {
return false;
}
if (canAbandon !== false && !validate) {
this._abandonRecords([this.editedRecord], { force: true });
}
// if we still have an editedRecord, it means it hasn't been abandonned
if (this.editedRecord) {
if (isValid && !this.editedRecord.dirty && discard) {
return false;
}
if (
isValid ||
(!this.editedRecord.dirty && !this.editedRecord._manuallyAdded)
) {
this.editedRecord._switchMode("readonly");
}
}
}
return !this.editedRecord;
});
}
async linkTo(resId, serverData) {
await this._applyCommands([[x2ManyCommands.LINK_TO, resId, serverData]]);
this._onUpdate();
}
load({ limit, offset, orderBy }) {
return this.model.mutex.exec(async () => {
if (this.editedRecord && !(await this.editedRecord.checkValidity())) {
return;
}
limit = limit !== undefined ? limit : this.limit;
offset = offset !== undefined ? offset : this.offset;
orderBy = orderBy !== undefined ? orderBy : this.orderBy;
return this._load({ limit, offset, orderBy });
});
}
moveRecord(dataRecordId, _dataGroupId, refId, _targetGroupId) {
return this.resequence(dataRecordId, refId);
}
sortBy(fieldName) {
return this.model.mutex.exec(() => this._sortBy(fieldName));
}
async replaceWith(ids, { reload = false, silent = false } = {}) {
const resIds = reload ? ids : ids.filter((id) => !this._cache[id]);
if (resIds.length) {
const records = await this.model._loadRecords({
...this.config,
resIds,
context: this.context,
});
for (const record of records) {
this._createRecordDatapoint(record);
}
}
this.records = ids.map((id) => this._cache[id]);
const updateCommandsToKeep = this._commands.filter(
(c) => c[0] === x2ManyCommands.UPDATE && ids.includes(c[1])
);
this._commands = [x2ManyCommands.replaceWith(ids)].concat(updateCommandsToKeep);
this._currentIds = [...ids];
this.count = this._currentIds.length;
if (!silent) {
this._onUpdate();
}
}
async resequence(movedId, targetId) {
return this.model.mutex.exec(() => this._resequence(movedId, targetId));
}
/**
* This method is meant to be called when a record, which has previously been extended to be
* displayed in a form view dialog (see @extendRecord) is saved. In this case, we may need to
* add this record to the list (if it is a new one), and to notify the parent record of the
* update. We may also want to sort the list.
*
* @param {Record} record
*/
validateExtendedRecord(record) {
return this.model.mutex.exec(async () => {
if (!this._currentIds.includes(record.isNew ? record._virtualId : record.resId)) {
// new record created, not yet in the list
await this._addRecord(record);
}
await this._onUpdate();
if (this.orderBy.length) {
await this._sort();
}
});
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
_abandonRecords(records = this.records, { force } = {}) {
for (const record of records) {
if (record.canBeAbandoned && (force || !record._checkValidity())) {
const virtualId = record._virtualId;
const index = this._currentIds.findIndex((id) => id === virtualId);
this._currentIds.splice(index, 1);
this.records.splice(
this.records.findIndex((r) => r === record),
1
);
this._commands = this._commands.filter((c) => c[1] !== virtualId);
this.count--;
if (this._tmpIncreaseLimit > 0) {
this.model._updateConfig(
this.config,
{ limit: this.limit - 1 },
{ noReload: true }
);
this._tmpIncreaseLimit--;
}
}
}
}
async _addRecord(record, { position } = {}) {
const command = [x2ManyCommands.CREATE, record._virtualId];
if (position === "top") {
this.records.unshift(record);
if (this.records.length > this.limit) {
this.records.pop();
}
this._currentIds.splice(this.offset, 0, record._virtualId);
this._commands.unshift(command);
} else if (position === "bottom") {
this.records.push(record);
this._currentIds.splice(this.offset + this.limit, 0, record._virtualId);
if (this.records.length > this.limit) {
this._tmpIncreaseLimit++;
const nextLimit = this.limit + 1;
this.model._updateConfig(this.config, { limit: nextLimit }, { noReload: true });
}
this._commands.push(command);
} else {
const currentIds = [...this._currentIds, record._virtualId];
if (this.orderBy.length) {
await this._sort(currentIds);
} else {
if (this.records.length < this.limit) {
this.records.push(record);
}
}
this._currentIds = currentIds;
this._commands.push(command);
}
this.count++;
this._needsReordering = true;
}
_addSavePoint() {
for (const id in this._cache) {
this._cache[id]._addSavePoint();
}
this._savePoint._commands = [...this._commands];
this._savePoint._currentIds = [...this._currentIds];
this._savePoint.count = this.count;
}
_applyCommands(commands) {
const isOnLastPage = this.limit + this.offset >= this.count;
const { CREATE, UPDATE, DELETE, FORGET, LINK_TO, REPLACE_WITH } = x2ManyCommands;
for (const command of commands) {
switch (command[0]) {
case CREATE: {
const virtualId = getId("virtual");
const record = this._createRecordDatapoint(command[2], { virtualId });
this.records.push(record);
this._commands.push([CREATE, virtualId]);
this._currentIds.splice(this.offset + this.limit, 0, virtualId);
this.count++;
break;
}
case UPDATE: {
const existingCommand = this._commands.find((c) => {
return (c[0] === CREATE || c[0] === UPDATE) && c[1] === command[1];
});
if (!existingCommand) {
this._commands.push([UPDATE, command[1]]);
}
const record = this._cache[command[1]];
if (!record) {
// the record isn't in the cache, it means it is on a page we haven't loaded
// so we say the record is "unknown", and store all update commands we
// receive about it in a separated structure, s.t. we can easily apply them
// later on after loading the record, if we ever load it.
if (!(command[1] in this._unknownRecordCommands)) {
this._unknownRecordCommands[command[1]] = [];
}
this._unknownRecordCommands[command[1]].push(command);
} else if (command[1] in this._unknownRecordCommands) {
// this case is more tricky: the record is in the cache, but it isn't loaded
// yet, as we are currently loading it (see below, where we load missing
// records for the current page)
this._unknownRecordCommands[command[1]].push(command);
} else {
record._applyChanges(record._parseServerValues(command[2], record.data));
}
break;
}
case DELETE:
case FORGET: {
if (command[0] === DELETE) {
if (!this._commands.find((c) => c[0] === CREATE && c[1] === command[1])) {
this._commands.push([DELETE, command[1]]);
}
this._commands = this._commands.filter((c) => {
return !(c[0] === CREATE || c[0] === UPDATE) || c[1] !== command[1];
});
} else {
// FORGET
const replaceWithIndex = this._commands.findIndex(
(c) => c[0] === REPLACE_WITH && c[2].includes(command[1])
);
if (replaceWithIndex >= 0) {
const ids = this._commands[replaceWithIndex][2];
this._commands[replaceWithIndex][2] = ids.filter(
(id) => id !== command[1]
);
} else {
const linkToIndex = this._commands.findIndex(
(c) => c[0] === LINK_TO && c[1] === command[1]
);
if (linkToIndex >= 0) {
this._commands.splice(linkToIndex, 1);
} else {
this._commands.push([FORGET, command[1]]);
}
}
}
const record = this._cache[command[1]];
this.records.splice(
this.records.findIndex((r) => r.id === record.id),
1
);
if (record.resId) {
const index = this._currentIds.findIndex((id) => id === record.resId);
this._currentIds.splice(index, 1);
}
this.count--;
break;
}
case LINK_TO: {
const record = this._createRecordDatapoint({ ...command[2], id: command[1] });
if (!this.limit || this.records.length < this.limit) {
this.records.push(record);
}
this._currentIds.push(record.resId);
this._commands.push([command[0], command[1]]);
this.count++;
break;
}
}
}
// if we aren't on the last page, and *n* records of the current page have been removed
// removed, the first *n* records of the next page become the last *n* ones of the current
// page, so we need to add (and maybe load) them.
const nbMissingRecords = this.limit - this.records.length;
if (!isOnLastPage && nbMissingRecords > 0) {
const lastRecordIndex = this.limit + this.offset;
const firstRecordIndex = lastRecordIndex - nbMissingRecords;
const nextRecordIds = this._currentIds.slice(firstRecordIndex, lastRecordIndex);
const recordsToLoad = [];
for (const id of nextRecordIds) {
if (this._cache[id]) {
this.records.push(this._cache[id]);
} else {
// id isn't in the cache, so we know it's not a virtual id
const record = this._createRecordDatapoint({ id }, { dontApplyCommands: true });
this.records.push(record);
recordsToLoad.push(record);
}
}
const resIds = recordsToLoad.map((r) => r.resId);
this.model._loadRecords({ ...this.config, resIds }).then((recordValues) => {
for (let i = 0; i < recordsToLoad.length; i++) {
const record = recordsToLoad[i];
record._applyValues(recordValues[i]);
const commands = this._unknownRecordCommands[record.resId];
if (commands) {
delete this._unknownRecordCommands[record.resId];
this._applyCommands(commands);
}
}
});
}
}
async _createNewRecordDatapoint(params = {}) {
const changes = {};
if (!params.withoutParent) {
changes[this.config.relationField] = this._parent._getChanges();
}
const values = await this.model._loadNewRecord(
{
resModel: this.resModel,
activeFields: params.activeFields || this.activeFields,
fields: this.fields,
context: Object.assign({}, this.context, params.context),
},
{ changes, evalContext: this.evalContext }
);
if (this.canResequence() && this.records.length) {
const position = params.position || "bottom";
const order = this.orderBy[0];
const asc = !order || order.asc;
let value;
if (position === "top") {
const isOnFirstPage = this.offset === 0;
value = this.records[0].data[this.handleField];
if (isOnFirstPage) {
if (asc) {
value = value > 0 ? value - 1 : 0;
} else {
value = value + 1;
}
}
} else if (position === "bottom") {
value = this.records[this.records.length - 1].data[this.handleField];
const isOnLastPage = this.limit + this.offset >= this.count;
if (isOnLastPage) {
if (asc) {
value = value + 1;
} else {
value = value > 0 ? value - 1 : 0;
}
}
}
values[this.handleField] = value;
}
return this._createRecordDatapoint(values, {
mode: params.mode || "edit",
virtualId: getId("virtual"),
activeFields: params.activeFields,
manuallyAdded: params.manuallyAdded,
});
}
_createRecordDatapoint(data, params = {}) {
const resId = data.id || false;
if (!resId && !params.virtualId) {
throw new Error("You must provide a virtualId if the record has no id");
}
const id = resId || params.virtualId;
const config = {
context: this.context,
activeFields: params.activeFields || this.activeFields,
resModel: this.resModel,
fields: params.fields || this.fields,
relationField: this.config.relationField,
resId,
resIds: resId ? [resId] : [],
mode: params.mode || "readonly",
isMonoRecord: true,
};
const { CREATE, UPDATE } = x2ManyCommands;
const options = {
parentRecord: this._parent,
onUpdate: async (changes, { withoutParentUpdate }) => {
if (!this.currentIds.includes(record.isNew ? record._virtualId : record.resId)) {
// the record hasn't been added to the list yet (we're currently creating it
// from a dialog)
return;
}
const hasCommand = this._commands.some(
(c) => (c[0] === CREATE || c[0] === UPDATE) && c[1] === id
);
if (!hasCommand) {
this._commands.push([UPDATE, id]);
}
if (this._extendedRecords.has(record.id)) {
// the record is edited from a dialog, so we don't want to notify the parent
// record to be notified at each change inside the dialog (it will be notified
// at the end when the dialog is saved)
return;
}
if (!withoutParentUpdate) {
await this._onUpdate({
withoutOnchange: !record._checkValidity({ silent: true }),
});
}
},
virtualId: params.virtualId,
manuallyAdded: params.manuallyAdded,
};
const record = new this.model.constructor.Record(this.model, config, data, options);
this._cache[id] = record;
if (!params.dontApplyCommands) {
const commands = this._unknownRecordCommands[id];
if (commands) {
delete this._unknownRecordCommands[id];
this._applyCommands(commands);
}
}
return record;
}
_discard() {
for (const id in this._cache) {
this._cache[id]._discard();
}
if (this._savePoint._commands) {
this._commands = this._savePoint._commands;
this._currentIds = this._savePoint._currentIds;
this.count = this._savePoint.count;
delete this._savePoint._commands;
delete this._savePoint._currentIds;
delete this._savePoint.count;
} else {
this._commands = [];
this._currentIds = [...this.resIds];
this.count = this.resIds.length;
}
this._unknownRecordCommands = [];
const limit = this.limit - this._tmpIncreaseLimit;
this._tmpIncreaseLimit = 0;
this.model._updateConfig(this.config, { limit }, { noReload: true });
this.records = this._currentIds
.slice(this.offset, this.limit)
.map((resId) => this._cache[resId]);
}
_getCommands({ withReadonly } = {}) {
const { CREATE, UPDATE } = x2ManyCommands;
const commands = [];
for (const command of this._commands) {
if (command[0] === UPDATE && command[1] in this._unknownRecordCommands) {
// the record has never been loaded, but we received update commands from the
// server for it, so we need to sanitize them (as they contained unity values)
const uCommands = this._unknownRecordCommands[command[1]];
for (const uCommand of uCommands) {
const values = fromUnityToServerValues(
uCommand[2],
this.fields,
this.activeFields
);
commands.push([uCommand[0], uCommand[1], values]);
}
} else if (command[0] === CREATE || command[0] === UPDATE) {
const record = this._cache[command[1]];
const values = record._getChanges(record._changes, { withReadonly });
if (command[0] === CREATE || Object.keys(values).length) {
commands.push([command[0], command[1], values]);
}
} else {
commands.push(command);
}
}
return commands;
}
async _load({ limit, offset, orderBy }) {
const currentIds = this._currentIds.slice(offset, offset + limit);
// FIXME: record in cache could be not yet loaded
const resIds = currentIds.filter((id) => typeof id === "number" && !this._cache[id]);
if (resIds.length) {
const records = await this.model._loadRecords({ ...this.config, resIds });
for (const record of records) {
this._createRecordDatapoint(record);
}
}
this.records = currentIds.map((id) => this._cache[id]);
await this.model._updateConfig(this.config, { limit, offset, orderBy }, { noReload: true });
}
async _resequence(movedId, targetId) {
const records = [...this.records];
const order = this.orderBy.find((o) => o.name === this.handleField);
const asc = !order || order.asc;
// Find indices
const fromIndex = records.findIndex((r) => r.id === movedId);
let toIndex = 0;
if (targetId !== null) {
const targetIndex = records.findIndex((r) => r.id === targetId);
toIndex = fromIndex > targetIndex ? targetIndex + 1 : targetIndex;
}
const getSequence = (rec) => rec && rec.data[this.handleField];
// Determine what records need to be modified
const firstIndex = Math.min(fromIndex, toIndex);
const lastIndex = Math.max(fromIndex, toIndex) + 1;
let reorderAll = false;
let lastSequence = (asc ? -1 : 1) * Infinity;
for (let index = 0; index < records.length; index++) {
const sequence = getSequence(records[index]);
if (
((index < firstIndex || index >= lastIndex) &&
((asc && lastSequence >= sequence) || (!asc && lastSequence <= sequence))) ||
(index >= firstIndex && index < lastIndex && lastSequence === sequence)
) {
reorderAll = true;
}
lastSequence = sequence;
}
// Perform the resequence in the list of records
const [record] = records.splice(fromIndex, 1);
records.splice(toIndex, 0, record);
// Creates the list of to modify
let toReorder = records;
if (!reorderAll) {
toReorder = toReorder.slice(firstIndex, lastIndex).filter((r) => r.id !== movedId);
if (fromIndex < toIndex) {
toReorder.push(record);
} else {
toReorder.unshift(record);
}
}
if (!asc) {
toReorder.reverse();
}
const sequences = toReorder.map(getSequence);
const offset = sequences.length && Math.min(...sequences);
const proms = [];
for (const [i, record] of Object.entries(toReorder)) {
proms.push(
record._update(
{ [this.handleField]: offset + Number(i) },
{ withoutParentUpdate: true }
)
);
}
await Promise.all(proms);
await this._sort();
this._onUpdate();
}
async _sort(currentIds = this.currentIds, orderBy = this.orderBy) {
const fieldNames = orderBy.map((o) => o.name);
const resIds = currentIds.filter((id) => {
if (typeof id === "string") {
// this is a virtual id, we don't want to read it
return false;
}
const record = this._cache[id];
if (!record) {
// record hasn't been loaded yet
return true;
}
// record has already been loaded -> check if we already read all orderBy fields
return intersection(record.fieldNames, fieldNames).length !== fieldNames.length;
});
if (resIds.length) {
const activeFields = pick(this.activeFields, fieldNames);
const config = { ...this.config, resIds, activeFields };
const records = await this.model._loadRecords(config);
for (const record of records) {
this._createRecordDatapoint(record, { activeFields });
}
}
const allRecords = currentIds.map((id) => this._cache[id]);
const sortedRecords = allRecords.sort((r1, r2) => {
return compareRecords(r1, r2, orderBy, this.fields);
});
const currentPageRecords = sortedRecords.slice(this.offset, this.offset + this.limit);
//TODO: read records that haven't been fully read yet
this.model._updateConfig(this.config, { orderBy }, { noReload: true });
this.records = currentPageRecords;
this._needsReordering = false;
}
async _sortBy(fieldName) {
let orderBy = [...this.orderBy];
if (fieldName) {
if (orderBy.length && orderBy[0].name === fieldName) {
if (!this._needsReordering) {
orderBy[0] = { name: orderBy[0].name, asc: !orderBy[0].asc };
}
} else {
orderBy = orderBy.filter((o) => o.name !== fieldName);
orderBy.unshift({
name: fieldName,
asc: true,
});
}
}
return this._sort(this._currentIds, orderBy);
}
_updateContext(context) {
Object.assign(this.context, context);
}
}
@@ -0,0 +1,523 @@
/* @odoo-module */
import { markup, onWillDestroy, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl";
import { makeContext } from "@web/core/context";
import { deserializeDate, deserializeDateTime } from "@web/core/l10n/dates";
import { x2ManyCommands } from "@web/core/orm_service";
import { Deferred } from "@web/core/utils/concurrency";
import { omit } from "@web/core/utils/objects";
import { effect } from "@web/core/utils/reactive";
import { batched } from "@web/core/utils/timing";
import { orderByToString } from "@web/search/utils/order_by";
export function makeActiveField({
context,
invisible,
readonly,
required,
onChange,
forceSave,
isHandle,
} = {}) {
return {
context: context || "{}",
invisible: invisible || false,
readonly: readonly || false,
required: required || false,
onChange: onChange || false,
forceSave: forceSave || false,
isHandle: isHandle || false,
};
}
const AGGREGATABLE_FIELD_TYPES = ["float", "integer", "monetary"]; // types that can be aggregated in grouped views
export function addFieldDependencies(activeFields, fields, fieldDependencies = []) {
for (const field of fieldDependencies) {
if (field.name in activeFields) {
patchActiveFields(activeFields[field.name], field);
} else {
activeFields[field.name] = makeActiveField(field);
}
if (!fields[field.name]) {
fields[field.name] = omit(field, [
"context",
"invisible",
"required",
"readonly",
"onChange",
]);
}
}
}
export function createPropertyActiveField(property) {
const { type } = property;
const activeField = makeActiveField();
if (type === "many2many") {
activeField.related = {
fields: {
id: { name: "id", type: "integer" },
display_name: { name: "display_name", type: "char" },
},
activeFields: {
id: makeActiveField({ readonly: true }),
display_name: makeActiveField(),
},
};
}
return activeField;
}
export function patchActiveFields(activeField, patch) {
activeField.invisible = activeField.invisible && patch.invisible;
activeField.readonly = activeField.readonly && patch.readonly;
activeField.required = activeField.required || patch.required;
activeField.onChange = activeField.onChange || patch.onChange;
activeField.forceSave = activeField.forceSave || patch.forceSave;
activeField.isHandle = activeField.isHandle || patch.isHandle;
// x2manys
if (patch.related) {
const related = activeField.related;
if (related) {
for (const fieldName in patch.related.activeFields) {
if (fieldName in related.activeFields) {
patchActiveFields(
related.activeFields[fieldName],
patch.related.activeFields[fieldName]
);
} else {
related.activeFields[fieldName] = { ...patch.related.activeFields[fieldName] };
}
}
Object.assign(related.fields, patch.related.fields);
} else {
activeField.related = patch.related;
}
}
if ("limit" in patch) {
activeField.limit = patch.limit;
}
if ("defaultOrderBy" in patch) {
activeField.defaultOrderBy = patch.defaultOrderBy;
}
}
export function extractFieldsFromArchInfo({ fieldNodes, widgetNodes }, fields) {
const activeFields = {};
for (const fieldNode of Object.values(fieldNodes)) {
const fieldName = fieldNode.name;
const modifiers = fieldNode.modifiers || {};
const activeField = makeActiveField({
context: fieldNode.context,
invisible: modifiers.invisible || modifiers.column_invisible,
readonly: modifiers.readonly,
required: modifiers.required,
onChange: fieldNode.onChange,
forceSave: fieldNode.forceSave,
isHandle: fieldNode.isHandle,
});
if (["one2many", "many2many"].includes(fields[fieldName].type)) {
if (fieldNode.views) {
const viewDescr = fieldNode.views[fieldNode.viewMode];
if (viewDescr) {
activeField.related = extractFieldsFromArchInfo(viewDescr, viewDescr.fields);
activeField.limit = viewDescr.limit;
activeField.defaultOrderBy = viewDescr.defaultOrder;
if (fieldNode.views.form) {
// we already know the form view (it is inline), so add its fields (in invisible)
// s.t. they will be sent in the spec for onchange, and create commands returned
// by the onchange could return values for those fields (that would be displayed
// later if the user opens the form view)
const formArchInfo = extractFieldsFromArchInfo(
fieldNode.views.form,
fieldNode.views.form.fields
);
for (const fieldName in formArchInfo.activeFields) {
const formActiveField = {
...formArchInfo.activeFields[fieldName],
invisible: true,
};
if (fieldName in activeField.related.activeFields) {
patchActiveFields(
formActiveField,
activeField.related.activeFields[fieldName]
);
}
activeField.related.activeFields[fieldName] = formActiveField;
}
Object.assign(activeField.related.fields, formArchInfo.fields);
}
}
}
if (fieldNode.field?.useSubView) {
activeField.required = false;
}
}
if (fieldName in activeFields) {
patchActiveFields(activeFields[fieldName], activeField);
} else {
activeFields[fieldName] = activeField;
}
if (fieldNode.field) {
let fieldDependencies = fieldNode.field.fieldDependencies;
if (typeof fieldDependencies === "function") {
fieldDependencies = fieldDependencies(fieldNode);
}
addFieldDependencies(activeFields, fields, fieldDependencies);
}
}
for (const widgetInfo of Object.values(widgetNodes || {})) {
let fieldDependencies = widgetInfo.widget.fieldDependencies;
if (typeof fieldDependencies === "function") {
fieldDependencies = fieldDependencies(widgetInfo);
}
addFieldDependencies(activeFields, fields, fieldDependencies);
}
return { activeFields, fields };
}
export function getFieldContext(
record,
fieldName,
rawContext = record.activeFields[fieldName].context
) {
const context = {};
for (const key in record.context) {
if (
!key.startsWith("default_") &&
!key.startsWith("search_default_") &&
!key.endsWith("_view_ref")
) {
context[key] = record.context[key];
}
}
return {
...context,
...record.fields[fieldName].context,
...makeContext([rawContext], record.evalContext),
};
}
const SENTINEL = Symbol("sentinel");
function _getFieldContextSpec(
fieldName,
activeFields,
fields,
evalContext,
parentActiveFields = null
) {
const rawContext = activeFields[fieldName].context;
if (!rawContext || rawContext === "{}") {
return fields[fieldName].context;
}
evalContext = {
ids: SENTINEL,
active_id: SENTINEL,
active_ids: SENTINEL,
active_model: SENTINEL,
...evalContext,
id: SENTINEL,
};
for (const fieldName in activeFields) {
evalContext[fieldName] = SENTINEL;
}
if (parentActiveFields) {
evalContext.parent = {};
for (const fieldName in parentActiveFields) {
evalContext.parent[fieldName] = SENTINEL;
}
}
const evaluatedContext = makeContext([fields[fieldName].context, rawContext], evalContext);
for (const key in evaluatedContext) {
if (evaluatedContext[key] === SENTINEL || key.startsWith("default_")) {
// FIXME: this isn't perfect, a value might be evaluted to something else
// than the symbol because of the symbol
delete evaluatedContext[key];
}
}
if (Object.keys(evaluatedContext).length > 0) {
return evaluatedContext;
}
}
export function getFieldsSpec(
activeFields,
fields,
evalContext,
{ parentActiveFields, withInvisible } = {}
) {
const fieldsSpec = {};
const properties = [];
for (const fieldName in activeFields) {
if (fields[fieldName].relatedPropertyField) {
continue;
}
const { related, limit, defaultOrderBy, invisible } = activeFields[fieldName];
fieldsSpec[fieldName] = {};
// X2M
if (related && (invisible !== true || withInvisible)) {
fieldsSpec[fieldName].fields = getFieldsSpec(
related.activeFields,
related.fields,
evalContext,
{ parentActiveFields: activeFields, withInvisible }
);
fieldsSpec[fieldName].limit = limit;
if (defaultOrderBy) {
fieldsSpec[fieldName].order = orderByToString(defaultOrderBy);
}
}
// Properties
if (fields[fieldName].type === "properties") {
properties.push(fieldName);
}
// M2O
if (fields[fieldName].type === "many2one" && invisible !== true) {
fieldsSpec[fieldName].fields = { display_name: {} };
}
if (["many2one", "one2many", "many2many"].includes(fields[fieldName].type)) {
const context = _getFieldContextSpec(
fieldName,
activeFields,
fields,
evalContext,
parentActiveFields
);
if (context) {
fieldsSpec[fieldName].context = context;
}
}
// Reference
if (fields[fieldName].type === "reference") {
fieldsSpec[fieldName].fields = { display_name: {} };
}
}
for (const fieldName of properties) {
const fieldSpec = fieldsSpec[fields[fieldName].definition_record];
if (fieldSpec) {
if (!fieldSpec.fields) {
fieldSpec.fields = {};
}
fieldSpec.fields.display_name = {};
}
}
return fieldsSpec;
}
let nextId = 0;
/**
* @param {string} [prefix]
* @returns {string}
*/
export function getId(prefix = "") {
return `${prefix}_${++nextId}`;
}
/**
* @protected
* @param {Field | false} field
* @param {any} value
* @returns {any}
*/
export function parseServerValue(field, value) {
switch (field.type) {
case "char":
case "text": {
return value || "";
}
case "date": {
return value ? deserializeDate(value) : false;
}
case "datetime": {
return value ? deserializeDateTime(value) : false;
}
case "html": {
return markup(value || "");
}
case "selection": {
if (value === false) {
// process selection: convert false to 0, if 0 is a valid key
const hasKey0 = field.selection.find((option) => option[0] === 0);
return hasKey0 ? 0 : value;
}
return value;
}
case "reference": {
if (value === false) {
return false;
}
return {
resId: value.id.id,
resModel: value.id.model,
displayName: value.display_name,
};
}
case "many2one": {
if (Array.isArray(value)) {
return value;
}
if (Number.isInteger(value)) {
// for always invisible many2ones, unity directly returns the id, not a pair
return [value, ""];
}
return value ? [value.id, value.display_name] : false;
}
case "properties": {
return value
? value.map((property) => ({
...property,
value: parseServerValue(property, property.value ?? false),
}))
: [];
}
}
return value;
}
/**
* @param {Object} groupData
* @returns {Object}
*/
export function getAggregatesFromGroupData(groupData, fields) {
const aggregates = {};
for (const [key, value] of Object.entries(groupData)) {
if (key in fields && AGGREGATABLE_FIELD_TYPES.includes(fields[key].type)) {
aggregates[key] = value;
}
}
return aggregates;
}
/**
* @param {import("./datapoint").Field} field
* @param {any} rawValue
* @returns {string | false}
*/
export function getDisplayNameFromGroupData(field, rawValue) {
if (field.type === "selection") {
return Object.fromEntries(field.selection)[rawValue];
}
if (["many2one", "many2many"].includes(field.type)) {
return rawValue ? rawValue[1] : false;
}
return rawValue;
}
/**
* @param {Object} groupData
* @param {import("./datapoint").Field} field
* @param {any} rawValue
* @returns {any}
*/
export function getValueFromGroupData(groupData, field, rawValue) {
if (["date", "datetime"].includes(field.type)) {
const range = groupData.range;
if (!range) {
return false;
}
const dateValue = parseServerValue(field, range.to);
return dateValue.minus({
[field.type === "date" ? "day" : "second"]: 1,
});
}
const value = parseServerValue(field, rawValue);
if (["many2one", "many2many"].includes(field.type)) {
return value ? value[0] : false;
}
return value;
}
/**
* Onchanges sometimes return update commands for records we don't know (e.g. if
* they are on a page we haven't loaded yet). We may actually never load them.
* When this happens, we must still be able to send back those commands to the
* server when saving. However, we can't send the commands exactly as we received
* them, since the values they contain have been "unity read". The purpose of this
* function is to transform field values from the unity format to the format
* expected by the server for a write.
* For instance, for a many2one: { id: 3, display_name: "Marc" } => 3.
*/
export function fromUnityToServerValues(values, fields, activeFields) {
const { CREATE, UPDATE } = x2ManyCommands;
const serverValues = {};
for (const fieldName in values) {
let value = values[fieldName];
switch (fields[fieldName].type) {
case "one2many":
case "many2many":
value = value.map((c) => {
if (c[0] === CREATE || c[0] === UPDATE) {
const _fields = activeFields[fieldName].related.fields;
const _activeFields = activeFields[fieldName].related.activeFields;
return [c[0], c[1], fromUnityToServerValues(c[2], _fields, _activeFields)];
}
return [c[0], c[1]];
});
break;
case "many2one":
value = value ? value.id : false;
break;
// case "reference":
// // TODO
// break;
}
serverValues[fieldName] = value;
}
return serverValues;
}
/**
* @param {any} field
* @returns {boolean}
*/
export function isRelational(field) {
return field && ["one2many", "many2many", "many2one"].includes(field.type);
}
/**
* This hook should only be used in a component field because it
* depends on the record props.
* The callback will be executed once during setup and each time
* a record value read in the callback changes.
* @param {(record) => void} callback
*/
export function useRecordObserver(callback) {
const component = useComponent();
let alive = true;
const fct = (props) => {
const def = new Deferred();
effect(
batched(
async (record) => {
if (!alive) {
// effect doesn't clean up when the component is unmounted.
// We must do it manually.
return;
}
await callback(record);
def.resolve();
},
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
),
[props.record]
);
return def;
};
onWillDestroy(() => {
alive = false;
});
onWillStart(() => fct(component.props));
onWillUpdateProps((props) => {
if (props.record.id !== component.props.record.id) {
return fct(props);
}
});
}
@@ -10,9 +10,12 @@ import {
import { ORM } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { cartesian, sortBy as arraySortBy } from "@web/core/utils/arrays";
import { parseServerValue } from "./relational_model/utils";
class UnimplementedRouteError extends Error {}
let searchReadNumber = 0;
/**
* Helper function returning the value from a list of sample strings
* corresponding to the given ID.
@@ -24,15 +27,15 @@ function getSampleFromId(id, sampleTexts) {
return sampleTexts[(id - 1) % sampleTexts.length];
}
function serializeGroupValue(value, type) {
switch (type) {
case "date":
return serializeDate(value);
case "datetime":
return serializeDateTime(value);
default:
return value;
function serializeGroupDateValue(range, field) {
if (!range) {
return false;
}
let dateValue = parseServerValue(field, range.to);
dateValue = dateValue.minus({
[field.type === "date" ? "day" : "second"]: 1,
});
return field.type === "date" ? serializeDate(dateValue) : serializeDateTime(dateValue);
}
/**
@@ -126,6 +129,8 @@ export class SampleServer {
return this._mockSearchReadController(params);
case "web_search_read":
return this._mockWebSearchRead(params);
case "unity_web_search_read":
return this._mockWebSearchReadUnity(params);
case "web_read_group":
return this._mockWebReadGroup(params);
case "read_group":
@@ -632,6 +637,43 @@ export class SampleServer {
return { records, length: records.length };
}
_mockWebSearchReadUnity(params) {
const fields = Object.keys(params.specification);
let result;
if (this.existingGroups) {
const groups = this.existingGroups;
const group = groups[searchReadNumber++ % groups.length];
result = {
records: group.__data.records,
length: group.__data.records.length,
};
} else {
result = this._mockWebSearchRead({ ...params, fields });
}
// populate x2many values
for (const fieldName in params.specification) {
const field = this.data[params.model].fields[fieldName];
if (field.type === "one2many" || field.type === "many2many") {
const relFields = Object.keys(params.specification[fieldName].fields || {});
if (relFields.length) {
const relIds = result.records.map((r) => r[fieldName]).flat();
const relRecords = {};
const _relRecords = this._mockRead({
model: field.relation,
args: [relIds, relFields],
});
for (const relRecord of _relRecords) {
relRecords[relRecord.id] = relRecord;
}
for (const record of result.records) {
record[fieldName] = record[fieldName].map((resId) => relRecords[resId]);
}
}
}
}
return result;
}
/**
* Mocks calls to the web_read_group method to return groups populated
* with sample records. Only handles the case where the real call to
@@ -668,22 +710,26 @@ export class SampleServer {
* @param {string[]} params.groupBy
*/
_populateExistingGroups(params) {
if (!this.existingGroupsPopulated) {
const groups = this.existingGroups;
const groupBy = params.groupBy[0].split(":")[0];
const groupByField = this.data[params.model].fields[groupBy];
const groupedByM2O = groupByField.type === "many2one";
if (groupedByM2O) {
// re-populate co model with relevant records
this.data[groupByField.relation].records = groups.map((g) => {
return { id: g.value, display_name: g.displayName };
});
const groups = this.existingGroups;
const groupBy = params.groupBy[0].split(":")[0];
const groupByField = this.data[params.model].fields[groupBy];
const groupedByM2O = groupByField.type === "many2one";
if (groupedByM2O) {
// re-populate co model with relevant records
this.data[groupByField.relation].records = groups.map((g) => {
return { id: g.value, display_name: g.displayName };
});
}
for (const r of this.data[params.model].records) {
const group = getSampleFromId(r.id, groups);
if (["date", "datetime"].includes(groupByField.type)) {
r[groupBy] = serializeGroupDateValue(
group.__range[params.groupBy[0]],
groupByField
);
} else {
r[groupBy] = group[params.groupBy[0]];
}
for (const r of this.data[params.model].records) {
const group = getSampleFromId(r.id, groups);
r[groupBy] = serializeGroupValue(group.value, groupByField.type);
}
this.existingGroupsPopulated = true;
}
}
@@ -748,16 +794,20 @@ export class SampleServer {
const records = this.data[params.model].records;
for (const g of groups) {
const recordsInGroup = records.filter((r) => {
return r[groupBy] === serializeGroupValue(g.value, groupByField.type);
if (["date", "datetime"].includes(groupByField.type)) {
return (
r[groupBy] === serializeGroupDateValue(g.__range[fullGroupBy], groupByField)
);
}
return r[groupBy] === g[fullGroupBy];
});
g[`${groupBy}_count`] = recordsInGroup.length;
g[fullGroupBy] = g.__rawValue;
for (const field of params.fields) {
const fieldType = this.data[params.model].fields[field].type;
if (["integer, float", "monetary"].includes(fieldType)) {
g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0);
}
}
g[`${groupBy}_count`] = recordsInGroup.length;
g.__data = {
records: this._mockRead({
model: params.model,
@@ -765,7 +815,6 @@ export class SampleServer {
}),
length: recordsInGroup.length,
};
g.__range = { ...g.range };
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ import { useGetDomainTreeDescription } from "@web/core/domain_selector/utils";
const { DateTime } = luxon;
/** @typedef {import("@web/core/domain").DomainRepr} DomainRepr */
/** @typedef {import("../views/relational_model").OrderTerm} OrderTerm */
/** @typedef {import("../views/utils").OrderTerm} OrderTerm */
/**
* @typedef {Object} ComparisonDomain
@@ -0,0 +1,39 @@
/** @odoo-module */
/**
* @typedef {Object} OrderTerm
* @property {string} name
* @property {boolean} asc
*/
/**
* @param {OrderTerm[]} orderBy
* @returns {string}
*/
export function orderByToString(orderBy) {
return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", ");
}
/**
* @param {any} string
* @return {OrderTerm[]}
*/
export function stringToOrderBy(string) {
if (!string) {
return [];
}
return string.split(",").map((order) => {
const splitOrder = order.trim().split(" ");
if (splitOrder.length === 2) {
return {
name: splitOrder[0],
asc: splitOrder[1].toLowerCase() === "asc",
};
} else {
return {
name: splitOrder[0],
asc: true,
};
}
});
}
File diff suppressed because it is too large Load Diff
@@ -35,7 +35,7 @@ export class CalendarArchParser extends XMLParser {
let isDateHidden = false;
let isTimeHidden = false;
let formViewId = false;
const popoverFields = {};
const popoverFieldNodes = {};
const filtersInfo = {};
this.visitXML(arch, (node) => {
@@ -122,7 +122,7 @@ export class CalendarArchParser extends XMLParser {
"calendar",
jsClass
);
popoverFields[fieldName] = fieldInfo;
popoverFieldNodes[fieldName] = fieldInfo;
const field = fields[fieldName];
if (!node.hasAttribute("invisible") || node.hasAttribute("filters")) {
@@ -193,7 +193,7 @@ export class CalendarArchParser extends XMLParser {
quickCreate,
isDateHidden,
isTimeHidden,
popoverFields,
popoverFieldNodes,
scale,
scales,
showUnusualDays,
@@ -1,11 +1,11 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { evalDomain } from "@web/core/domain";
import { is24HourFormat } from "@web/core/l10n/dates";
import { Field } from "@web/views/fields/field";
import { Record } from "@web/views/record";
import { evalDomain } from "@web/views/utils";
import { getFormattedDateSpan } from '@web/views/calendar/utils';
import { getFormattedDateSpan } from "@web/views/calendar/utils";
import { Component } from "@odoo/owl";
@@ -19,6 +19,9 @@ export class CalendarCommonPopover extends Component {
this.computeDateTimeAndDuration();
}
get activeFields() {
return this.props.model.popoverFields;
}
get isEventEditable() {
return true;
}
@@ -29,9 +32,8 @@ export class CalendarCommonPopover extends Component {
return this.isEventEditable || this.isEventDeletable;
}
isInvisible(fieldName, record) {
const { invisible } = this.props.model.popoverFields[fieldName].modifiers;
return evalDomain(invisible, record.evalContext);
isInvisible(fieldNode, record) {
return evalDomain(fieldNode.modifiers.invisible, record.evalContext);
}
computeDateTimeAndDuration() {
@@ -44,10 +44,10 @@
</li>
</ul>
<ul class="list-group list-group-flush o_cw_popover_fields_secondary">
<Record resModel="props.model.resModel" resId="props.record.id" fields="props.model.fields" activeFields="props.model.popoverFields" mode="'readonly'" values="props.record.rawRecord" t-slot-scope="slot">
<t t-foreach="slot.record.fieldNames" t-as="fieldName" t-key="fieldName">
<t t-if="!isInvisible(fieldName, slot.record)">
<t t-set="fieldInfo" t-value="props.model.popoverFields[fieldName]" />
<Record resModel="props.model.resModel" resId="props.record.id" fields="props.model.fields" activeFields="activeFields" mode="'readonly'" values="props.record.rawRecord" t-slot-scope="slot">
<t t-foreach="Object.keys(props.model.popoverFieldNodes)" t-as="fieldId" t-key="fieldId">
<t t-set="fieldInfo" t-value="props.model.popoverFieldNodes[fieldId]"/>
<t t-if="!isInvisible(fieldInfo, slot.record)">
<li class="list-group-item flex-shrink-0 d-flex align-items-start" t-att-class="fieldInfo.attrs.class">
<strong class="me-2">
<t t-if="fieldInfo.options.icon">
@@ -60,7 +60,7 @@
</t>
</strong>
<div class="flex-grow-1">
<Field name="fieldName" record="slot.record" fieldInfo="fieldInfo" type="fieldInfo.widget" />
<Field name="fieldInfo.name" record="slot.record" fieldInfo="fieldInfo" type="fieldInfo.widget" />
</div>
</li>
</t>
@@ -5,7 +5,7 @@ import { _t, _lt } from "@web/core/l10n/translation";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { Layout } from "@web/search/layout";
import { useModel } from "@web/views/model";
import { useModel } from "@web/model/model";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { useSetupView } from "@web/views/view_hook";
import { CalendarDatePicker } from "./date_picker/calendar_date_picker";
@@ -10,7 +10,8 @@ import { localization } from "@web/core/l10n/localization";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { Model } from "@web/views/model";
import { Model } from "@web/model/model";
import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
export class CalendarModel extends Model {
setup(params, services) {
@@ -22,8 +23,12 @@ export class CalendarModel extends Model {
const formViewFromConfig = (this.env.config.views || []).find((view) => view[1] === "form");
const formViewIdFromConfig = formViewFromConfig ? formViewFromConfig[0] : false;
const fieldNodes = params.popoverFieldNodes;
const { activeFields, fields } = extractFieldsFromArchInfo({ fieldNodes }, params.fields);
this.meta = {
...params,
popoverFields: activeFields,
fields,
firstDayOfWeek: localization.weekStart,
formViewId: params.formViewId || formViewIdFromConfig,
};
@@ -110,6 +115,9 @@ export class CalendarModel extends Model {
get isTimeHidden() {
return this.meta.isTimeHidden;
}
get popoverFieldNodes() {
return this.meta.popoverFieldNodes;
}
get popoverFields() {
return this.meta.popoverFields;
}
+16 -24
View File
@@ -2,12 +2,12 @@
import { _lt } from "@web/core/l10n/translation";
import { Dialog } from "@web/core/dialog/dialog";
import { evalDomain } from "@web/core/domain";
import { editModelDebug } from "@web/core/debug/debug_utils";
import { formatDateTime, deserializeDateTime } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { formatMany2one } from "@web/views/fields/formatters";
import { evalDomain } from "@web/views/utils";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { Component, onWillStart, useState, xml } from "@odoo/owl";
@@ -199,16 +199,12 @@ class SetDefaultDialog extends Component {
condition: "",
scope: "self",
};
const root = this.props.component.model.root;
this.fields = root.fields;
this.fieldsInfo = root.activeFields;
this.fieldNamesInView = root.fieldNames;
this.fields = this.props.record.fields;
this.activeFields = this.props.record.activeFields;
this.fieldNamesInView = this.props.record.fieldNames;
this.fieldNamesBlackList = ["message_attachment_count"];
this.fieldsValues = root.data;
this.fieldsValues = this.props.record.data;
this.modifierDatas = {};
this.fieldNamesInView.forEach((fieldName) => {
this.modifierDatas[fieldName] = this.fieldsInfo[fieldName].modifiers;
});
this.defaultFields = this.getDefaultFields();
this.conditions = this.getConditions();
}
@@ -217,26 +213,22 @@ class SetDefaultDialog extends Component {
return this.fieldNamesInView
.filter((fieldName) => !this.fieldNamesBlackList.includes(fieldName))
.map((fieldName) => {
const modifierData = this.modifierDatas[fieldName];
let invisibleOrReadOnly;
if (modifierData) {
const evalContext = this.props.component.model.root.evalContext;
invisibleOrReadOnly =
evalDomain(modifierData.invisible, evalContext) ||
evalDomain(modifierData.readonly, evalContext);
}
const fieldInfo = this.fields[fieldName];
const valueDisplayed = this.display(fieldInfo, this.fieldsValues[fieldName]);
const value = valueDisplayed[0];
const displayed = valueDisplayed[1];
const evalContext = this.props.record.evalContext;
// ignore fields which are empty, invisible, readonly, o2m or m2m
if (
!value ||
invisibleOrReadOnly ||
evalDomain(this.activeFields[fieldName].invisible, evalContext) ||
evalDomain(this.activeFields[fieldName].readonly, evalContext) ||
fieldInfo.type === "one2many" ||
fieldInfo.type === "many2many" ||
fieldInfo.type === "binary" ||
this.fieldsInfo[fieldName].options.isPassword
Object.entries(this.props.fieldNodes)
.filter(([key, value]) => value.name === fieldName)
.some(([key, value]) => value.options.isPassword)
) {
return false;
}
@@ -292,7 +284,7 @@ class SetDefaultDialog extends Component {
return field.name === this.state.fieldToSet;
}).value;
await this.orm.call("ir.default", "set", [
this.props.resModel,
this.props.record.resModel,
this.state.fieldToSet,
fieldToSet,
this.state.scope === "self",
@@ -305,8 +297,8 @@ class SetDefaultDialog extends Component {
SetDefaultDialog.template = "web.DebugMenu.SetDefaultDialog";
SetDefaultDialog.components = { Dialog };
SetDefaultDialog.props = {
resModel: { type: String },
component: { type: Component },
record: { type: Object },
fieldNodes: { type: Object },
close: { type: Function },
};
@@ -316,8 +308,8 @@ export function setDefaults({ component, env }) {
description: env._t("Set Defaults"),
callback: () => {
env.services.dialog.add(SetDefaultDialog, {
resModel: component.props.resModel,
component,
record: component.model.root,
fieldNodes: component.props.archInfo.fieldNodes,
});
},
sequence: 310,
@@ -3,11 +3,12 @@
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { effect } from "@web/core/utils/reactive";
import { formatText } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
import { CodeEditor } from "@web/core/code_editor/code_editor";
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
import { useDebounced } from "@web/core/utils/timing";
export class AceField extends Component {
@@ -23,7 +24,7 @@ export class AceField extends Component {
setup() {
this.cookies = useService("cookie");
this.debouncedCommit = useDebounced(this.commitChanges, 10);
this.debouncedCommit = useDebounced(this.commitChanges, 5000);
this.state = useState({
onChange: (value) => this.handleChange(value),
@@ -31,8 +32,17 @@ export class AceField extends Component {
theme: this.theme,
});
this.updateCodeEditor(this.props);
onWillUpdateProps(this.updateCodeEditor);
this.lastSetValue = false;
effect(
async ({ record, mode, name, readonly }) => {
if (this.lastSetValue !== record.data[name]) {
this.state.value = formatText(record.data[name]);
}
this.state.mode = mode === "xml" ? "qweb" : mode;
this.state.readonly = readonly;
},
[this.props]
);
const { model } = this.props.record;
useBus(model.bus, "WILL_SAVE_URGENTLY", () => this.commitChanges());
@@ -46,21 +56,17 @@ export class AceField extends Component {
}
handleChange(newValue) {
this.props.record.model.bus.trigger("FIELD_IS_DIRTY", true);
this.state.value = newValue;
this.debouncedCommit();
}
updateCodeEditor({ record, mode, readonly }) {
this.state.value = formatText(record.data[this.props.name]);
this.state.mode = mode === "xml" ? "qweb" : mode;
this.state.readonly = readonly;
}
commitChanges() {
async commitChanges() {
if (!this.props.readonly) {
const value = this.state.value;
if (this.props.record.data[this.props.name] !== value) {
return this.props.record.update({ [this.props.name]: value });
this.lastSetValue = value;
await this.props.record.update({ [this.props.name]: value });
}
}
}
@@ -1,24 +1,32 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useSpecialData } from "@web/views/fields/relational_utils";
import { standardFieldProps } from "../standard_field_props";
export class BadgeSelectionField extends Component {
static template = "web.BadgeSelectionField";
static props = {
...standardFieldProps,
domain: { type: Array, optional: true },
};
setup() {
this.type = this.props.record.fields[this.props.name].type;
if (this.type === "many2one") {
this.specialData = useSpecialData((orm, props) => {
const { relation } = props.record.fields[props.name];
return orm.call(relation, "name_search", ["", props.domain]);
});
}
}
get options() {
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "many2one":
// WOWL: conversion needed while we keep using the legacy model
return Object.values(this.props.record.preloadedData[this.props.name]).map((v) => {
return [v.id, v.display_name];
});
return this.specialData.data;
case "selection":
return this.props.record.fields[this.props.name].selection;
default:
@@ -27,7 +35,7 @@ export class BadgeSelectionField extends Component {
}
get string() {
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "many2one":
return this.props.record.data[this.props.name]
? this.props.record.data[this.props.name][1]
@@ -42,9 +50,7 @@ export class BadgeSelectionField extends Component {
}
get value() {
const rawValue = this.props.record.data[this.props.name];
return this.props.record.fields[this.props.name].type === "many2one" && rawValue
? rawValue[0]
: rawValue;
return this.type === "many2one" && rawValue ? rawValue[0] : rawValue;
}
stringify(value) {
@@ -55,7 +61,7 @@ export class BadgeSelectionField extends Component {
* @param {string | number | false} value
*/
onChange(value) {
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "many2one":
if (value === false) {
this.props.record.update({ [this.props.name]: false });
@@ -77,17 +83,9 @@ export const badgeSelectionField = {
displayName: _lt("Badges"),
supportedTypes: ["many2one", "selection"],
isEmpty: (record, fieldName) => record.data[fieldName] === false,
legacySpecialData: "_fetchSpecialMany2ones",
extractProps: (fieldInfo, dynamicInfo) => ({
domain: dynamicInfo.domain(),
}),
};
registry.category("fields").add("selection_badge", badgeSelectionField);
export function preloadSelection(orm, record, fieldName, { domain }) {
const field = record.fields[fieldName];
return orm.call(field.relation, "name_search", ["", domain]);
}
registry.category("preloadedData").add("selection_badge", {
loadOnTypes: ["many2one"],
preload: preloadSelection,
});
@@ -8,7 +8,7 @@ import { standardFieldProps } from "../standard_field_props";
import { FileUploader } from "../file_handler";
import { _lt } from "@web/core/l10n/translation";
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
import { Component } from "@odoo/owl";
export class BinaryField extends Component {
static template = "web.BinaryField";
static components = {
@@ -25,20 +25,17 @@ export class BinaryField extends Component {
setup() {
this.notification = useService("notification");
this.state = useState({
fileName: this.props.record.data[this.props.fileNameField] || "",
});
onWillUpdateProps((nextProps) => {
this.state.fileName = nextProps.record.data[nextProps.fileNameField] || "";
});
}
get fileName() {
return this.state.fileName || this.props.record.data[this.props.name] || "";
return (
this.props.record.data[this.props.fileNameField] ||
this.props.record.data[this.props.name] ||
""
);
}
update({ data, name }) {
this.state.fileName = name || "";
const { fileNameField, record } = this.props;
const changes = { [this.props.name]: data || false };
if (fileNameField in record.fields && record.data[fileNameField] !== name) {
@@ -56,7 +56,7 @@
<t t-elif="props.record.resId and props.record.data[props.name]">
<a class="o_form_uri" href="#" t-on-click.prevent="onFileDownload">
<span class="fa fa-download me-2" />
<t t-if="state.fileName" t-esc="state.fileName" />
<t t-if="fileName" t-esc="fileName" />
</a>
</t>
</t>
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
@@ -10,14 +10,8 @@ export class ColorField extends Component {
...standardFieldProps,
};
setup() {
this.state = useState({
color: this.props.record.data[this.props.name] || "",
});
onWillUpdateProps((nextProps) => {
this.state.color = nextProps.record.data[nextProps.name] || "";
});
get color() {
return this.props.record.data[this.props.name] || "";
}
}
@@ -2,8 +2,8 @@
<templates xml:space="preserve">
<t t-name="web.ColorField" owl="1">
<div class="o_field_color d-flex" t-att-class="{ 'o_field_cursor_disabled': props.readonly }" t-attf-style="background: #{state.color or 'url(/web/static/img/transparent.png)'}">
<input t-on-click.stop="" class="w-100 h-100 opacity-0" type="color" t-att-value="state.color" t-att-disabled="props.readonly" t-on-input="(ev) => this.state.color = ev.target.value" t-on-change="(ev) => this.props.record.update({ [this.props.name]: ev.target.value })" />
<div class="o_field_color d-flex" t-att-class="{ 'o_field_cursor_disabled': props.readonly }" t-attf-style="background: #{color or 'url(/web/static/img/transparent.png)'}">
<input t-on-click.stop="" class="w-100 h-100 opacity-0" type="color" t-att-value="color" t-att-disabled="props.readonly" t-on-input="(ev) => this.color = ev.target.value" t-on-change="(ev) => this.props.record.update({ [this.props.name]: ev.target.value })" />
</div>
</t>
@@ -1,8 +1,8 @@
/** @odoo-module **/
import { evalDomain } from "@web/core/domain";
import { _lt } from "@web/core/l10n/translation";
import { evaluateExpr } from "@web/core/py_js/py";
import { evalDomain } from "@web/views/utils";
import { registry } from "@web/core/registry";
import { omit } from "@web/core/utils/objects";
+106 -54
View File
@@ -1,19 +1,17 @@
/** @odoo-module **/
import { makeContext } from "@web/core/context";
import { Domain } from "@web/core/domain";
import { Domain, evalDomain } from "@web/core/domain";
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import {
archParseBoolean,
evalDomain,
getClassNameFromDecoration,
X2M_TYPES,
} from "@web/views/utils";
import { utils } from "@web/core/ui/ui_service";
import { getFieldContext } from "@web/model/relational_model/utils";
import { archParseBoolean, getClassNameFromDecoration, X2M_TYPES } from "@web/views/utils";
import { getTooltipInfo } from "./field_tooltip";
import { Component, xml } from "@odoo/owl";
const isSmall = utils.isSmall;
const viewRegistry = registry.category("views");
const fieldRegistry = registry.category("fields");
@@ -55,11 +53,63 @@ export function fieldVisualFeedback(field, record, fieldName, fieldInfo) {
return {
readonly,
required: evalDomain(modifiers.required, record.evalContext),
invalid: record.isInvalid(fieldName),
invalid: record.isFieldInvalid(fieldName),
empty,
};
}
export function getPropertyFieldInfo(propertyField) {
const { name, relatedPropertyField, string, type } = propertyField;
const fieldInfo = {
name,
string,
type,
widget: type,
options: {},
modifiers: {},
attrs: {},
relatedPropertyField,
// ??? We don t use it ? But it s in the fieldInfo of the field
context: "{}",
help: undefined,
onChange: false,
forceSave: false,
alwaysInvisible: false,
decorations: {},
// ???
};
if (type === "many2one" || type === "many2many") {
const { domain, relation } = propertyField;
fieldInfo.relation = relation;
fieldInfo.domain = domain;
if (relation === "res.users" || relation === "res.partner") {
fieldInfo.widget =
propertyField.type === "many2one" ? "many2one_avatar" : "many2many_tags_avatar";
} else {
fieldInfo.widget = propertyField.type === "many2one" ? type : "many2many_tags";
}
} else if (type === "tags") {
fieldInfo.tags = propertyField.tags;
fieldInfo.widget = `property_tags`;
} else if (type === "selection") {
fieldInfo.selection = propertyField.selection;
}
fieldInfo.field = getFieldFromRegistry(propertyField.type, fieldInfo.widget);
let { relatedFields } = fieldInfo.field;
if (relatedFields) {
if (relatedFields instanceof Function) {
relatedFields = relatedFields({ options: {}, attrs: {} });
}
fieldInfo.relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f]));
}
return fieldInfo;
}
export class Field extends Component {
setup() {
if (this.props.fieldInfo) {
@@ -134,21 +184,7 @@ export class Field extends Component {
}
const dynamicInfo = {
get context() {
const evalContext = record.getEvalContext
? record.getEvalContext(false)
: record.evalContext;
const context = {};
for (const key in record.context) {
if (!key.startsWith("default_") && !key.endsWith("_view_ref")) {
context[key] = record.context[key];
}
}
return {
...context,
...makeContext([fieldInfo.context], evalContext),
};
return getFieldContext(record, fieldInfo.name, fieldInfo.context);
},
domain() {
const evalContext = record.getEvalContext
@@ -256,43 +292,59 @@ Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) {
fieldInfo.attrs[name] = value;
}
}
if (name === "id") {
fieldInfo.modifiers.readonly = true;
}
if (widget === "handle") {
fieldInfo.isHandle = true;
}
if (X2M_TYPES.includes(fields[name].type)) {
const views = {};
for (const child of node.children) {
const viewType = child.tagName === "tree" ? "list" : child.tagName;
const { ArchParser } = viewRegistry.get(viewType);
const xmlSerializer = new XMLSerializer();
const subArch = xmlSerializer.serializeToString(child);
const archInfo = new ArchParser().parse(subArch, models, fields[name].relation);
views[viewType] = {
...archInfo,
fields: models[fields[name].relation],
};
fieldInfo.relatedFields = models[fields[name].relation];
}
let viewMode = node.getAttribute("mode");
if (!viewMode) {
if (views.list && !views.kanban) {
viewMode = "list";
} else if (!views.list && views.kanban) {
viewMode = "kanban";
} else if (views.list && views.kanban) {
viewMode = "list,kanban";
}
} else {
viewMode = viewMode.replace("tree", "list");
}
fieldInfo.viewMode = viewMode;
fieldInfo.views = views;
let relatedFields = field.relatedFields;
let relatedFields = fieldInfo.field.relatedFields;
if (relatedFields) {
if (relatedFields instanceof Function) {
relatedFields = relatedFields(fieldInfo);
}
fieldInfo.relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f]));
relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f]));
views.default = { fieldNodes: relatedFields, fields: relatedFields };
fieldInfo.viewMode = "default";
} else {
for (const child of node.children) {
const viewType = child.tagName === "tree" ? "list" : child.tagName;
const { ArchParser } = viewRegistry.get(viewType);
const xmlSerializer = new XMLSerializer();
const subArch = xmlSerializer.serializeToString(child);
const archInfo = new ArchParser().parse(subArch, models, fields[name].relation);
views[viewType] = {
...archInfo,
limit: archInfo.limit || 40,
fields: models[fields[name].relation],
};
}
let viewMode = node.getAttribute("mode");
if (!viewMode) {
if (views.list && !views.kanban) {
viewMode = "list";
} else if (!views.list && views.kanban) {
viewMode = "kanban";
} else if (views.list && views.kanban) {
viewMode = isSmall() ? "kanban" : "list";
}
} else {
if (viewMode.split(",").length !== 1) {
viewMode = isSmall() ? "kanban" : "list";
} else {
viewMode = viewMode === "tree" ? "list" : viewMode;
}
}
fieldInfo.viewMode = viewMode;
}
if (Object.keys(views).length) {
fieldInfo.relatedFields = models[fields[name].relation];
fieldInfo.views = views;
}
}
@@ -49,7 +49,6 @@ export const fontSelectionField = {
required: dynamicInfo.required,
};
},
legacySpecialData: "_fetchSpecialRelation",
};
registry.category("fields").add("font", fontSelectionField);
@@ -9,7 +9,7 @@ import { isBinarySize } from "@web/core/utils/binary";
import { FileUploader } from "../file_handler";
import { standardFieldProps } from "../standard_field_props";
import { Component, useState, onWillUpdateProps } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
const { DateTime } = luxon;
export const fileTypeMagicWordMap = {
@@ -61,15 +61,10 @@ export class ImageField extends Component {
this.state = useState({
isValid: true,
});
}
this.rawCacheKey = this.props.record.data.write_date;
onWillUpdateProps((nextProps) => {
const { record } = this.props;
const { record: nextRecord } = nextProps;
if (record.resId !== nextRecord.resId || nextRecord.mode === "readonly") {
this.rawCacheKey = nextRecord.data.write_date;
}
});
get rawCacheKey() {
return this.props.record.data.write_date;
}
get sizeStyle() {
@@ -103,9 +98,6 @@ export class ImageField extends Component {
getUrl(previewFieldName) {
if (this.state.isValid && this.props.record.data[this.props.name]) {
if (isBinarySize(this.props.record.data[this.props.name])) {
if (!this.rawCacheKey) {
this.rawCacheKey = this.props.record.data.write_date;
}
return url("/web/image", {
model: this.props.record.resModel,
id: this.props.record.resId,
@@ -127,8 +119,6 @@ export class ImageField extends Component {
}
async onFileUploaded(info) {
this.state.isValid = true;
// Invalidate the `rawCacheKey`.
this.rawCacheKey = null;
if (info.type === 'image/webp') {
// Generate alternate sizes and format for reports.
const image = document.createElement('img');
@@ -113,7 +113,11 @@ export function useInputField(params) {
* If it is not such a case, we update the field with the new value.
*/
useEffect(() => {
if (inputRef.el && !isDirty && !component.props.record.isInvalid(component.props.name)) {
if (
inputRef.el &&
!isDirty &&
!component.props.record.isFieldInvalid(component.props.name)
) {
inputRef.el.value = params.getValue();
lastSetValue = inputRef.el.value;
}
@@ -154,10 +158,9 @@ export function useInputField(params) {
}
if ((val || false) !== (component.props.record.data[component.props.name] || false)) {
const nextValue = inputRef.el.value;
lastSetValue = inputRef.el.value;
await component.props.record.update({ [component.props.name]: val });
component.props.record.model.bus.trigger("FIELD_IS_DIRTY", false);
lastSetValue = nextValue;
}
}
}
@@ -30,7 +30,12 @@ export class Many2ManyBinaryField extends Component {
return this.props.record.fields[this.props.name].string;
}
get files() {
return this.props.record.data[this.props.name].records.map((record) => record.data);
return this.props.record.data[this.props.name].records.map((record) => {
return {
...record.data,
id: record.resId,
};
});
}
getUrl(id) {
@@ -55,7 +60,7 @@ export class Many2ManyBinaryField extends Component {
async onFileRemove(deleteId) {
const record = this.props.record.data[this.props.name].records.find(
(record) => record.data.id === deleteId
(record) => record.resId === deleteId
);
this.operations.removeRecord(record);
}
@@ -1,21 +1,29 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useSpecialData } from "@web/views/fields/relational_utils";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class Many2ManyCheckboxesField extends Component {
static template = "web.Many2ManyCheckboxesField";
static components = { CheckBox };
static props = {
...standardFieldProps,
domain: { type: Array, optional: true },
};
setup() {
this.specialData = useSpecialData((orm, props) => {
const { relation } = props.record.fields[props.name];
return orm.call(relation, "name_search", ["", props.domain]);
});
}
get items() {
return this.props.record.preloadedData[this.props.name];
return this.specialData.data;
}
isSelected(item) {
@@ -44,7 +52,11 @@ export const many2ManyCheckboxesField = {
displayName: _lt("Checkboxes"),
supportedTypes: ["many2many"],
isEmpty: () => false,
legacySpecialData: "_fetchSpecialRelation",
extractProps(fieldInfo, dynamicInfo) {
return {
domain: dynamicInfo.domain(),
};
},
};
registry.category("fields").add("many2many_checkboxes", many2ManyCheckboxesField);
@@ -53,8 +65,3 @@ export function preloadMany2ManyCheckboxes(orm, record, fieldName, { domain }) {
const field = record.fields[fieldName];
return orm.call(field.relation, "name_search", ["", domain]);
}
registry.category("preloadedData").add("many2many_checkboxes", {
loadOnTypes: ["many2many"],
preload: preloadMany2ManyCheckboxes,
});
@@ -271,7 +271,6 @@ export const many2ManyTagsField = {
},
],
supportedTypes: ["many2many"],
isSet: (value) => value.count > 0,
relatedFields: ({ options }) => {
const relatedFields = [{ name: "display_name", type: "char" }];
if (options.color_field) {
@@ -106,11 +106,9 @@ export class Many2OneField extends Component {
const resId = this.value[0];
const fields = ["display_name"];
const { context } = this.props;
// use unity read + relatedFields from Field Component
const records = await this.orm.read(this.relation, [resId], fields, { context });
await this.updateRecord(m2oTupleFromData(records[0]));
if (this.props.record.model.root.id !== this.props.record.id) {
this.props.record.switchMode("readonly");
}
},
onClose: () => this.focusInput(),
fieldString: this.string,
@@ -6,9 +6,8 @@ import { useNumpadDecimal } from "../numpad_decimal_hook";
import { parseFloat } from "../parsers";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillUpdateProps, useRef, useState } from "@odoo/owl";
import { Component, useRef, useState } from "@odoo/owl";
const formatters = registry.category("formatters");
const parsers = registry.category("parsers");
export class ProgressBarField extends Component {
static template = "web.ProgressBarField";
@@ -28,76 +27,66 @@ export class ProgressBarField extends Component {
this.root = useRef("numpadDecimal");
this.maxValueRef = useRef("maxValue");
this.currentValueRef = useRef("currentValue");
const { currentValueField, maxValueField, name } = this.props;
this.currentValueField = currentValueField ? currentValueField : name;
if (maxValueField) {
this.maxValueField = maxValueField;
}
this.state = useState({
currentValue: this.getCurrentValue(this.props),
maxValue: this.getMaxValue(this.props),
isEditing: false,
});
onWillUpdateProps((nextProps) => {
Object.assign(this.state, {
currentValue: this.getCurrentValue(nextProps),
maxValue: this.getMaxValue(nextProps),
});
});
}
get isCurrentValueInteger() {
return this.state.currentValue % 1 === 0;
}
get isEditable() {
return this.props.isEditable && !this.props.readonly;
}
get isMaxValueInteger() {
return this.state.maxValue % 1 === 0;
}
get isPercentage() {
return !this.props.maxValueField || !isNaN(this.props.maxValueField);
}
getCurrentValueField(p) {
return typeof p.currentValueField === "string" ? p.currentValueField : p.name;
}
getMaxValueField(p) {
return typeof p.maxValueField === "string" ? p.maxValueField : p.name;
get currentValue() {
return this.props.record.data[this.currentValueField] || 0;
}
getCurrentValue(p) {
return p.record.data[this.getCurrentValueField(p)] || 0;
}
getMaxValue(p) {
if (p.maxValueField) {
return p.record.data[p.maxValueField] || 100;
}
return 100;
get maxValue() {
return this.props.record.data[this.maxValueField] || 100;
}
formatCurrentValue(humanReadable = !this.state.isEditing) {
const formatter = formatters.get(this.isCurrentValueInteger ? "integer" : "float");
return formatter(this.state.currentValue, { humanReadable });
const formatter = formatters.get(Number.isInteger(this.currentValue) ? "integer" : "float");
return formatter(this.currentValue, { humanReadable });
}
formatMaxValue(humanReadable = !this.state.isEditing) {
const formatter = formatters.get(this.isMaxValueInteger ? "integer" : "float");
return formatter(this.state.maxValue, { humanReadable });
const formatter = formatters.get(Number.isInteger(this.maxValue) ? "integer" : "float");
return formatter(this.maxValue, { humanReadable });
}
onCurrentValueChange(ev) {
onValueChange(value, fieldName) {
let parsedValue;
try {
parsedValue = parseFloat(ev.target.value);
parsedValue = parseFloat(value);
} catch {
this.props.record.setInvalidField(this.props.name);
return;
}
if (this.isCurrentValueInteger) {
if (this.props.record.fields[fieldName].type === "integer") {
parsedValue = Math.floor(parsedValue);
}
this.state.currentValue = parsedValue;
this.props.record.update({ [this.getCurrentValueField(this.props)]: parsedValue });
this.props.record.update({ [fieldName]: parsedValue });
if (this.props.readonly) {
this.props.record.save();
}
}
onCurrentValueChange(ev) {
this.onValueChange(ev.target.value, this.currentValueField);
}
onMaxValueChange(ev) {
this.onValueChange(ev.target.value, this.maxValueField);
}
onInputBlur() {
if (
document.activeElement !== this.maxValueRef.el &&
@@ -109,40 +98,6 @@ export class ProgressBarField extends Component {
onInputFocus() {
this.state.isEditing = true;
}
onMaxValueChange(ev) {
let parsedValue;
try {
parsedValue = parseFloat(ev.target.value);
} catch {
this.props.record.setInvalidField(this.props.name);
return;
}
if (this.isMaxValueInteger) {
parsedValue = Math.floor(parsedValue);
}
this.state.maxValue = parsedValue;
this.props.record.update({ [this.getMaxValueField(this.props)]: parsedValue });
if (this.props.readonly) {
this.props.record.save();
}
}
onCurrentValueInput(ev) {
const parser = parsers.get(this.isCurrentValueInteger ? "integer" : "float");
try {
this.state.currentValue = parser(ev.target.value);
} catch {
// pass
}
}
onMaxValueInput(ev) {
const parser = parsers.get(this.isMaxValueInteger ? "integer" : "float");
try {
this.state.maxValue = parser(ev.target.value);
} catch {
// pass
}
}
}
export const progressBarField = {
@@ -4,8 +4,8 @@
<t t-name="web.ProgressBarField" owl="1">
<div class="o_progressbar w-100 d-flex align-items-center" t-ref="numpadDecimal">
<div t-if="props.title" class="o_progressbar_title text-nowrap pe-1"><t t-esc="props.title"/></div>
<div class="o_progress align-middle overflow-hidden" aria-valuemin="0" t-att-aria-valuemax="state.maxValue" t-att-aria-valuenow="state.currentValue">
<div t-attf-class="{{ state.currentValue > state.maxValue ? props.overflowClass : 'bg-primary' }} h-100" t-att-style="'width: min(' + 100 * state.currentValue / state.maxValue + '%, 100%)'"></div>
<div class="o_progress align-middle overflow-hidden" aria-valuemin="0" t-att-aria-valuemax="this.maxValue" t-att-aria-valuenow="this.currentValue">
<div t-attf-class="{{ this.currentValue > this.maxValue ? props.overflowClass : 'bg-primary' }} h-100" t-att-style="'width: min(' + 100 * this.currentValue / this.maxValue + '%, 100%)'"></div>
</div>
<div class="o_progressbar_value d-flex">
<t t-if="isPercentage">
@@ -18,7 +18,6 @@
t-att-value="formatCurrentValue() or ''"
t-att-required="props.required"
t-on-change="onCurrentValueChange"
t-on-input="onCurrentValueInput"
t-on-focus="onInputFocus"
t-on-blur="onInputBlur"
/>
@@ -34,7 +33,6 @@
inputmode="decimal"
t-att-value="formatCurrentValue()"
t-on-change="onCurrentValueChange"
t-on-input="onCurrentValueInput"
t-on-focus="onInputFocus"
t-on-blur="onInputBlur"
/>
@@ -48,7 +46,6 @@
inputmode="decimal"
t-att-value="formatMaxValue()"
t-on-change="onMaxValueChange"
t-on-input="onMaxValueInput"
t-on-focus="onInputFocus"
t-on-blur="onInputBlur"
/>
@@ -91,13 +91,7 @@ export class PropertiesField extends Component {
* @returns {array}
*/
get propertiesList() {
const propertiesValues = (this.props.record.data[this.props.name] || []).map(property => {
if (["date", "datetime"].includes(property.type)) {
// Date and Datetime are immutable luxon objects.
return property;
}
return JSON.parse(JSON.stringify(property));
});
const propertiesValues = this.props.record.data[this.props.name] || [];
return propertiesValues.filter((definition) => !definition.definition_deleted);
}
@@ -1,10 +1,10 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useSpecialData } from "@web/views/fields/relational_utils";
import { standardFieldProps } from "../standard_field_props";
export class RadioField extends Component {
static template = "web.RadioField";
@@ -12,6 +12,7 @@ export class RadioField extends Component {
...standardFieldProps,
orientation: { type: String, optional: true },
label: { type: String, optional: true },
domain: { type: Array, optional: true },
};
static defaultProps = {
orientation: "vertical",
@@ -19,28 +20,34 @@ export class RadioField extends Component {
static nextId = 0;
static getItems(fieldName, record) {
switch (record.fields[fieldName].type) {
setup() {
this.type = this.props.record.fields[this.props.name].type;
if (this.type === "many2one") {
this.specialData = useSpecialData(async (orm, props) => {
const { relation } = props.record.fields[props.name];
const kwargs = {
specification: { display_name: 1 },
domain: props.domain,
};
const { records } = await orm.call(relation, "unity_web_search_read", [], kwargs);
return records.map((record) => [record.id, record.display_name]);
});
}
}
get items() {
switch (this.type) {
case "selection":
return record.fields[fieldName].selection;
return this.props.record.fields[this.props.name].selection;
case "many2one": {
const value = record.preloadedData[fieldName] || [];
return value.map((item) => [item.id, item.display_name]);
return this.specialData.data;
}
default:
return [];
}
}
setup() {
this.id = `radio_field_${++RadioField.nextId}`;
}
get items() {
return RadioField.getItems(this.props.name, this.props.record);
}
get value() {
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "selection":
return this.props.record.data[this.props.name];
case "many2one":
@@ -56,7 +63,7 @@ export class RadioField extends Component {
* @param {any} value
*/
onChange(value) {
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "selection":
this.props.record.update({ [this.props.name]: value[0] });
break;
@@ -79,22 +86,11 @@ export const radioField = {
],
supportedTypes: ["many2one", "selection"],
isEmpty: (record, fieldName) => record.data[fieldName] === false,
extractProps: ({ options, string }) => ({
extractProps: ({ options, string }, dynamicInfo) => ({
orientation: options.horizontal ? "horizontal" : "vertical",
label: string,
domain: dynamicInfo.domain(),
}),
legacySpecialData: "_fetchSpecialMany2ones",
};
registry.category("fields").add("radio", radioField);
export async function preloadRadio(orm, record, fieldName, { domain }) {
const field = record.fields[fieldName];
const records = await orm.searchRead(field.relation, domain, ["id"]);
return await orm.nameGet(field.relation, [records.map((record) => record.id)]);
}
registry.category("preloadedData").add("radio", {
loadOnTypes: ["many2one"],
preload: preloadRadio,
});
@@ -2,14 +2,39 @@
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useRecordObserver } from "@web/model/relational_model/utils";
import { many2OneField, Many2OneField } from "../many2one/many2one_field";
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
function valuesEqual(a, b) {
return a.resId === b.resId && a.resModel === b.resModel;
}
/**
* @typedef ReferenceValue
* @property {string} resModel
* @property {number} resId
* @property {string} displayName
*/
/**
* 1. Reference field is a char field
* 2. Reference widget has model_field prop
* 3. Standard case
*/
/**
* This class represents a reference field widget. It can be used to display
* a reference field OR a char field.
* The res_model of the relation is defined either by the reference field itself
* or by the model_field prop.
*
* 1) Reference field is a char field
* We have to fetch the display name (name_get) of the referenced record.
*
* 2) Reference widget has model_field prop
* We have to fetch the technical name of the co model.
*
* 3) Standard case
* The value is already in record.data[fieldName]
*/
export class ReferenceField extends Component {
static template = "web.ReferenceField";
static components = {
@@ -17,120 +42,198 @@ export class ReferenceField extends Component {
};
static props = {
...Many2OneField.props,
hideModelSelector: { type: Boolean, optional: true },
hideModel: { type: Boolean, optional: true },
modelField: { type: String, optional: true },
};
static defaultProps = {
...Many2OneField.defaultProps,
};
setup() {
/** @type {{formattedCharValue?: ReferenceValue, modelName?: string}} */
this.state = useState({
resModel: this.relation,
formattedCharValue: undefined, // Value extracted from reference char field
modelName: undefined, // Name get of the value of the model field
});
this.currentValue = this.getValue(this.props);
onWillUpdateProps((nextProps) => {
if (
valuesEqual(this.currentValue || {}, this.getValue(nextProps) || {}) &&
this.state.resModel &&
this.getRelation(nextProps) !== this.state.resModel
) {
nextProps.record.update({ [this.props.name]: false });
}
this.currentValue = this.getValue(this.props);
});
}
getPreloadedData(p) {
return p.record.preloadedData[p.name];
}
getValue(p) {
if (p.record.fields[p.name].type === "char") {
const pdata = this.getPreloadedData(p);
if (!pdata) {
return null;
}
return {
resModel: pdata.model,
resId: pdata.data.id,
displayName: pdata.data.display_name,
};
this.currentValue = undefined;
this.currentRelation = this.getRelation();
if (this._isCharField(this.props)) {
/** Fetch the display name of the record referenced by the field */
useRecordObserver(async (record) => {
if (this.currentValue !== record.data[this.props.name]) {
this.state.formattedCharValue = await this._fetchReferenceCharData(this.props);
this.currentValue = record.data[this.props.name];
}
});
} else if (this.props.modelField) {
/** Fetch the technical name of the co model */
useRecordObserver(async (record) => {
if (this.currentModelId !== record.data[this.props.modelField]?.[0]) {
this.state.modelName = await this._fetchModelTechnicalName(this.props);
if (this.currentModelId !== undefined) {
record.update({ [this.props.name]: false });
}
this.currentModelId = record.data[this.props.modelField]?.[0];
}
});
} else {
return p.record.data[p.name];
this.currentValue = this.props.record.data[this.props.name];
}
}
get m2oProps() {
const value = this.getValue(this.props);
const value = this.getValue();
const p = {
...this.props,
relation: this.relation,
relation: this.getRelation(),
value: value && [value.resId, value.displayName],
update: this.updateM2O.bind(this),
};
delete p.hideModelSelector;
delete p.hideModel;
delete p.modelField;
return p;
}
get selection() {
if (
this.props.record.fields[this.props.name].type !== "char" &&
!this.props.hideModelSelector
) {
if (!this._isCharField(this.props) && !this.hideModelSelector) {
return this.props.record.fields[this.props.name].selection;
}
return [];
}
get relation() {
return this.getRelation(this.props);
return this.getRelation();
}
getRelation(props) {
const modelName = this.getModelName(props);
get hideModelSelector() {
return this.props.hideModel || this.props.modelField;
}
getRelation() {
const modelName = this.getModelName();
if (modelName) {
return modelName;
}
const value = this.getValue(props);
const value = this.getValue();
if (value && value.resModel) {
return value.resModel;
} else {
return this.state && this.state.resModel;
return this.currentRelation;
}
}
getModelName(p) {
if (p.hideModelSelector && p.record.preloadedData[p.name]) {
return p.record.preloadedData[p.name].modelName;
/**
* @returns {ReferenceValue|false}
*/
getValue() {
if (this._isCharField(this.props)) {
return this.state.formattedCharValue;
} else {
return this.props.record.data[this.props.name];
}
return null;
}
/**
* @returns {string|undefined}
*/
getModelName() {
return this.hideModelSelector && this.state.modelName;
}
updateModel(value) {
this.state.resModel = value;
this.currentRelation = value;
this.props.record.update({ [this.props.name]: false });
}
updateM2O(data) {
const value = data[this.props.name];
if (!this.state.resModel) {
this.state.resModel = this.relation;
if (!this.currentRelation) {
this.currentRelation = this.getRelation();
}
this.props.record.update({
[this.props.name]: value && {
resModel: this.state.resModel,
resModel: this.currentRelation,
resId: value[0],
displayName: value[1],
},
});
}
/**
* Return true if the reference field is a char field.
*/
_isCharField(props) {
return props.record.fields[props.name].type === "char";
}
/**
* Fetch special data if the reference field is a char field.
* It fetches the display name of the record.
*
* @returns {Promise<{ resId: number, resModel: string, displayName: string }|false>}
*/
async _fetchReferenceCharData(props) {
const recordData = props.record.data[props.name];
if (!recordData) {
return false;
}
const [resModel, _resId] = recordData.split(",");
const resId = parseInt(_resId, 10);
if (resModel && resId) {
const { specialDataCaches, orm } = props.record.model;
const key = `__reference__name_get-${recordData}`;
if (!specialDataCaches[key]) {
specialDataCaches[key] = orm.nameGet(resModel, [resId]);
}
const result = await specialDataCaches[key];
return {
resId,
resModel,
displayName: result[0][1],
};
}
return false;
}
/**
* Ensure that the modelField is a many2one to ir.model
*/
_assertMany2OneToIrModel(props) {
const field = props.modelField && props.record.fields[props.modelField];
if (field && (field.type !== "many2one" || field.relation !== "ir.model")) {
throw new Error(
`The model_field (${props.modelField}) of the reference field ${props.name} must be a many2one('ir.model').`
);
}
}
/**
* Fetch the technical name of the model which is selected in the modelField
* props
*
* @returns {Promise<string|false>}
*/
async _fetchModelTechnicalName(props) {
this._assertMany2OneToIrModel(props);
const record = props.record;
const modelId = record.data[props.modelField]?.[0];
if (!modelId) {
return false;
}
const { specialDataCaches, orm } = props.record.model;
const key = `__reference__ir_model-${modelId}`;
if (!specialDataCaches[key]) {
specialDataCaches[key] = orm.read("ir.model", [modelId], ["model"]);
}
const result = await specialDataCaches[key];
return result[0].model;
}
}
export const referenceField = {
component: ReferenceField,
displayName: _lt("Reference"),
supportedTypes: ["reference", "char"],
legacySpecialData: "_fetchSpecialReference",
extractProps({ options }) {
/*
1 - <field name="ref" options="{'model_field': 'model_id'}" />
@@ -141,7 +244,8 @@ export const referenceField = {
We want to display the model selector only in the 4th case.
*/
const props = many2OneField.extractProps(...arguments);
props.hideModelSelector = !!options.hide_model || !!options.model_field;
props.hideModel = !!options.hide_model;
props.modelField = options.model_field;
return props;
},
};
@@ -3,7 +3,7 @@
<t t-name="web.ReferenceField" owl="1">
<div class="o_row">
<t t-if="!props.readonly and !props.hideModelSelector">
<t t-if="!props.readonly and !hideModelSelector">
<select class="o_input"
t-att-tabindex="relation ? '-1': 0"
t-on-change="(ev) => this.updateModel(ev.target.value || false)"
@@ -3,6 +3,9 @@
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { makeContext } from "@web/core/context";
import { Dialog } from "@web/core/dialog/dialog";
import { evalDomain } from "@web/core/domain";
import { RPCError } from "@web/core/network/rpc_service";
import { Cache } from "@web/core/utils/cache";
import {
useBus,
useChildRef,
@@ -15,7 +18,8 @@ import { createElement } from "@web/core/utils/xml";
import { FormArchParser } from "@web/views/form/form_arch_parser";
import { loadSubViews } from "@web/views/form/form_controller";
import { FormRenderer } from "@web/views/form/form_renderer";
import { computeViewClassName, evalDomain, isNull } from "@web/views/utils";
import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { computeViewClassName, isNull } from "@web/views/utils";
import { ViewButton } from "@web/views/view_button/view_button";
import { useViewButtons } from "@web/views/view_button/view_button_hook";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
@@ -35,11 +39,12 @@ import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog
import {
Component,
onWillStart,
onWillUpdateProps,
useComponent,
useEffect,
useEnv,
useSubEnv,
onWillUpdateProps,
} from "@odoo/owl";
//
@@ -137,6 +142,30 @@ export function useActiveActions({
return activeActions;
}
export function useSpecialData(loadFn) {
const component = useComponent();
const record = component.props.record;
const key = `${record.resModel}-${component.props.name}`;
const { specialDataCaches, orm } = record.model;
const ormWithCache = Object.create(orm);
if (!specialDataCaches[key]) {
specialDataCaches[key] = new Cache(
(...args) => orm.call(...args),
(...path) => JSON.stringify(path)
);
}
ormWithCache.call = (...args) => specialDataCaches[key].read(...args);
const result = {};
onWillStart(async () => {
result.data = await loadFn(ormWithCache, component.props);
});
onWillUpdateProps(async (props) => {
result.data = await loadFn(ormWithCache, props);
});
return result;
}
//
// Many2X
//
@@ -153,7 +182,7 @@ export class Many2XAutocomplete extends Component {
activeActions,
isToMany,
onRecordSaved: (record) => {
return update([record.data]);
return update([{ ...record.data, id: record.resId }]);
},
onRecordDiscarded: () => {
if (!isToMany) {
@@ -221,7 +250,7 @@ export class Many2XAutocomplete extends Component {
}
const record = {
id: option.value,
name: option.label,
display_name: option.displayName,
};
this.props.update([record], params);
}
@@ -239,6 +268,7 @@ export class Many2XAutocomplete extends Component {
return {
value: result[0],
label: result[1].split("\n")[0],
displayName: result[1],
};
}
async loadOptionsSource(request) {
@@ -259,26 +289,13 @@ export class Many2XAutocomplete extends Component {
await this.props.quickCreate(request, params);
} catch (e) {
if (
e &&
e.name === "RPC_ERROR" &&
e instanceof RPCError &&
e.exceptionName === "odoo.exceptions.ValidationError"
) {
const context = this.getCreationContext(request);
return this.openMany2X({ context });
}
// Compatibility with legacy code
if (
e &&
e.message &&
e.message.name === "RPC_ERROR" &&
e.message.exceptionName === "odoo.exceptions.ValidationError"
) {
// The event.preventDefault() is necessary because we still use the legacy
e.event.preventDefault();
const context = this.getCreationContext(request);
return this.openMany2X({ context });
}
throw e.message ? e.message : e;
throw e;
}
},
});
@@ -573,7 +590,15 @@ export class X2ManyFieldDialog extends Component {
async save({ saveAndNew }) {
const disabledButtons = this.disableButtons();
if (await this.record.checkValidity()) {
this.record = (await this.props.save(this.record, { saveAndNew })) || this.record;
try {
await this.props.save(this.record);
} catch (error) {
this.enableButtons(disabledButtons);
throw error;
}
if (saveAndNew) {
this.record = await this.props.addNew();
}
} else {
this.record.openInvalidFieldsNotification();
this.enableButtons(disabledButtons);
@@ -606,6 +631,7 @@ X2ManyFieldDialog.props = {
archInfo: Object,
close: Function,
record: Object,
addNew: Function,
save: Function,
title: String,
delete: { optional: true },
@@ -615,24 +641,29 @@ X2ManyFieldDialog.props = {
X2ManyFieldDialog.template = "web.X2ManyFieldDialog";
async function getFormViewInfo({ list, activeField, viewService, userService, env }) {
let formViewInfo = activeField.views.form;
let formArchInfo = activeField.views.form;
let fields = activeField.fields;
const comodel = list.resModel;
if (!formViewInfo) {
const { fields, relatedModels, views } = await viewService.loadViews({
if (!formArchInfo) {
const {
fields: formFields,
relatedModels,
views,
} = await viewService.loadViews({
context: list.context,
resModel: comodel,
views: [[false, "form"]],
});
const archInfo = new FormArchParser().parse(views.form.arch, relatedModels, comodel);
formArchInfo = new FormArchParser().parse(views.form.arch, relatedModels, comodel);
// Fields that need to be defined are the ones in the form view, this is natural,
// plus the ones that the list record has, that is, present in either the list arch or the kanban arch
// of the one2many field
formViewInfo = { ...archInfo, fields: { ...list.fields, ...fields } }; // should be good to memorize this on activeField
// plus the ones that the list record has, that is, present in either the list arch
// or the kanban arch of the one2many field
fields = { ...list.fields, ...formFields }; // FIXME: update in place?
}
await loadSubViews(
formViewInfo.activeFields,
formViewInfo.fields,
formArchInfo.fieldNodes,
fields,
{}, // context
comodel,
viewService,
@@ -640,7 +671,7 @@ async function getFormViewInfo({ list, activeField, viewService, userService, en
env.isSmall
);
return formViewInfo;
return { archInfo: formArchInfo, fields };
}
export function useAddInlineRecord({ addNew }) {
@@ -661,12 +692,12 @@ export function useAddInlineRecord({ addNew }) {
export function useOpenX2ManyRecord({
resModel,
activeField,
activeField, // TODO: this should be renamed (object with keys "viewMode", "views" and "string")
activeActions,
getList,
updateRecord,
saveRecord,
withParentId,
isMany2Many,
}) {
const viewService = useService("view");
const userService = useService("user");
@@ -681,78 +712,65 @@ export function useOpenX2ManyRecord({
title = sprintf(title, activeField.string);
}
const list = getList();
const model = list.model;
const form = await getFormViewInfo({ list, activeField, viewService, userService, env });
const { archInfo, fields: _fields } = await getFormViewInfo({
list,
activeField,
viewService,
userService,
env,
});
const { activeFields, fields } = extractFieldsFromArchInfo(archInfo, _fields);
let deleteRecord;
let deleteButtonLabel = undefined;
const isDuplicate = !!record;
const params = { activeFields, fields, mode };
if (record) {
const _record = record;
record = await model.duplicateDatapoint(record, {
mode,
viewMode: "form",
fields: { ...form.fields },
views: { form },
});
const { delete: canDelete, onDelete } = activeActions;
deleteRecord = viewMode === "kanban" && canDelete ? () => onDelete(_record) : null;
deleteRecord = viewMode === "kanban" && canDelete ? () => onDelete(record) : null;
deleteButtonLabel =
activeActions.type === "one2many" ? env._t("Delete") : env._t("Remove");
} else {
const recordParams = {
context: makeContext([list.context, context]),
resModel: resModel,
activeFields: form.activeFields,
fields: { ...form.fields },
views: { form },
mode: "edit",
viewType: "form",
};
record = await model.addNewRecord(list, recordParams, withParentId);
params.context = makeContext([list.context, context]);
params.withoutParent = isMany2Many;
}
record = await list.extendRecord(params, record);
const _onClose = () => {
list.editedRecord?.switchMode("readonly");
onClose?.();
};
addDialog(
X2ManyFieldDialog,
{
config: env.config,
archInfo: form,
archInfo,
record,
save: async (rec, { saveAndNew }) => {
addNew: () => {
return getList().extendRecord(params);
},
save: (rec) => {
if (isDuplicate && rec.id === record.id) {
await updateRecord(rec);
return updateRecord(rec);
} else {
await saveRecord(rec);
}
if (saveAndNew) {
return model.addNewRecord(
list,
{
context: makeContext([list.context, context]),
resModel: resModel,
activeFields: form.activeFields,
fields: { ...form.fields },
views: { form },
mode: "edit",
viewType: "form",
},
withParentId
);
return saveRecord(rec);
}
},
title,
delete: deleteRecord,
deleteButtonLabel: deleteButtonLabel,
},
{ onClose }
{ onClose: _onClose }
);
}
return openRecord;
}
export function useX2ManyCrud(getList, isMany2Many) {
let saveRecord;
let saveRecord; // FIXME: isn't this "createRecord" instead?
if (isMany2Many) {
saveRecord = async (object) => {
const list = getList();
@@ -760,31 +778,34 @@ export function useX2ManyCrud(getList, isMany2Many) {
let resIds;
if (Array.isArray(object)) {
resIds = [...currentIds, ...object];
} else if (object.resId) {
if (object.isDirty) {
await object.save();
} else {
// object instanceof Record
if (!object.resId || object.isDirty) {
await object.save();
}
resIds = [...currentIds, object.resId];
} else {
return list.add(object, { isM2M: isMany2Many });
}
return list.replaceWith(resIds);
};
} else {
saveRecord = (record) => {
return getList().add(record);
saveRecord = async (record) => {
return getList().validateExtendedRecord(record);
};
}
const updateRecord = (record) => {
const list = getList();
return list.model.updateRecord(list, record, { isM2M: isMany2Many });
const updateRecord = async (record) => {
if (isMany2Many) {
await record.save();
}
return getList().validateExtendedRecord(record);
};
const operation = isMany2Many ? "FORGET" : "DELETE";
const removeRecord = (record) => {
const list = getList();
return list.delete(record.id, operation);
if (isMany2Many) {
return list.forget(record);
}
return list.delete(record);
};
return {
@@ -1,10 +1,10 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useSpecialData } from "@web/views/fields/relational_utils";
import { standardFieldProps } from "../standard_field_props";
export class SelectionField extends Component {
static template = "web.SelectionField";
@@ -12,12 +12,23 @@ export class SelectionField extends Component {
...standardFieldProps,
placeholder: { type: String, optional: true },
required: { type: Boolean, optional: true },
domain: { type: Array, optional: true },
};
setup() {
this.type = this.props.record.fields[this.props.name].type;
if (this.type === "many2one") {
this.specialData = useSpecialData((orm, props) => {
const { relation } = props.record.fields[props.name];
return orm.call(relation, "name_search", ["", props.domain]);
});
}
}
get options() {
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "many2one":
return [...this.props.record.preloadedData[this.props.name]];
return [...this.specialData.data];
case "selection":
return this.props.record.fields[this.props.name].selection.filter(
(option) => option[0] !== false && option[1] !== ""
@@ -27,7 +38,7 @@ export class SelectionField extends Component {
}
}
get string() {
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "many2one":
return this.props.record.data[this.props.name]
? this.props.record.data[this.props.name][1]
@@ -42,9 +53,7 @@ export class SelectionField extends Component {
}
get value() {
const rawValue = this.props.record.data[this.props.name];
return this.props.record.fields[this.props.name].type === "many2one" && rawValue
? rawValue[0]
: rawValue;
return this.type === "many2one" && rawValue ? rawValue[0] : rawValue;
}
stringify(value) {
@@ -56,7 +65,7 @@ export class SelectionField extends Component {
*/
onChange(ev) {
const value = JSON.parse(ev.target.value);
switch (this.props.record.fields[this.props.name].type) {
switch (this.type) {
case "many2one":
if (value === false) {
this.props.record.update({ [this.props.name]: false });
@@ -77,24 +86,14 @@ export const selectionField = {
component: SelectionField,
displayName: _lt("Selection"),
supportedTypes: ["many2one", "selection"],
legacySpecialData: "_fetchSpecialRelation",
isEmpty: (record, fieldName) => record.data[fieldName] === false,
extractProps({ attrs }, dynamicInfo) {
return {
placeholder: attrs.placeholder,
required: dynamicInfo.required,
domain: dynamicInfo.domain(),
};
},
};
registry.category("fields").add("selection", selectionField);
export function preloadSelection(orm, record, fieldName, { domain }) {
const field = record.fields[fieldName];
return orm.call(field.relation, "name_search", ["", domain]);
}
registry.category("preloadedData").add("selection", {
loadOnTypes: ["many2one"],
preload: preloadSelection,
});
@@ -5,7 +5,7 @@
* @property {string} [id]
* @property {string} name
* @property {boolean} [readonly]
* @property {import("@web/views/relational_model").Record} record
* @property {import("@web/model/relational_model/record").Record} record
*/
export const standardFieldProps = {
@@ -1,15 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { useCommand } from "@web/core/commands/command_hook";
import { Domain } from "@web/core/domain";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { groupBy } from "@web/core/utils/arrays";
import { escape, sprintf } from "@web/core/utils/strings";
import { Domain } from "@web/core/domain";
import { _lt } from "@web/core/l10n/translation";
import { useSpecialData } from "@web/views/fields/relational_utils";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class StatusBarField extends Component {
static template = "web.StatusBarField";
@@ -25,6 +26,8 @@ export class StatusBarField extends Component {
isDisabled: { type: Boolean, optional: true },
visibleSelection: { type: Array, optional: true },
withCommand: { type: Boolean, optional: true },
foldField: { type: String, optional: true },
domain: { type: typeof Domain, optional: true },
};
static defaultProps = {
visibleSelection: [],
@@ -90,6 +93,25 @@ export class StatusBarField extends Component {
}
);
}
if (this.type === "many2one") {
this.specialData = useSpecialData((orm, props) => {
const { foldField, name: fieldName, record } = props;
const { relation } = record.fields[fieldName];
const fieldNames = ["display_name"];
if (foldField) {
fieldNames.push(foldField);
}
const value = props.record.data[fieldName];
let domain = props.domain;
if (domain.length && value) {
domain = Domain.or([[["id", "=", value[0]]], domain]).toList(
props.record.evalContext
);
}
return orm.searchRead(relation, domain, fieldNames);
});
}
}
get currentName() {
@@ -114,7 +136,7 @@ export class StatusBarField extends Component {
get options() {
switch (this.type) {
case "many2one":
return this.props.record.preloadedData[this.props.name];
return this.specialData.data;
case "selection":
return this.props.record.fields[this.props.name].selection;
default:
@@ -147,7 +169,7 @@ export class StatusBarField extends Component {
return {
id: i.id,
name: i.display_name,
isFolded: i.fold,
isFolded: i[this.props.foldField],
};
});
return items.map((item) => ({
@@ -234,40 +256,16 @@ export const statusBarField = {
],
supportedTypes: ["many2one", "selection"],
isEmpty: (record, fieldName) => record.model.env.isSmall && !record.data[fieldName],
legacySpecialData: "_fetchSpecialStatus",
extractProps: ({ attrs, options, viewType }) => ({
extractProps: ({ attrs, options, viewType }, dynamicInfo) => ({
canCreate: Boolean(attrs.can_create),
canWrite: Boolean(attrs.can_write),
isDisabled: !options.clickable,
visibleSelection:
attrs.statusbar_visible && attrs.statusbar_visible.trim().split(/\s*,\s*/g),
withCommand: viewType === "form",
foldField: options.fold_field,
domain: dynamicInfo.domain(),
}),
};
registry.category("fields").add("statusbar", statusBarField);
export async function preloadStatusBar(orm, record, fieldName, { domain }) {
const fieldNames = ["id", "display_name"];
const foldField = record.activeFields[fieldName].options.fold_field;
if (foldField) {
fieldNames.push(foldField);
}
if (domain.length && record.data[fieldName]) {
domain = Domain.or([[["id", "=", record.data[fieldName][0]]], domain]).toList(
record.evalContext
);
}
const relation = record.fields[fieldName].relation;
return await orm.searchRead(relation, domain, fieldNames);
}
registry.category("preloadedData").add("statusbar", {
loadOnTypes: ["many2one"],
extraMemoizationKey: (record, fieldName) => {
return record.data[fieldName];
},
preload: preloadStatusBar,
});
@@ -18,7 +18,7 @@ export function useTranslationDialog() {
const addDialog = useOwnedDialogs();
async function openTranslationDialog({ record, fieldName }) {
const saved = await record.save({ stayInEdition: true });
const saved = await record.save({ force: true });
if (!saved) {
return;
}
@@ -1,6 +1,7 @@
/** @odoo-module **/
import { makeContext } from "@web/core/context";
import { evalDomain } from "@web/core/domain";
import { _lt } from "@web/core/l10n/translation";
import { Pager } from "@web/core/pager/pager";
import { registry } from "@web/core/registry";
@@ -15,7 +16,7 @@ import {
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { ListRenderer } from "@web/views/list/list_renderer";
import { computeViewClassName, evalDomain } from "@web/views/utils";
import { computeViewClassName } from "@web/views/utils";
import { ViewButton } from "@web/views/view_button/view_button";
import { Component } from "@odoo/owl";
@@ -31,6 +32,7 @@ export class X2ManyField extends Component {
widget: { type: String, optional: true },
crudOptions: { type: Object, optional: true },
string: { type: String, optional: true },
relatedFields: { type: Object, optional: true },
views: { type: Object, optional: true },
domain: { type: [Array, Function], optional: true },
context: { type: Object },
@@ -77,7 +79,7 @@ export class X2ManyField extends Component {
});
this.addInLine = useAddInlineRecord({
addNew: (...args) => this.list.addNew(...args),
addNew: (...args) => this.list.addNewRecord(...args),
});
const openRecord = useOpenX2ManyRecord({
@@ -87,7 +89,7 @@ export class X2ManyField extends Component {
getList: () => this.list,
saveRecord,
updateRecord,
withParentId: this.props.widget !== "many2many",
isMany2Many: this.isMany2Many,
});
this._openRecord = (params) => {
const activeElement = document.activeElement;
@@ -126,6 +128,7 @@ export class X2ManyField extends Component {
get activeField() {
return {
fields: this.props.relatedFields,
views: this.props.views,
viewMode: this.props.viewMode,
string: this.props.string,
@@ -152,7 +155,7 @@ export class X2ManyField extends Component {
return {
field: this.props.name,
model: this.props.record.resModel,
viewMode: this.props.record.viewMode || this.props.record.__viewType,
viewMode: "form",
};
}
@@ -164,8 +167,8 @@ export class X2ManyField extends Component {
total: list.count,
onUpdate: async ({ offset, limit }) => {
const initialLimit = this.list.limit;
const unselected = await list.unselectRecord(true);
if (unselected) {
const leaved = await list.leaveEditMode();
if (leaved) {
if (initialLimit === limit && initialLimit === this.list.limit + 1) {
// Unselecting the edited record might have abandonned it. If the page
// size was reached before that record was created, the limit was temporarily
@@ -195,6 +198,13 @@ export class X2ManyField extends Component {
const recordsDraggable = !this.props.readonly && archInfo.recordsDraggable;
props.archInfo = { ...archInfo, recordsDraggable };
props.readonly = this.props.readonly;
// TODO: apply same logic in the list case
props.deleteRecord = (record) => {
if (this.isMany2Many) {
return this.list.forget(record);
}
return this.list.delete(record);
};
return props;
}
@@ -256,9 +266,7 @@ export class X2ManyField extends Component {
const proms = [];
this.list.model.bus.trigger("NEED_LOCAL_CHANGES", { proms });
await Promise.all([...proms, this.list.editedRecord._updatePromise]);
if (this.list.editedRecord) {
await this.list.editedRecord.switchMode("readonly", { checkValidity: true });
}
await this.list.leaveEditMode({ canAbandon: false });
}
if (!this.list.editedRecord) {
return this.addInLine({ context, editable });
@@ -280,7 +288,10 @@ export const x2ManyField = {
displayName: _lt("Relational table"),
supportedTypes: ["one2many", "many2many"],
useSubView: true,
extractProps: ({ attrs, viewMode, views, widget, options, string }, dynamicInfo) => {
extractProps: (
{ attrs, relatedFields, viewMode, views, widget, options, string },
dynamicInfo
) => {
const props = {
addLabel: attrs["add-label"],
context: dynamicInfo.context,
@@ -291,6 +302,7 @@ export const x2ManyField = {
if (viewMode) {
props.views = views;
props.viewMode = viewMode;
props.relatedFields = relatedFields;
}
if (widget) {
props.widget = widget;
@@ -3,7 +3,7 @@
import { XMLParser } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
import { Widget } from "@web/views/widgets/widget";
import { addFieldDependencies, archParseBoolean, getActiveActions } from "@web/views/utils";
import { archParseBoolean, getActiveActions } from "@web/views/utils";
export class FormArchParser extends XMLParser {
parse(arch, models, modelName) {
@@ -16,7 +16,6 @@ export class FormArchParser extends XMLParser {
let widgetNextId = 0;
const fieldNextIds = {};
let autofocusFieldId = null;
const activeFields = {};
this.visitXML(xmlDoc, (node) => {
if (node.tagName === "field") {
const fieldInfo = Field.parseFieldNode(node, models, modelName, "form", jsClass);
@@ -29,7 +28,6 @@ export class FormArchParser extends XMLParser {
if (archParseBoolean(node.getAttribute("default_focus") || "")) {
autofocusFieldId = fieldId;
}
addFieldDependencies(fieldInfo, activeFields, models[modelName]);
return false;
} else if (node.tagName === "div" && node.classList.contains("oe_chatter")) {
// remove this when chatter fields are declared as attributes on the root node
@@ -39,38 +37,11 @@ export class FormArchParser extends XMLParser {
const widgetId = `widget_${++widgetNextId}`;
widgetNodes[widgetId] = widgetInfo;
node.setAttribute("widget_id", widgetId);
addFieldDependencies(widgetInfo, activeFields, models[modelName]);
}
});
// 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;
}
// const { onChange, modifiers } = fieldNode;
// let readonly = modifiers.readonly || [];
// let required = modifiers.required || [];
// if (activeFields[fieldNode.name]) {
// activeFields[fieldNode.name].readonly = Domain.combine([activeFields[fieldNode.name].readonly, readonly], "|");
// activeFields[fieldNode.name].required = Domain.combine([activeFields[fieldNode.name].required, required], "|");
// activeFields[fieldNode.name].onChange = activeFields[fieldNode.name].onChange || onChange;
// } else {
// activeFields[fieldNode.name] = { readonly, required, onChange };
// }
}
return {
arch,
activeActions,
activeFields,
autofocusFieldId,
disableAutofocus,
fieldNodes,
@@ -166,7 +166,7 @@ export class FormCompiler extends ViewCompiler {
append(buttonBox, mainSlot);
}
return hasContent ? buttonBox : '';
return hasContent ? buttonBox : "";
}
compileButton(el, params) {
@@ -214,7 +214,7 @@ export class FormCompiler extends ViewCompiler {
? `d-flex {{ __comp__.uiService.size < ${SIZES.XXL} ? "flex-column" : "flex-nowrap h-100" }}`
: "d-block";
const stateClasses =
"{{ __comp__.props.record.isDirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}";
"{{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}";
const form = createElement("div", {
class: "o_form_renderer",
"t-att-class": "__comp__.props.class",
@@ -3,6 +3,7 @@
import { hasTouch } from "@web/core/browser/feature_detection";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { makeContext } from "@web/core/context";
import { evalDomain } from "@web/core/domain";
import { useDebugCategory } from "@web/core/debug/debug_context";
import { registry } from "@web/core/registry";
import { SIZES } from "@web/core/ui/ui_service";
@@ -11,27 +12,36 @@ import { omit } from "@web/core/utils/objects";
import { createElement } from "@web/core/utils/xml";
import { Layout } from "@web/search/layout";
import { usePager } from "@web/search/pager_hook";
import { useModel } from "@web/views/model";
import { standardViewProps } from "@web/views/standard_view_props";
import { isX2Many } from "@web/views/utils";
import { useViewButtons } from "@web/views/view_button/view_button_hook";
import { useSetupView } from "@web/views/view_hook";
import { FormStatusIndicator } from "./form_status_indicator/form_status_indicator";
import { ButtonBox } from "./button_box/button_box";
import { ViewButton } from "@web/views/view_button/view_button";
import { Field } from "@web/views/fields/field";
import { addFieldDependencies, extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { useViewCompiler } from "@web/views/view_compiler";
import { CogMenu } from "@web/search/cog_menu/cog_menu";
import { STATIC_ACTIONS_GROUP_NUMBER } from "@web/search/action_menus/action_menus";
import { Component, onRendered, useEffect, useRef } from "@odoo/owl";
import { useViewCompiler } from "../view_compiler";
import { ButtonBox } from "./button_box/button_box";
import { FormCompiler } from "./form_compiler";
import { evalDomain } from "../utils";
import { FormErrorDialog } from "./form_error_dialog/form_error_dialog";
import { FormStatusIndicator } from "./form_status_indicator/form_status_indicator";
import {
Component,
onRendered,
onWillStart,
onWillUpdateProps,
useEffect,
useRef,
useState,
} from "@odoo/owl";
const viewRegistry = registry.category("views");
export async function loadSubViews(
activeFields,
fieldNodes,
fields,
context,
resModel,
@@ -39,20 +49,20 @@ export async function loadSubViews(
userService,
isSmall
) {
for (const fieldName in activeFields) {
for (const fieldInfo of Object.values(fieldNodes)) {
const fieldName = fieldInfo.name;
const field = fields[fieldName];
if (!isX2Many(field)) {
continue; // what follows only concerns x2many fields
}
const fieldInfo = activeFields[fieldName];
if (fieldInfo.modifiers.invisible === true) {
if (fieldInfo.alwaysInvisible) {
continue; // no need to fetch the sub view if the field is always invisible
}
if (!fieldInfo.field.useSubView) {
continue; // the FieldComponent used to render the field doesn't need a sub view
}
fieldInfo.views = fieldInfo.views || {};
let viewType = fieldInfo.viewMode || "list,kanban";
viewType = viewType.replace("tree", "list");
if (viewType.includes(",")) {
@@ -94,7 +104,11 @@ export async function loadSubViews(
});
const { ArchParser } = viewRegistry.get(viewType);
const archInfo = new ArchParser().parse(views[viewType].arch, relatedModels, comodel);
fieldInfo.views[viewType] = { ...archInfo, fields: comodelFields };
fieldInfo.views[viewType] = {
...archInfo,
limit: archInfo.limit || 40,
fields: comodelFields,
};
fieldInfo.relatedFields = comodelFields;
}
}
@@ -111,46 +125,45 @@ export class FormController extends Component {
useBus(this.ui.bus, "resize", this.render);
this.archInfo = this.props.archInfo;
const activeFields = this.archInfo.activeFields;
const { create, edit } = this.archInfo.activeActions;
this.canCreate = create && !this.props.preventCreate;
this.canEdit = edit && !this.props.preventEdit;
let mode = this.props.mode || "edit";
if (!this.canEdit) {
mode = "readonly";
}
this.model = useModel(
this.props.Model,
{
resModel: this.props.resModel,
resId: this.props.resId || false,
resIds: this.props.resIds,
fields: this.props.fields,
activeFields,
viewMode: "form",
rootType: "record",
mode,
component: this,
onRecordSaved: this.onRecordSaved.bind(this),
onWillSaveRecord: this.onWillSaveRecord.bind(this),
},
{
ignoreUseSampleModel: true,
onWillStart: () =>
loadSubViews(
this.archInfo.activeFields,
this.props.fields,
this.props.context,
this.props.resModel,
this.viewService,
this.user,
this.env.isSmall
),
}
const modelServices = Object.fromEntries(
this.props.Model.services.map((servName) => {
return [servName, useService(servName)];
})
);
this.model = useState(new this.props.Model(this.env, this.modelParams, modelServices));
onWillStart(async () => {
await loadSubViews(
this.archInfo.fieldNodes,
this.props.fields,
this.props.context,
this.props.resModel,
this.viewService,
this.user,
this.env.isSmall
);
const { activeFields, fields } = extractFieldsFromArchInfo(
this.archInfo,
this.props.fields
);
if (this.props.display.controlPanel) {
addFieldDependencies(activeFields, fields, [
{ name: "display_name", type: "char", readonly: true },
]);
}
this.model.config.activeFields = activeFields;
this.model.config.fields = fields;
return this.model.load();
});
onWillUpdateProps((nextProps) => {
if (nextProps.resId !== this.model.root.resId) {
return this.model.root.load(nextProps.resId);
}
});
this.cpButtonsRef = useRef("cpButtons");
@@ -165,14 +178,6 @@ export class FormController extends Component {
}
});
// enable the archive feature in Actions menu only if the active field is in the view
this.archiveEnabled =
"active" in activeFields
? !this.props.fields.active.readonly
: "x_active" in activeFields
? !this.props.fields.x_active.readonly
: false;
// select footers that are not in subviews and move them to another arch
// that will be moved to the dialog's footer (if we are in a dialog)
const footers = [...this.archInfo.xmlDoc.querySelectorAll("footer:not(field footer)")];
@@ -214,9 +219,9 @@ export class FormController extends Component {
beforeLeave: () => this.beforeLeave(),
beforeUnload: (ev) => this.beforeUnload(ev),
getLocalState: () => {
// TODO: export the whole model?
return {
activeNotebookPages: !this.model.root.isNew ? activeNotebookPages : {},
modelState: this.model.exportState(),
resId: this.model.root.resId,
};
},
@@ -262,6 +267,38 @@ export class FormController extends Component {
}
}
get modelParams() {
let mode = this.props.mode || "edit";
if (!this.canEdit && this.props.resId) {
mode = "readonly";
}
return {
config: {
resModel: this.props.resModel,
resId: this.props.resId || false,
resIds: this.props.resIds || (this.props.resId ? [this.props.resId] : []),
fields: this.props.fields,
activeFields: {}, // will be generated after loading sub views (see willStart)
isMonoRecord: true,
mode,
context: this.props.context,
},
state: this.props.state?.modelState,
hooks: {
onWillLoadRoot: this.onWillLoadRoot.bind(this),
onWillSaveRecord: this.onWillSaveRecord.bind(this),
onRecordSaved: this.onRecordSaved.bind(this),
},
};
}
/**
* onWillLoadRoot is a callback that will be executed before (re)loading the
* data necessary for the root record datapoint. Note that this.model.root
* may not exist yet at this point, if this is the first load.
*/
onWillLoadRoot() {}
/**
* onRecordSaved is a callBack that will be executed after the save
* if it was done. It will therefore not be executed if the record
@@ -278,30 +315,44 @@ export class FormController extends Component {
*/
async onWillSaveRecord(record) {}
async onSaveError(error, { discard }) {
const proceed = await new Promise((resolve) => {
this.model.dialog.add(FormErrorDialog, {
message: error.data.message,
onDiscard: () => {
discard();
resolve(true);
},
onStayHere: () => resolve(false),
});
});
return proceed;
}
displayName() {
return this.model.root.data.display_name || this.env._t("New");
}
async onPagerUpdate({ offset, resIds }) {
await this.model.root.askChanges(); // ensures that isDirty is correct
const dirty = await this.model.root.isDirty();
let canProceed = true;
if (this.model.root.isDirty) {
if (dirty) {
canProceed = await this.model.root.save({
stayInEdition: true,
useSaveErrorDialog: true,
onError: this.onSaveError.bind(this),
});
}
if (canProceed) {
return this.model.load({ resId: resIds[offset] });
return this.model.root.load(resIds[offset]);
}
}
async beforeLeave() {
if (this.model.root.isDirty) {
if (this.model.root.dirty) {
return this.model.root.save({
noReload: true,
stayInEdition: true,
useSaveErrorDialog: true,
onError: this.onSaveError.bind(this),
});
}
}
@@ -381,9 +432,22 @@ export class FormController extends Component {
};
}
// enable the archive feature in Actions menu only if the active field is in the view
get archiveEnabled() {
return "active" in this.model.root.activeFields
? !this.props.fields.active.readonly
: "x_active" in this.model.root.activeFields
? !this.props.fields.x_active.readonly
: false;
}
async shouldExecuteAction(item) {
if ((this.model.root.isDirty || this.model.root.isNew) && !item.skipSave) {
return this.model.root.save({ stayInEdition: true, useSaveErrorDialog: true });
const dirty = await this.model.root.isDirty();
if ((dirty || this.model.root.isNew) && !item.skipSave) {
return this.model.root.save({
stayInEdition: true,
onError: this.onSaveError.bind(this),
});
}
return true;
}
@@ -427,14 +491,20 @@ export class FormController extends Component {
async beforeExecuteActionButton(clickParams) {
if (clickParams.special !== "cancel") {
const noReload = this.env.inDialog && clickParams.close;
return this.model.root
.save({ stayInEdition: true, useSaveErrorDialog: !this.env.inDialog, noReload })
.then((saved) => {
if (saved && this.props.onSave) {
this.props.onSave(this.model.root);
}
return saved;
});
const params = {
force: true,
stayInEdition: true,
noReload,
};
if (!this.env.inDialog) {
params.onError = this.onSaveError.bind(this);
}
return this.model.root.save(params).then((saved) => {
if (saved && this.props.onSave) {
this.props.onSave(this.model.root);
}
return saved;
});
} else if (this.props.onDiscard) {
this.props.onDiscard(this.model.root);
}
@@ -442,22 +512,15 @@ export class FormController extends Component {
async afterExecuteActionButton(clickParams) {}
async edit() {
await this.model.root.switchMode("edit");
}
async create() {
await this.model.root.askChanges(); // ensures that isDirty is correct
let canProceed = true;
if (this.model.root.isDirty) {
canProceed = await this.model.root.save({
stayInEdition: true,
useSaveErrorDialog: true,
});
}
const canProceed = await this.model.root.save({
stayInEdition: true,
onError: this.onSaveError.bind(this),
});
// FIXME: disable/enable not done in onPagerUpdate
if (canProceed) {
const btns = this.disableButtons();
await this.model.load({ resId: null });
await this.model.root.load(false);
this.enableButtons(btns);
}
}
@@ -466,17 +529,18 @@ export class FormController extends Component {
const btns = this.disableButtons();
const record = this.model.root;
let saved = false;
if (this.props.saveRecord) {
saved = await this.props.saveRecord(record, params);
} else {
saved = await record.save(params);
try {
if (this.props.saveRecord) {
saved = await this.props.saveRecord(record, params);
} else {
saved = await record.save(params);
}
} finally {
this.enableButtons(btns);
}
this.enableButtons(btns);
if (saved && this.props.onSave) {
this.props.onSave(record, params);
}
return saved;
}
@@ -1,21 +1,10 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const errorHandlerRegistry = registry.category("error_handlers");
export class FormErrorDialog extends Component {
setup() {
const { data, message } = this.props;
if (data && data.arguments && data.arguments.length > 0) {
this.message = data.arguments[0];
} else {
this.message = message;
}
}
async discard() {
await this.props.onDiscard();
this.props.close();
@@ -28,31 +17,3 @@ export class FormErrorDialog extends Component {
}
FormErrorDialog.template = "web.FormErrorDialog";
FormErrorDialog.components = { Dialog };
function formSaveErrorHandler(env, error, originalError) {
if (originalError.__raisedOnFormSave) {
const event = originalError.event;
error.unhandledRejectionEvent.preventDefault();
if (event.isDefaultPrevented()) {
// in theory, here, event was already handled
return true;
}
event.preventDefault();
env.services.dialog.add(
FormErrorDialog,
{
message: originalError.message.message,
data: originalError.message.data,
onDiscard: originalError.onDiscard,
onStayHere: originalError.onStayHere,
},
{
onClose: originalError.onStayHere,
}
);
return true;
}
}
errorHandlerRegistry.add("formSaveErrorHandler", formSaveErrorHandler);
@@ -5,7 +5,7 @@
<Dialog header="false" size="'md'" contentClass="'o_error_dialog'">
<div role="alert">
<h1 class="text-danger">Oh snap!</h1>
<p t-esc="message" class="text-prewrap"/>
<p t-esc="props.message" class="text-prewrap"/>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="stay">Stay here</button>
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { evalDomain } from "@web/core/domain";
import { Notebook } from "@web/core/notebook/notebook";
import { Setting } from "./setting/setting";
import { Field } from "@web/views/fields/field";
@@ -12,7 +13,6 @@ import { ViewButton } from "@web/views/view_button/view_button";
import { useViewCompiler } from "@web/views/view_compiler";
import { useBounceButton } from "@web/views/view_hook";
import { Widget } from "@web/views/widgets/widget";
import { evalDomain } from "../utils";
import { FormCompiler } from "./form_compiler";
import { FormLabel } from "./form_label";
import { StatusBarButtons } from "./status_bar_buttons/status_bar_buttons";
@@ -36,7 +36,7 @@ export class FormStatusIndicator extends Component {
return this.props.model.root.isValid ? "dirty" : "invalid";
} else if (!this.props.model.root.isValid) {
return "invalid";
} else if (this.props.model.root.isDirty || this.state.fieldIsDirty) {
} else if (this.props.model.root.dirty || this.state.fieldIsDirty) {
return "dirty";
} else {
return "saved";
@@ -1,8 +1,8 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { FormRenderer } from "./form_renderer";
import { RelationalModel } from "../basic_relational_model";
import { FormArchParser } from "./form_arch_parser";
import { FormController } from "./form_controller";
import { FormCompiler } from "./form_compiler";
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { Layout } from "@web/search/layout";
import { useModel } from "@web/views/model";
import { useModel } from "@web/model/model";
import { standardViewProps } from "@web/views/standard_view_props";
import { useSetupView } from "@web/views/view_hook";
import { SearchBar } from "@web/search/search_bar/search_bar";
@@ -5,7 +5,7 @@ import { KeepLast, Race } from "@web/core/utils/concurrency";
import { rankInterval } from "@web/search/utils/dates";
import { getGroupBy } from "@web/search/utils/group_by";
import { GROUPABLE_TYPES } from "@web/search/utils/misc";
import { Model } from "@web/views/model";
import { Model } from "@web/model/model";
import { computeReportMeasures, processMeasure } from "@web/views/utils";
import { sprintf } from "@web/core/utils/strings";
import { Domain } from "@web/core/domain";
@@ -1,15 +1,10 @@
/** @odoo-module **/
import { extractAttributes, XMLParser } from "@web/core/utils/xml";
import { stringToOrderBy } from "@web/search/utils/order_by";
import { Field } from "@web/views/fields/field";
import { Widget } from "@web/views/widgets/widget";
import {
addFieldDependencies,
archParseBoolean,
getActiveActions,
processButton,
stringToOrderBy,
} from "@web/views/utils";
import { archParseBoolean, getActiveActions, processButton } from "@web/views/utils";
/**
* NOTE ON 't-name="kanban-box"':
@@ -61,10 +56,9 @@ export class KanbanArchParser extends XMLParser {
const type = xmlDoc.getAttribute("type");
const openAction = action && type ? { action, type } : null;
const templateDocs = {};
const activeFields = {};
let headerButtons = [];
const creates = [];
let buttonId = 0;
let button_id = 0;
// Root level of the template
this.visitXML(xmlDoc, (node) => {
if (node.hasAttribute("t-name")) {
@@ -77,7 +71,7 @@ export class KanbanArchParser extends XMLParser {
.map((node) => ({
...processButton(node),
type: "button",
id: buttonId++,
id: button_id++,
}))
.filter((button) => button.modifiers.invisible !== true);
return false;
@@ -121,17 +115,15 @@ export class KanbanArchParser extends XMLParser {
if (fieldInfo.options.group_by_tooltip) {
tooltipInfo[name] = fieldInfo.options.group_by_tooltip;
}
if (fieldInfo.widget === "handle") {
if (fieldInfo.isHandle) {
handleField = name;
}
addFieldDependencies(fieldInfo, activeFields, models[modelName]);
}
if (node.tagName === "widget") {
const widgetInfo = Widget.parseWidgetNode(node);
const widgetId = `widget_${++widgetNextId}`;
widgetNodes[widgetId] = widgetInfo;
node.setAttribute("widget_id", widgetId);
addFieldDependencies(widgetInfo, activeFields, models[modelName]);
}
// Keep track of last update so images can be reloaded when they may have changed.
@@ -171,25 +163,9 @@ export class KanbanArchParser extends XMLParser {
defaultOrder = stringToOrderBy(handleField);
}
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,
activeActions,
activeFields,
className,
creates,
defaultGroupBy,
@@ -1,21 +1,24 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { CogMenu } from "@web/search/cog_menu/cog_menu";
import { Layout } from "@web/search/layout";
import { usePager } from "@web/search/pager_hook";
import { useModel } from "@web/views/model";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
import { session } from "@web/session";
import { useSetupView } from "@web/views/view_hook";
import { useModel } from "@web/model/model";
import { standardViewProps } from "@web/views/standard_view_props";
import { MultiRecordViewButton } from "@web/views/view_button/multi_record_view_button";
import { useViewButtons } from "@web/views/view_button/view_button_hook";
import { useSetupView } from "@web/views/view_hook";
import { addFieldDependencies, extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { KanbanRenderer } from "./kanban_renderer";
import { useProgressBar } from "./progress_bar_hook";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
import { CogMenu } from "@web/search/cog_menu/cog_menu";
import { Component, reactive, useRef } from "@odoo/owl";
import { addDependencies } from "../utils";
import { Component, reactive, useRef, useState } from "@odoo/owl";
const QUICK_CREATE_FIELD_TYPES = ["char", "boolean", "many2one", "selection", "many2many"];
@@ -24,8 +27,61 @@ const QUICK_CREATE_FIELD_TYPES = ["char", "boolean", "many2one", "selection", "m
export class KanbanController extends Component {
setup() {
this.actionService = useService("action");
this.dialog = useService("dialog");
const { Model, archInfo } = this.props;
this.model = useModel(Model, this.modelParams, this.modelOptions);
class KanbanSampleModel extends Model {
setup() {
super.setup(...arguments);
this.initialSampleGroups = undefined;
}
/**
* @override
*/
hasData() {
if (this.root.groups) {
if (!this.root.groups.length) {
// While we don't have any data, we want to display the column quick create and
// example background. Return true so that we don't get sample data instead
return true;
}
return this.root.groups.some((group) => group.hasData);
}
return super.hasData();
}
async load() {
if (this.orm.isSample && this.initialSampleGroups) {
this.orm.setGroups(this.initialSampleGroups);
}
return super.load(...arguments);
}
async _webReadGroup() {
const result = await super._webReadGroup(...arguments);
if (!this.initialSampleGroups) {
this.initialSampleGroups = JSON.parse(JSON.stringify(result.groups));
}
return result;
}
removeSampleDataInGroups() {
if (this.useSampleModel) {
for (const group of this.root.groups) {
const list = group.list;
list.count = 0;
if (list.records) {
list.records = [];
} else {
list.groups = [];
}
}
}
}
}
this.model = useState(useModel(KanbanSampleModel, this.modelParams, this.modelOptions));
if (archInfo.progressAttributes) {
const { activeBars } = this.props.state || {};
this.progressBarState = useProgressBar(
@@ -44,9 +100,8 @@ export class KanbanController extends Component {
},
set groupId(groupId) {
if (self.model.useSampleModel) {
self.model.removeSampleDataInGroups();
self.model.useSampleModel = false;
self.model.root.groups.forEach((g) => g.empty());
self.render();
}
this._groupId = groupId;
},
@@ -68,7 +123,7 @@ export class KanbanController extends Component {
getLocalState: () => {
return {
activeBars: this.progressBarState?.activeBars,
rootState: this.model.root.exportState(),
modelState: this.model.exportState(),
};
},
});
@@ -76,16 +131,14 @@ export class KanbanController extends Component {
const root = this.model.root;
const { count, hasLimitedCount, isGrouped, limit, offset } = root;
if (!isGrouped) {
console.log(root);
return {
offset: offset,
limit: limit,
total: count,
onUpdate: async ({ offset, limit }) => {
this.model.root.offset = offset;
this.model.root.limit = limit;
await this.model.root.load();
await this.model.root.load({ offset, limit });
await this.onUpdatedPager();
this.render(true); // FIXME WOWL reactivity
},
updateTotal: hasLimitedCount ? () => root.fetchCount() : undefined,
};
@@ -95,29 +148,33 @@ export class KanbanController extends Component {
}
get modelParams() {
const { resModel, fields, archInfo, limit, defaultGroupBy, state } = this.props;
const { rootState } = state || {};
const activeFields = archInfo.activeFields;
addDependencies(this.progressBarAggregateFields, activeFields, fields);
const { resModel, archInfo, limit, defaultGroupBy } = this.props;
const { activeFields, fields } = extractFieldsFromArchInfo(archInfo, this.props.fields);
addFieldDependencies(activeFields, fields, this.progressBarAggregateFields);
const modelConfig = this.props.state?.modelState?.config || {
resModel,
activeFields,
fields,
openGroupsByDefault: true,
};
return {
activeFields,
fields: { ...fields },
resModel,
handleField: archInfo.handleField,
limit: archInfo.limit || limit,
config: modelConfig,
state: this.props.state?.modelState,
limit: archInfo.limit || limit || 40,
groupsLimit: Number.MAX_SAFE_INTEGER, // no limit
countLimit: archInfo.countLimit,
defaultGroupBy,
defaultOrder: archInfo.defaultOrder,
viewMode: "kanban",
openGroupsByDefault: true,
onRecordSaved: this.onRecordSaved.bind(this),
rootState,
defaultOrderBy: archInfo.defaultOrder,
maxGroupByDepth: 1,
activeIdsLimit: session.active_ids_limit,
hooks: {
onRecordSaved: this.onRecordSaved.bind(this),
},
};
}
get modelOptions(){
get modelOptions() {
return {};
}
@@ -139,6 +196,15 @@ export class KanbanController extends Component {
return this.props.className;
}
async deleteRecord(record) {
this.dialog.add(ConfirmationDialog, {
body: _t("Are you sure you want to delete this record?"),
confirm: () => this.model.root.deleteRecords([record]),
confirmLabel: _t("Delete"),
cancel: () => {},
});
}
async openRecord(record, mode) {
const activeIds = this.model.root.records.map((datapoint) => datapoint.resId);
this.props.selectRecord(record.resId, { activeIds, mode });
@@ -219,7 +285,10 @@ KanbanController.template = `web.KanbanView`;
KanbanController.components = { Layout, KanbanRenderer, MultiRecordViewButton, SearchBar, CogMenu };
KanbanController.props = {
...standardViewProps,
defaultGroupBy: { validate: (dgb) => !dgb || typeof dgb === "string", optional: true },
defaultGroupBy: {
validate: (dgb) => !dgb || typeof dgb === "string",
optional: true,
},
editable: { type: Boolean, optional: true },
forceGlobalClick: { type: Boolean, optional: true },
onSelectionChanged: { type: Function, optional: true },
@@ -44,6 +44,7 @@
Compiler="props.Compiler"
readonly="true"
forceGlobalClick="props.forceGlobalClick"
deleteRecord.bind="deleteRecord"
openRecord.bind="openRecord"
noContentHelp="props.info.noContentHelp"
scrollTop.bind="scrollTop"
@@ -9,7 +9,8 @@ import { memoize } from "@web/core/utils/functions";
import { useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { useDebounced } from "@web/core/utils/timing";
import { isNull, isRelational } from "@web/views/utils";
import { isRelational } from "@web/model/relational_model/utils";
import { isNull } from "@web/views/utils";
import { ColumnProgress } from "@web/views/view_components/column_progress";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
@@ -161,12 +162,12 @@ export class KanbanHeader extends Component {
}
editGroup() {
const { context, displayName, resModel, value } = this.group;
const { context, displayName, groupByField, value } = this.group;
this.props.dialogClose.push(
this.dialog.add(FormViewDialog, {
context,
resId: value,
resModel,
resModel: groupByField.relation,
title: sprintf(this.env._t("Edit: %s"), displayName),
onRecordSaved: async () => {
await this.props.list.load();
@@ -1,229 +0,0 @@
/** @odoo-module **/
import {
DynamicGroupList,
DynamicRecordList,
Group,
RelationalModel,
} from "@web/views/relational_model";
import { isRelational } from "@web/views/utils";
import { EventBus } from "@odoo/owl";
class TransactionInProgress extends Error {}
class NoTransactionInProgress extends Error {}
function makeTransactionManager() {
const bus = new EventBus();
const transactions = {};
return {
start: (id) => {
if (transactions[id]) {
throw new TransactionInProgress(
`Transaction in progress: commit or abort to start a new one.`
);
}
transactions[id] = true;
bus.trigger("START");
},
commit: (id) => {
if (!transactions[id]) {
throw new NoTransactionInProgress(`No transaction in progress.`);
}
delete transactions[id];
bus.trigger("COMMIT");
},
abort: (id) => {
if (!transactions[id]) {
throw new NoTransactionInProgress(`No transaction in progress.`);
}
delete transactions[id];
bus.trigger("ABORT");
},
register: ({ onStart, onCommit, onAbort }) => {
let currentData = null;
bus.addEventListener("START", () => onStart && (currentData = onStart()));
bus.addEventListener("COMMIT", () => onCommit && onCommit(currentData));
bus.addEventListener("ABORT", () => onAbort && onAbort(currentData));
},
};
}
class KanbanGroup extends Group {
setup(_params, state = {}) {
super.setup(...arguments);
this.tooltip = [];
this.model.transaction.register({
onStart: () => ({
count: this.count,
records: [...this.list.records],
}),
onAbort: ({ count, records }) => {
this.count = count;
this.list.records = records;
},
});
}
}
export class KanbanDynamicGroupList extends DynamicGroupList {
setup(params, state) {
super.setup(...arguments);
this.previousParams = state.previousParams || "[]";
this.groupBy = this.groupBy.slice(0, 1);
this.limit = null;
}
get currentParams() {
return JSON.stringify([this.domain, this.groupBy]);
}
exportState() {
return {
...super.exportState(),
previousParams: this.currentParams,
};
}
/**
* After a reload, empty groups are expcted to disappear from the web_read_group.
* However, if the parameters are the same (domain + groupBy), we want to
* temporarily keep these empty groups in the interface until the next reload
* with different parameters.
* @override
*/
async load() {
const previousGroups = this.groups.map((g, i) => [g, i]);
await super.load();
if (this.previousParams === this.currentParams) {
for (const [group, index] of previousGroups) {
const newGroup = this.groups.find((g) => group.valueEquals(g.value));
if (!group.deleted && !newGroup) {
group.empty();
this.groups.splice(index, 0, group);
}
}
}
}
/**
* @param {string} dataRecordId
* @param {string} dataGroupId
* @param {string} refId
* @param {string} targetGroupId
*/
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
if (!sourceGroup || !targetGroup) {
return; // Groups have been re-rendered, old ids are ignored
}
const record = sourceGroup.list.records.find((r) => r.id === dataRecordId);
try {
this.model.transaction.start(dataRecordId);
} catch (err) {
if (err instanceof TransactionInProgress) {
return;
}
throw err;
}
// Move from one group to another
if (dataGroupId !== targetGroupId) {
const refIndex = targetGroup.list.records.findIndex((r) => r.id === refId);
// Quick update: moves the record at the right position and notifies components
targetGroup.addRecord(sourceGroup.removeRecord(record), refIndex + 1);
const value = isRelational(this.groupByField)
? [targetGroup.value, targetGroup.displayName]
: targetGroup.value;
const abort = () => {
this.model.transaction.abort(dataRecordId);
this.model.notify();
};
try {
await record.update({ [this.groupByField.name]: value }, { silent: true });
const saved = await record.save({ noReload: true });
if (!saved) {
abort();
this.model.notify();
return;
}
} catch (err) {
abort();
throw err;
}
const promises = [];
const groupsToReload = [sourceGroup];
if (!targetGroup.isFolded) {
groupsToReload.push(targetGroup);
promises.push(record.load());
}
await Promise.all(promises);
}
if (!targetGroup.isFolded) {
// Only trigger resequence if the group isn't folded
await targetGroup.list.resequence(dataRecordId, refId);
}
this.model.notify();
this.model.transaction.commit(dataRecordId);
return true;
}
}
export class KanbanDynamicRecordList extends DynamicRecordList {
async moveRecord(dataRecordId, _dataGroupId, refId) {
this.model.transaction.start(dataRecordId);
try {
await this.resequence(dataRecordId, refId);
} catch (err) {
this.model.transaction.abort(dataRecordId);
this.model.notify();
throw err;
}
this.model.transaction.commit(dataRecordId);
}
}
KanbanDynamicRecordList.DEFAULT_LIMIT = 40;
export class KanbanModel extends RelationalModel {
setup(params) {
super.setup(...arguments);
this.transaction = makeTransactionManager();
}
/**
* @override
*/
hasData() {
if (this.root.groups) {
if (!this.root.groups.length) {
// While we don't have any data, we want to display the column quick create and
// example background. Return true so that we don't get sample data instead
return true;
}
return this.root.groups.some((group) => group.count > 0);
}
return this.root.records.length > 0;
}
}
KanbanModel.DynamicGroupList = KanbanDynamicGroupList;
KanbanModel.DynamicRecordList = KanbanDynamicRecordList;
KanbanModel.Group = KanbanGroup;
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { evalDomain } from "@web/core/domain";
import { ColorList } from "@web/core/colorlist/colorlist";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
@@ -14,7 +14,7 @@ import { fileTypeMagicWordMap, imageCacheKey } from "@web/views/fields/image/ima
import { ViewButton } from "@web/views/view_button/view_button";
import { useViewCompiler } from "@web/views/view_compiler";
import { Widget } from "@web/views/widgets/widget";
import { evalDomain, getFormattedValue } from "../utils";
import { getFormattedValue } from "../utils";
import {
KANBAN_BOX_ATTRIBUTE,
KANBAN_MENU_ATTRIBUTE,
@@ -30,7 +30,9 @@ const { COLORS } = ColorList;
const formatters = registry.category("formatters");
// These classes determine whether a click on a record should open it.
export const CANCEL_GLOBAL_CLICK = ["a", ".dropdown", ".oe_kanban_action", "[data-bs-toggle]"].join(",");
export const CANCEL_GLOBAL_CLICK = ["a", ".dropdown", ".oe_kanban_action", "[data-bs-toggle]"].join(
","
);
const ALLOW_GLOBAL_CLICK = [".oe_kanban_global_click", ".oe_kanban_global_click_edit"].join(",");
/**
@@ -295,7 +297,7 @@ export class KanbanRecord extends Component {
*/
triggerAction(params) {
const env = this.env;
const { archInfo, group, list, openRecord, record } = this.props;
const { archInfo, openRecord, deleteRecord, record } = this.props;
const { type } = params;
switch (type) {
case "edit": {
@@ -305,19 +307,7 @@ export class KanbanRecord extends Component {
return openRecord(record);
}
case "delete": {
const listOrGroup = group || list;
if (listOrGroup.deleteRecords) {
this.dialog.add(ConfirmationDialog, {
body: env._t("Are you sure you want to delete this record?"),
confirm: () => listOrGroup.deleteRecords([record]),
confirmLabel: env._t("Delete"),
cancel: () => {},
});
} else {
// static list case
listOrGroup.removeRecord(record);
}
return;
return deleteRecord(record);
}
case "set_cover": {
const { autoOpen, fieldName } = params;
@@ -387,6 +377,7 @@ KanbanRecord.components = {
};
KanbanRecord.defaultProps = {
colors: COLORS,
deleteRecord: () => {},
openRecord: () => {},
};
KanbanRecord.props = [
@@ -397,6 +388,7 @@ KanbanRecord.props = [
"forceGlobalClick?",
"group?",
"list",
"deleteRecord?",
"openRecord?",
"readonly?",
"record",
@@ -1,21 +1,21 @@
/** @odoo-module */
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import {
Component,
onMounted,
useExternalListener,
useState,
useRef,
onWillStart,
useExternalListener,
useRef,
useState,
useSubEnv,
} from "@odoo/owl";
import { getDefaultConfig } from "../view";
import { RPCError } from "@web/core/network/rpc_service";
import { FormViewDialog } from "../view_dialogs/form_view_dialog";
import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { formView } from "../form/form_view";
import { useModel } from "../model";
import { getDefaultConfig } from "../view";
import { FormViewDialog } from "../view_dialogs/form_view_dialog";
const DEFAULT_QUICK_CREATE_VIEW = {
// note: the required modifier is written in the format returned by the server
@@ -55,24 +55,31 @@ class KanbanQuickCreateController extends Component {
this.state = useState({ disabled: false });
this.addDialog = useOwnedDialogs();
this.model = useModel(
this.props.Model,
{
resModel: this.props.resModel,
resId: false,
resIds: [],
fields: this.props.fields,
activeFields: this.props.archInfo.activeFields,
viewMode: "form",
rootType: "record",
mode: "edit",
component: this,
},
{
ignoreUseSampleModel: true,
}
const { activeFields, fields } = extractFieldsFromArchInfo(
this.props.archInfo,
this.props.fields
);
const modelServices = Object.fromEntries(
this.props.Model.services.map((servName) => {
return [servName, useService(servName)];
})
);
modelServices.orm = useService("orm");
const config = {
resModel: this.props.resModel,
resId: false,
resIds: [],
fields,
activeFields,
isMonoRecord: true,
mode: "edit",
context: this.props.context,
};
this.model = useState(new this.props.Model(this.env, { config }, modelServices));
onWillStart(() => this.model.load());
onMounted(() => {
this.uiActiveElement = this.uiService.activeElement;
});
@@ -126,7 +133,7 @@ class KanbanQuickCreateController extends Component {
const keys = Object.keys(this.model.root.activeFields);
if (keys.length === 1 && keys[0] === "display_name") {
const isValid = await this.model.root.checkValidity(true); // needed to put the class o_field_invalid in the field
const isValid = await this.model.root.checkValidity(); // needed to put the class o_field_invalid in the field
if (isValid) {
try {
[resId] = await this.model.orm.call(
@@ -141,31 +148,35 @@ class KanbanQuickCreateController extends Component {
this.showFormDialogInError(e);
}
} else {
this.model.notificationService.add(this.model.env._t("Display Name"), {
this.model.notification.add(this.model.env._t("Display Name"), {
title: this.model.env._t("Invalid fields: "),
type: "danger",
});
}
} else {
try {
await this.model.root.save({ closable: true, noReload: true, throwOnError: true });
} catch (e) {
this.showFormDialogInError(e);
}
await this.model.root.save({
closable: true,
noReload: true,
force: true,
onError: (e) => this.showFormDialogInError(e),
});
resId = this.model.root.resId;
}
if (resId) {
await this.props.onValidate(resId, mode);
if (mode === "add") {
await this.model.root.load(false);
}
}
this.state.disabled = false;
}
cancel(force) {
async cancel(force) {
if (this.state.disabled) {
return;
}
if (force || !this.model.root.isDirty) {
if (force || !(await this.model.root.isDirty())) {
this.props.onCancel();
}
}
@@ -185,6 +196,7 @@ class KanbanQuickCreateController extends Component {
title: this.env._t("Create"),
onRecordSaved: async (record) => {
await this.props.onValidate(record.resId, "add");
await this.model.load();
},
});
}
@@ -246,7 +258,7 @@ export class KanbanRecordQuickCreate extends Component {
props.resModel
);
const context = props.context || {};
context[`default_${props.group.groupByField.name}`] = props.group.getServerValue();
context[`default_${props.group.groupByField.name}`] = props.group.serverValue;
this.quickCreateProps = {
Model: formView.Model,
Renderer: formView.Renderer,
@@ -49,6 +49,7 @@ export class KanbanRenderer extends Component {
"archInfo",
"Compiler?", // optional in stable for backward compatibility
"list",
"deleteRecord",
"openRecord",
"readonly",
"forceGlobalClick?",
@@ -214,15 +215,22 @@ export class KanbanRenderer extends Component {
if (!this.canResequenceRecords) {
return false;
}
const { fields, groupByField } = this.props.list;
const groupByField = this.props.list.groupByField;
if (!groupByField) {
return true;
}
const { name, modifiers } = groupByField;
const isReadonly =
modifiers && "readonly" in modifiers
? modifiers.readonly === true
: fields[name].readonly;
const fieldNodes = Object.values(this.props.archInfo.fieldNodes).filter(
(fieldNode) => fieldNode.name === groupByField.name
);
let isReadonly;
if (
fieldNodes.length &&
fieldNodes.some((fieldNode) => "readonly" in fieldNode.modifiers)
) {
isReadonly = fieldNodes.every((fieldNode) => fieldNode.modifiers.readonly === true);
} else {
isReadonly = this.props.list.fields[groupByField.name].readonly;
}
return !isReadonly && this.isMovableField(groupByField);
}
@@ -391,7 +399,7 @@ export class KanbanRenderer extends Component {
}
loadMore(group) {
return group.list.loadMore();
return group.list.load({ limit: group.list.records.length + group.model.initialLimit });
}
/**
@@ -47,6 +47,7 @@
forceGlobalClick="props.forceGlobalClick"
group="group"
list="props.list"
deleteRecord="props.deleteRecord"
openRecord="props.openRecord"
readonly="props.readonly"
record="record"
@@ -74,6 +75,7 @@
canResequence="!isProcessing(groupOrRecord.record.id) and canResequenceRecords"
forceGlobalClick="props.forceGlobalClick"
list="props.list"
deleteRecord="props.deleteRecord"
openRecord="props.openRecord"
readonly="props.readonly"
record="groupOrRecord.record"
@@ -1,11 +1,11 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { KanbanArchParser } from "./kanban_arch_parser";
import { KanbanCompiler } from "./kanban_compiler";
import { KanbanController } from "./kanban_controller";
import { KanbanModel } from "./kanban_model";
import { KanbanRenderer } from "./kanban_renderer";
import { registry } from "@web/core/registry";
export const kanbanView = {
type: "kanban",
@@ -17,7 +17,7 @@ export const kanbanView = {
ArchParser: KanbanArchParser,
Controller: KanbanController,
Model: KanbanModel,
Model: RelationalModel,
Renderer: KanbanRenderer,
Compiler: KanbanCompiler,
@@ -219,9 +219,9 @@ class ProgressBarState {
}
async _updateAggregates() {
const { context, groupBy, domain, resModel, firstGroupBy } = this.model.root;
const { context, groupBy, domain, resModel } = this.model.root;
const fieldsName = this._aggregateFields.map((f) => f.name);
const firstGroupByName = firstGroupBy.split(":")[0];
const firstGroupByName = groupBy[0].split(":")[0];
const kwargs = { context };
const res = await this.model.orm.webReadGroup(
resModel,
-147
View File
@@ -1,147 +0,0 @@
/** @odoo-module **/
import fieldRegistry from "web.field_registry";
import { parseArch } from "web.viewUtils";
import { parse } from "web.field_utils";
import { traverse } from "web.utils";
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
const { date: parseDate, datetime: parseDateTime } = parse;
export function mapWowlValueToLegacy(value, type) {
switch (type) {
case "date":
// from luxon to moment
return value ? parseDate(serializeDate(value), null, { isUTC: true }) : false;
case "datetime":
// from luxon to moment
return value ? parseDateTime(serializeDateTime(value), null, { isUTC: true }) : false;
case "many2one":
return value ? { id: value[0], display_name: value[1] } : false;
case "reference":
return value
? { id: value.resId, display_name: value.displayName, model: value.resModel }
: false;
case "one2many":
case "many2many":
if (value.operation === "REPLACE_WITH") {
return { operation: "REPLACE_WITH", ids: value.resIds };
}
return value;
default:
return value;
}
}
export function mapViews(views, env) {
const res = {};
for (const [viewType, viewDescr] of Object.entries(views || {})) {
const arch = parseArch(viewDescr.__rawArch);
traverse(arch, function (node) {
if (typeof node === "string") {
return false;
}
node.attrs.modifiers = node.attrs.modifiers ? JSON.parse(node.attrs.modifiers) : {};
return true;
});
// the basic model expects the former shape of load_views result, where we don't know
// all co-model fields, only those in the subview, so we filter the fields here
const fields = {};
for (const f in viewDescr.activeFields) {
fields[f] = viewDescr.fields[f];
}
res[viewType] = {
arch,
fields,
type: viewType,
fieldsInfo: mapActiveFieldsToFieldsInfo(viewDescr.activeFields, fields, viewType, env),
};
for (const fieldName in res[viewType].fieldsInfo[viewType]) {
if (!res[viewType].fields[fieldName]) {
res[viewType].fields[fieldName] = {
name: fieldName,
type: res[viewType].fieldsInfo[viewType][fieldName].type,
};
}
}
}
return res;
}
export function mapActiveFieldsToFieldsInfo(activeFields, fields, viewType, env) {
const fieldsInfo = {};
fieldsInfo[viewType] = {};
for (const [fieldName, fieldDescr] of Object.entries(activeFields)) {
const views = mapViews(fieldDescr.views, env);
let Widget;
if (fieldDescr.widget) {
Widget = fieldRegistry.getAny([`${viewType}.${fieldDescr.widget}`, fieldDescr.widget]);
} else {
Widget = fieldRegistry.getAny([
`${viewType}.${fields[fieldName].type}`,
fields[fieldName].type,
]);
}
Widget = Widget || fieldRegistry.get("abstract");
let domain;
if (fieldDescr.domain) {
domain = fieldDescr.domain.toString();
}
let mode = fieldDescr.viewMode;
if (mode && mode.split(",").length !== 1) {
mode = env.isSmall ? "kanban" : "list";
}
const fieldInfo = {
Widget, // remove this when we no longer support legacy fields inside wowl views
specialData: fieldDescr.field && fieldDescr.field.legacySpecialData,
domain,
context: fieldDescr.context,
fieldDependencies: [], // ??
force_save: fieldDescr.forceSave,
mode,
modifiers: fieldDescr.modifiers,
name: fieldName,
options: fieldDescr.options,
views,
widget: fieldDescr.widget,
__WOWL_FIELD_DESCR__: fieldDescr,
};
if (fieldDescr.field && fieldDescr.field.limit) {
fieldInfo.limit = fieldDescr.field.limit;
}
if (fieldDescr.modifiers && fieldDescr.modifiers.invisible === true) {
fieldInfo.__no_fetch = true;
}
if (!fieldInfo.__no_fetch && fieldDescr.field && fieldDescr.field.relatedFields) {
let relatedFields = fieldDescr.field.relatedFields;
if (relatedFields instanceof Function) {
relatedFields = relatedFields(fieldInfo.__WOWL_FIELD_DESCR__);
}
relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f]));
fieldInfo.relatedFields = { ...relatedFields };
fieldInfo.viewType = "default";
const defaultView = {};
for (const fieldName of Object.keys(relatedFields)) {
defaultView[fieldName] = {};
if (fieldDescr.relatedFields[fieldName]) {
defaultView[fieldName].__WOWL_FIELD_DESCR__ =
fieldDescr.relatedFields[fieldName];
}
}
fieldInfo.fieldsInfo = { default: defaultView };
}
if (fieldDescr.views && fieldDescr.views[fieldDescr.viewMode]) {
fieldInfo.limit = fieldDescr.views[fieldDescr.viewMode].limit || 40;
fieldInfo.orderedBy = fieldDescr.views[fieldDescr.viewMode].defaultOrder;
}
if (fieldDescr.onChange && !fields[fieldName].onChange) {
fields[fieldName].onChange = "1";
}
// FIXME? FieldWidget in kanban undefined
fieldsInfo[viewType][fieldName] = fieldInfo;
}
return fieldsInfo;
}
@@ -1,17 +1,11 @@
/** @odoo-module */
import {
addFieldDependencies,
archParseBoolean,
getActiveActions,
getDecoration,
processButton,
stringToOrderBy,
} from "@web/views/utils";
import { Field } from "@web/views/fields/field";
import { XMLParser } from "@web/core/utils/xml";
import { Widget } from "@web/views/widgets/widget";
import { stringToOrderBy } from "@web/search/utils/order_by";
import { Field } from "@web/views/fields/field";
import { archParseBoolean, getActiveActions, getDecoration, processButton } from "@web/views/utils";
import { encodeObjectForTemplate } from "@web/views/view_compiler";
import { Widget } from "@web/views/widgets/widget";
export class GroupListArchParser extends XMLParser {
parse(arch, models, modelName, jsClass) {
@@ -37,11 +31,7 @@ export class GroupListArchParser extends XMLParser {
return false;
}
});
const activeFields = {};
for (const fieldNode of Object.values(fieldNodes)) {
activeFields[fieldNode.name] = fieldNode;
}
return { fieldNodes, activeFields, buttons };
return { fieldNodes, buttons };
}
}
@@ -81,7 +71,6 @@ export class ListArchParser extends XMLParser {
let handleField = null;
const treeAttr = {};
let nextId = 0;
const activeFields = {};
const fieldNextIds = {};
this.visitXML(arch, (node) => {
if (node.tagName !== "button") {
@@ -116,10 +105,9 @@ export class ListArchParser extends XMLParser {
const fieldId = `${fieldInfo.name}_${fieldNextIds[fieldInfo.name]++}`;
fieldNodes[fieldId] = fieldInfo;
node.setAttribute("field_id", fieldId);
if (fieldInfo.widget === "handle") {
if (fieldInfo.isHandle) {
handleField = fieldInfo.name;
}
addFieldDependencies(fieldInfo, activeFields, models[modelName]);
if (this.isColumnVisible(fieldInfo.modifiers.column_invisible)) {
const label = fieldInfo.field.label;
columns.push({
@@ -140,7 +128,6 @@ export class ListArchParser extends XMLParser {
const widgetId = `widget_${++widgetNextId}`;
widgetNodes[widgetId] = widgetInfo;
node.setAttribute("widget_id", widgetId);
addFieldDependencies(widgetInfo, activeFields, models[modelName]);
const widgetProps = {
name: widgetInfo.name,
@@ -163,7 +150,6 @@ export class ListArchParser extends XMLParser {
const groupByArchInfo = groupListArchParser.parse(groupByArch, models, coModelName);
groupBy.buttons[fieldName] = groupByArchInfo.buttons;
groupBy.fields[fieldName] = {
activeFields: groupByArchInfo.activeFields,
fieldNodes: groupByArchInfo.fieldNodes,
fields: models[coModelName],
};
@@ -238,28 +224,11 @@ export class ListArchParser extends XMLParser {
treeAttr.defaultOrder = stringToOrderBy(handleField);
}
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 {
creates,
handleField,
headerButtons,
fieldNodes,
widgetNodes,
activeFields,
columns,
groupBy,
xmlDoc,
@@ -45,7 +45,6 @@ ListConfirmationDialog.props = {
nbRecords: Number,
nbValidRecords: Number,
record: Object,
fieldNodes: Object,
};
ListConfirmationDialog.defaultProps = {
title: _lt("Confirmation"),
@@ -23,7 +23,7 @@
<tr>
<td>Update to:</td>
<td style="pointer-events: none;">
<Field name="field.name" record="props.record" type="field.widget" readonly="true" fieldInfo="props.fieldNodes[field.name]"/>
<Field name="field.name" record="props.record" type="field.widget" readonly="true" fieldInfo="field.fieldNode"/>
</td>
</tr>
</t>
@@ -6,13 +6,12 @@ import { evaluateExpr } from "@web/core/py_js/py";
import { unique } from "@web/core/utils/arrays";
import { useService, useBus } from "@web/core/utils/hooks";
import { omit } from "@web/core/utils/objects";
import { sprintf } from "@web/core/utils/strings";
import { ActionMenus, STATIC_ACTIONS_GROUP_NUMBER } from "@web/search/action_menus/action_menus";
import { Layout } from "@web/search/layout";
import { usePager } from "@web/search/pager_hook";
import { session } from "@web/session";
import { useModel } from "@web/views/model";
import { DynamicRecordList } from "@web/views/relational_model";
import { useModel } from "@web/model/model";
import { DynamicRecordList } from "@web/model/relational_model/dynamic_record_list";
import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { standardViewProps } from "@web/views/standard_view_props";
import { MultiRecordViewButton } from "@web/views/view_button/multi_record_view_button";
import { ViewButton } from "@web/views/view_button/view_button";
@@ -23,6 +22,7 @@ import { ListConfirmationDialog } from "./list_confirmation_dialog";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
import { CogMenu } from "@web/search/cog_menu/cog_menu";
import { session } from "@web/session";
import {
Component,
@@ -31,6 +31,7 @@ import {
onWillStart,
useEffect,
useRef,
useState,
useSubEnv,
} from "@odoo/owl";
@@ -40,7 +41,6 @@ export class ListController extends Component {
setup() {
this.actionService = useService("action");
this.dialogService = useService("dialog");
this.notificationService = useService("notification");
this.userService = useService("user");
this.rpc = useService("rpc");
this.rootRef = useRef("root");
@@ -49,7 +49,7 @@ export class ListController extends Component {
this.activeActions = this.archInfo.activeActions;
this.editable =
this.activeActions.edit && this.props.editable ? this.archInfo.editable : false;
this.model = useModel(this.props.Model, this.modelParams);
this.model = useState(useModel(this.props.Model, this.modelParams));
// In multi edition, we save or notify invalidity directly when a field is updated, which
// occurs on the change event for input fields. But we don't want to do it when clicking on
@@ -88,13 +88,7 @@ export class ListController extends Component {
useSetupView({
rootRef: this.rootRef,
beforeLeave: async () => {
const list = this.model.root;
const editedRecord = list.editedRecord;
if (editedRecord) {
if (!(await list.unselectRecord(true))) {
return false;
}
}
return this.model.root.leaveEditMode();
},
beforeUnload: async (ev) => {
const editedRecord = this.model.root.editedRecord;
@@ -109,8 +103,11 @@ export class ListController extends Component {
getLocalState: () => {
const renderer = this.rootRef.el.querySelector(".o_list_renderer");
return {
rootState: this.model.root.exportState(),
rendererScrollPositions: { left: renderer.scrollLeft, top: renderer.scrollTop },
modelState: this.model.exportState(),
rendererScrollPositions: {
left: renderer.scrollLeft,
top: renderer.scrollTop,
},
};
},
getOrderBy: () => {
@@ -119,8 +116,7 @@ export class ListController extends Component {
});
usePager(() => {
const list = this.model.root;
const { count, hasLimitedCount, isGrouped, limit, offset } = list;
const { count, hasLimitedCount, isGrouped, limit, offset } = this.model.root;
return {
offset: offset,
limit: limit,
@@ -131,13 +127,13 @@ export class ListController extends Component {
return;
}
}
await list.load({ limit, offset });
this.render(true); // FIXME WOWL reactivity
await this.model.root.load({ limit, offset });
if (hasNavigated) {
this.onPageChangeScroll();
}
},
updateTotal: !isGrouped && hasLimitedCount ? () => list.fetchCount() : undefined,
updateTotal:
!isGrouped && hasLimitedCount ? () => this.model.root.fetchCount() : undefined,
};
});
@@ -155,32 +151,47 @@ export class ListController extends Component {
onWillPatch(() => {
this.firstLoad = false;
});
useBus(this.env.searchModel, 'direct-export-data', this.onDirectExportData.bind(this));
useBus(this.env.searchModel, "direct-export-data", this.onDirectExportData.bind(this));
}
get modelParams() {
const { rootState } = this.props.state || {};
const { defaultGroupBy, rawExpand } = this.archInfo;
return {
const { activeFields, fields } = extractFieldsFromArchInfo(
this.archInfo,
this.props.fields
);
const groupByInfo = {};
for (const fieldName in this.archInfo.groupBy.fields) {
const fieldNodes = this.archInfo.groupBy.fields[fieldName].fieldNodes;
const fields = this.archInfo.groupBy.fields[fieldName].fields;
groupByInfo[fieldName] = extractFieldsFromArchInfo({ fieldNodes }, fields);
}
const modelConfig = this.props.state?.modelState?.config || {
resModel: this.props.resModel,
fields: { ...this.props.fields },
activeFields: this.archInfo.activeFields,
handleField: this.archInfo.handleField,
viewMode: "list",
groupByInfo: this.archInfo.groupBy.fields,
fields,
activeFields,
openGroupsByDefault: rawExpand ? evaluateExpr(rawExpand, this.props.context) : false,
};
return {
config: modelConfig,
state: this.props.state?.modelState,
groupByInfo,
limit: this.archInfo.limit || this.props.limit,
countLimit: this.archInfo.countLimit,
defaultOrder: this.archInfo.defaultOrder,
defaultOrderBy: this.archInfo.defaultOrder,
defaultGroupBy: this.props.searchMenuTypes.includes("groupBy") ? defaultGroupBy : false,
expand: rawExpand ? evaluateExpr(rawExpand, this.props.context) : false,
groupsLimit: this.archInfo.groupsLimit,
multiEdit: this.archInfo.multiEdit,
rootState,
onRecordSaved: this.onRecordSaved.bind(this),
onWillSaveRecord: this.onWillSaveRecord.bind(this),
onWillSaveMultiRecords: this.onWillSaveMultiRecords.bind(this),
onSavedMultiRecords: this.onSavedMultiRecords.bind(this),
onWillSetInvalidField: this.onWillSetInvalidField.bind(this),
activeIdsLimit: session.active_ids_limit,
hooks: {
onRecordSaved: this.onRecordSaved.bind(this),
onWillSaveRecord: this.onWillSaveRecord.bind(this),
onWillSaveMulti: this.onWillSaveMulti.bind(this),
onSavedMulti: this.onSavedMulti.bind(this),
onWillSetInvalidField: this.onWillSetInvalidField.bind(this),
},
};
}
@@ -206,11 +217,9 @@ export class ListController extends Component {
if (!(list instanceof DynamicRecordList)) {
throw new Error("List should be a DynamicRecordList");
}
if (list.editedRecord) {
await list.editedRecord.save();
}
await list.leaveEditMode();
if (!list.editedRecord) {
await (group || list).createRecord({}, this.editable === "top");
await (group || list).addNewRecord(this.editable === "top");
}
this.render();
} else {
@@ -238,21 +247,25 @@ export class ListController extends Component {
}
}
onClickCreate() {
this.createRecord();
async onClickCreate() {
this.disableButtons();
await this.createRecord();
this.enableButtons();
}
onClickDiscard() {
const editedRecord = this.model.root.editedRecord;
if (editedRecord.isNew) {
this.model.root.removeRecord(editedRecord);
} else {
editedRecord.discard();
async onClickDiscard() {
this.disableButtons();
await this.model.root.leaveEditMode({ discard: true });
this.enableButtons();
}
async onClickSave() {
this.disableButtons();
const saved = await this.model.root.editedRecord.save();
if (saved) {
await this.model.root.leaveEditMode();
}
}
onClickSave() {
this.model.root.editedRecord.save();
this.enableButtons();
}
onMouseDownDiscard(mouseDownEvent) {
@@ -352,7 +365,7 @@ export class ListController extends Component {
}
async onSelectDomain() {
this.model.root.selectDomain(true);
await this.model.root.selectDomain(true);
if (this.props.onSelectionChanged) {
const resIds = await this.model.root.getResIds(true);
this.props.onSelectionChanged(resIds);
@@ -385,7 +398,7 @@ export class ListController extends Component {
get nbTotal() {
const list = this.model.root;
return list.isGrouped ? list.nbTotalRecords : list.count;
return list.isGrouped ? list.recordCount : list.count;
}
get defaultExportList() {
@@ -424,7 +437,10 @@ export class ListController extends Component {
type: field.field_type || field.type,
}));
if (import_compat) {
exportedFields.unshift({ name: "id", label: this.env._t("External ID") });
exportedFields.unshift({
name: "id",
label: this.env._t("External ID"),
});
}
await download({
data: {
@@ -481,30 +497,10 @@ export class ListController extends Component {
* @returns {Promise}
*/
async toggleArchiveState(archive) {
let resIds;
const isDomainSelected = this.model.root.isDomainSelected;
const total = this.model.root.count;
if (archive) {
resIds = await this.model.root.archive(true);
} else {
resIds = await this.model.root.unarchive(true);
}
if (
isDomainSelected &&
resIds.length === session.active_ids_limit &&
resIds.length < total
) {
this.notificationService.add(
sprintf(
this.env._t(
"Of the %s records selected, only the first %s have been archived/unarchived."
),
resIds.length,
total
),
{ title: this.env._t("Warning") }
);
return this.model.root.archive(true);
}
return this.model.root.unarchive(true);
}
get deleteConfirmationDialogProps() {
@@ -515,28 +511,8 @@ export class ListController extends Component {
: this.env._t("Are you sure you want to delete this record?");
return {
body,
confirm: async () => {
const total = root.count;
const resIds = await this.model.root.deleteRecords();
this.model.notify();
if (
root.isDomainSelected &&
resIds.length === session.active_ids_limit &&
resIds.length < total
) {
this.notificationService.add(
sprintf(
this.env._t(
`Only the first %s records have been deleted (out of %s selected)`
),
resIds.length,
total
),
{ title: this.env._t("Warning") }
);
}
},
confirmLabel: this.env._t("Delete"),
confirm: () => this.model.root.deleteRecords(),
cancel: () => {},
};
}
@@ -559,7 +535,22 @@ export class ListController extends Component {
async afterExecuteActionButton(clickParams) {}
onWillSaveMultiRecords(editedRecord, validSelectedRecords) {
disableButtons() {
const btns = [...this.rootRef.el.querySelectorAll("button:not([disabled])")];
for (const btn of btns) {
btn.setAttribute("disabled", "1");
}
this.disabledButtons = btns;
}
enableButtons() {
for (const btn of this.disabledButtons) {
btn.removeAttribute("disabled");
}
this.disabledButtons = null;
}
onWillSaveMulti(editedRecord, changes, validSelectedRecords) {
if (this.hasMousedownDiscard) {
this.nextActionAfterMouseup = () => this.model.root.multiSave(editedRecord);
return false;
@@ -570,22 +561,25 @@ export class ListController extends Component {
const dialogProps = {
confirm: () => resolve(true),
cancel: () => {
editedRecord.discard();
this.model.root.leaveEditMode({ discard: true });
resolve(false);
},
isDomainSelected,
fields: Object.keys(editedRecord.getChanges()).map((fieldName) => {
const activeField = editedRecord.activeFields[fieldName];
fields: Object.keys(changes).map((fieldName) => {
const fieldNode = Object.values(this.archInfo.fieldNodes).find(
(fieldNode) => fieldNode.name === fieldName
);
const label = fieldNode && fieldNode.string;
return {
name: fieldName,
label: activeField.string || editedRecord.fields[fieldName].string,
widget: activeField.widget,
label: label || editedRecord.fields[fieldName].string,
fieldNode,
widget: fieldNode && fieldNode.widget,
};
}),
nbRecords: selection.length,
nbValidRecords: validSelectedRecords.length,
record: editedRecord,
fieldNodes: this.archInfo.fieldNodes,
};
const focusedCellBeforeDialog = document.activeElement.closest(".o_data_cell");
@@ -594,7 +588,7 @@ export class ListController extends Component {
if (focusedCellBeforeDialog) {
focusedCellBeforeDialog.focus();
}
editedRecord.discard();
this.model.root.leaveEditMode({ discard: true });
resolve(false);
},
});
@@ -611,7 +605,7 @@ export class ListController extends Component {
return true;
}
onSavedMultiRecords(records) {
onSavedMulti(records) {
records.forEach((record) => {
record.selected = false;
});
@@ -80,7 +80,7 @@
<button type="button" class="btn btn-primary o_list_button_save" data-hotkey="s" t-on-click.stop="onClickSave">
Save
</button>
<button type="button" class="btn btn-secondary o_list_button_discard" data-hotkey="j" t-on-click="onClickDiscard" t-on-mousedown="onMouseDownDiscard">
<button type="button" class="btn btn-secondary o_list_button_discard" data-hotkey="j" t-on-click.stop="onClickDiscard" t-on-mousedown="onMouseDownDiscard">
Discard
</button>
</t>
@@ -2,7 +2,7 @@
import { browser } from "@web/core/browser/browser";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { Domain } from "@web/core/domain";
import { Domain, evalDomain } from "@web/core/domain";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
@@ -12,9 +12,9 @@ import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { useSortable } from "@web/core/utils/sortable";
import { getTabableElements } from "@web/core/utils/ui";
import { Field } from "@web/views/fields/field";
import { Field, getPropertyFieldInfo } from "@web/views/fields/field";
import { getTooltipInfo } from "@web/views/fields/field_tooltip";
import { evalDomain, getClassNameFromDecoration } from "@web/views/utils";
import { getClassNameFromDecoration } from "@web/views/utils";
import { ViewButton } from "@web/views/view_button/view_button";
import { useBounceButton } from "@web/views/view_hook";
import { Widget } from "@web/views/widgets/widget";
@@ -121,7 +121,11 @@ export class ListRenderer extends Component {
this.allColumns = this.processAllColumn(nextProps.archInfo.columns, nextProps.list);
this.state.columns = this.getActiveColumns(nextProps.list);
});
onPatched(() => {
onPatched(async () => {
// HACK: we need to wait for the next tick to be sure that the Field components are patched.
// OWL don't wait the patch for the children components if the children trigger a patch by himself.
await Promise.resolve();
const editedRecord = this.props.list.editedRecord;
if (editedRecord && this.activeRowId !== editedRecord.id) {
if (this.cellToFocus && this.cellToFocus.record === editedRecord) {
@@ -187,7 +191,7 @@ export class ListRenderer extends Component {
() => {
this.freezeColumnWidths();
},
() => [this.state.columns, this.isEmpty]
() => [this.state.columns, this.isEmpty, this.props.list.offset, this.props.list.limit]
);
useExternalListener(window, "resize", () => {
this.columnWidths = null;
@@ -238,32 +242,36 @@ export class ListRenderer extends Component {
processAllColumn(allColumns, list) {
return allColumns.flatMap((column) => {
if (column.type === "field" && list.fields[column.name].type === "properties") {
return Object.values(list.activeFields)
.filter(
(activeField) =>
activeField.relatedPropertyField &&
activeField.relatedPropertyField.fieldName === column.name
)
.map((activeField) => ({
...activeField,
id: `${column.id}_${activeField.name}`,
classNames: column.classNames,
optional: "hide",
type: "field",
hasLabel: true,
label: activeField.string,
}));
return this.getPropertyFieldColumns(column, list);
} else {
return [column];
}
});
}
getPropertyFieldColumns(column, list) {
return Object.values(list.fields)
.filter(
(field) =>
field.relatedPropertyField &&
field.relatedPropertyField.fieldName === column.name
)
.map((propertyField) => {
return {
...getPropertyFieldInfo(propertyField),
id: `${column.id}_${propertyField.name}`,
classNames: column.classNames,
optional: "hide",
type: "field",
hasLabel: true,
label: propertyField.string,
sortable: false,
};
});
}
getFieldProps(record, column) {
if (
this.isCellReadonly(column, record) ||
(this.props.activeActions?.edit === false && !record.isNew)
) {
if (this.isCellReadonly(column, record) || this.isRecordReadonly(record)) {
return {
readonly: true,
};
@@ -397,8 +405,7 @@ export class ListRenderer extends Component {
if (!this.props.list.canResequence()) {
return false;
}
const orderBy = this.props.list.orderBy;
const handleField = this.props.archInfo.handleField;
const { handleField, orderBy } = this.props.list;
return !orderBy.length || (orderBy.length && orderBy[0].name === handleField);
}
@@ -425,7 +432,30 @@ export class ListRenderer extends Component {
}
canUseFormatter(column, record) {
return !record.isInEdition && !column.widget;
if (column.widget) {
return false;
}
if (record.isInEdition && (record.model.multiEdit || this.isInlineEditable(record))) {
// in a x2many non editable list, a record is in edition when it is opened in a dialog,
// but in the list we want it to still be displayed in readonly.
return false;
}
return true;
}
isRecordReadonly(record) {
if (record.isNew) {
return false;
}
if (this.props.activeActions?.edit === false) {
return true;
}
if (record.isInEdition && !this.isInlineEditable(record) && !record.model.multiEdit) {
// in a x2many non editable list, a record is in edition when it is opened in a dialog,
// but in the list we want it to still be displayed in readonly.
return true;
}
return false;
}
focusCell(column, forward = true) {
@@ -499,7 +529,7 @@ export class ListRenderer extends Component {
createKeyOptionalFields() {
let keyParts = {
fields: this.props.list.fieldNames,
fields: this.props.list.fieldNames, // FIXME: use something else?
model: this.props.list.resModel,
viewMode: "list",
viewId: this.env.config.viewId,
@@ -617,7 +647,7 @@ export class ListRenderer extends Component {
let currencyId;
if (type === "monetary" || widget === "monetary") {
const currencyField =
this.props.list.activeFields[fieldName].options.currency_field ||
column.options.currency_field ||
this.fields[fieldName].currency_field ||
"currency_id";
if (!(currencyField in this.props.list.activeFields)) {
@@ -811,7 +841,7 @@ export class ListRenderer extends Component {
if (required && evalDomain(required, record.evalContext)) {
classNames.push("o_required_modifier");
}
if (record.isInvalid(column.name)) {
if (record.isFieldInvalid(column.name)) {
classNames.push("o_invalid_cell");
}
if (this.isCellReadonly(column, record)) {
@@ -1049,11 +1079,11 @@ export class ListRenderer extends Component {
this.cellToFocus = null;
} else {
await recordAfterResequence();
await record.switchMode("edit");
await this.props.list.enterEditMode(record);
this.cellToFocus = { column, record };
}
} else if (this.props.list.editedRecord && this.props.list.editedRecord !== record) {
this.props.list.unselectRecord(true);
this.props.list.leaveEditMode();
} else if (!this.props.archInfo.noOpen) {
this.props.openRecord(record);
}
@@ -1063,8 +1093,8 @@ export class ListRenderer extends Component {
this.keepColumnWidths = true;
const editedRecord = this.props.list.editedRecord;
if (editedRecord && editedRecord !== record) {
const unselected = await this.props.list.unselectRecord(true);
if (!unselected) {
const leaved = await this.props.list.leaveEditMode();
if (!leaved) {
return;
}
}
@@ -1143,13 +1173,8 @@ export class ListRenderer extends Component {
/**
* @param {KeyboardEvent} ev
* @param { import('@web/views/relational_model').Group
* | null
* } group
* @param { import('@web/views/relational_model').Record
* | import('@web/views/basic_relational_model').Record
* | null
* } record
* @param { import('@web/model/relational_model/group').Group | null } group
* @param { import('@web/model/relational_model/record').Record | null } record
*/
onCellKeydown(ev, group = null, record = null) {
if (this.props.list.model.useSampleModel) {
@@ -1271,7 +1296,7 @@ export class ListRenderer extends Component {
let toFocus, futureRecord;
const index = list.selection.indexOf(record);
if (this.lastIsDirty && ["tab", "shift+tab", "enter"].includes(hotkey)) {
record.switchMode("readonly");
list.leaveEditMode();
return true;
}
@@ -1304,7 +1329,7 @@ export class ListRenderer extends Component {
case "enter":
if (list.selection.length === 1) {
record.switchMode("readonly");
list.leaveEditMode();
return true;
}
futureRecord = list.selection[index + 1] || list.selection[0];
@@ -1312,7 +1337,7 @@ export class ListRenderer extends Component {
}
if (futureRecord) {
futureRecord.switchMode("edit");
list.enterEditMode(futureRecord);
return true;
}
return false;
@@ -1322,7 +1347,7 @@ export class ListRenderer extends Component {
const { editable } = this.props;
const groupIndex = group.list.records.indexOf(record);
const isLastOfGroup = groupIndex === group.list.records.length - 1;
const isDirty = record.isDirty || this.lastIsDirty;
const isDirty = record.dirty || this.lastIsDirty;
const isEnterBehavior = hotkey === "enter" && (!record.canBeAbandoned || isDirty);
const isTabBehavior = hotkey === "tab" && !record.canBeAbandoned && isDirty;
if (
@@ -1360,12 +1385,8 @@ export class ListRenderer extends Component {
/**
* @param {string} hotkey
* @param {HTMLTableCellElement} cell
* @param { import('@web/views/relational_model').Group
* | null
* } group
* @param { import('@web/views/relational_model').Record
* | import('@web/views/basic_relational_model').Record
* } record
* @param { import('@web/model/relational_model/group').Group | null } group
* @param { import('@web/model/relational_model/record').Record } record
* @returns {boolean} true if some behavior has been taken
*/
onCellKeydownEditMode(hotkey, cell, group, record) {
@@ -1395,8 +1416,8 @@ export class ListRenderer extends Component {
const lastIndex = topReCreate ? 0 : list.records.length - 1;
if (index === lastIndex) {
if (this.displayRowCreates) {
if (record.isNew && !record.isDirty) {
list.unselectRecord(true);
if (record.isNew && !record.dirty) {
list.leaveEditMode();
return false;
}
// add a line
@@ -1405,12 +1426,12 @@ export class ListRenderer extends Component {
} else if (
this.canCreate &&
!record.canBeAbandoned &&
(record.isDirty || this.lastIsDirty)
(record.dirty || this.lastIsDirty)
) {
this.add({ group });
} else if (cycleOnTab) {
if (record.canBeAbandoned) {
list.unselectRecord(true);
list.leaveEditMode();
}
const futureRecord = list.records[0];
if (record === futureRecord) {
@@ -1418,14 +1439,14 @@ export class ListRenderer extends Component {
const toFocus = this.findNextFocusableOnRow(row);
this.focus(toFocus);
} else {
futureRecord.switchMode("edit");
list.enterEditMode(futureRecord);
}
} else {
return false;
}
} else {
const futureRecord = list.records[index + 1];
futureRecord.switchMode("edit");
list.enterEditMode(futureRecord);
}
break;
}
@@ -1434,7 +1455,7 @@ export class ListRenderer extends Component {
if (index === 0) {
if (cycleOnTab) {
if (record.canBeAbandoned) {
list.unselectRecord(true);
list.leaveEditMode();
}
const futureRecord = list.records[list.records.length - 1];
if (record === futureRecord) {
@@ -1443,16 +1464,16 @@ export class ListRenderer extends Component {
this.focus(toFocus);
} else {
this.cellToFocus = { forward: false, record: futureRecord };
futureRecord.switchMode("edit");
list.enterEditMode(futureRecord);
}
} else {
list.unselectRecord(true);
list.leaveEditMode();
return false;
}
} else {
const futureRecord = list.records[index - 1];
this.cellToFocus = { forward: false, record: futureRecord };
futureRecord.switchMode("edit");
list.enterEditMode(futureRecord);
}
break;
}
@@ -1468,19 +1489,22 @@ export class ListRenderer extends Component {
}
if (futureRecord) {
futureRecord.switchMode("edit", { checkValidity: true });
list.leaveEditMode({ validate: true }).then((canProceed) => {
if (canProceed) {
list.enterEditMode(futureRecord);
}
});
} else if (this.lastIsDirty || !record.canBeAbandoned || this.displayRowCreates) {
this.add({ group });
} else {
futureRecord = list.records.at(0);
futureRecord.switchMode("edit", { checkValidity: true });
list.enterEditMode(futureRecord);
}
break;
}
case "escape": {
// TODO this seems bad: refactor this
record.discard();
list.unselectRecord(true);
list.leaveEditMode({ discard: true });
const firstAddButton = this.tableRef.el.querySelector(
".o_field_x2many_list_row_add a"
);
@@ -1519,13 +1543,10 @@ export class ListRenderer extends Component {
/**
* @param {string} hotkey
* @param {HTMLTableCellElement} cell
* @param { import('@web/views/relational_model').Group
* @param { import('@web/model/relational_model/group').Group
* | null
* } group
* @param { import('@web/views/relational_model').Record
* | import('@web/views/basic_relational_model').Record
* | null
* } record
* @param { import('@web/model/relational_model/record').Record | null } record
* @returns {boolean} true if some behavior has been taken
*/
onCellKeydownReadOnlyMode(hotkey, cell, group, record) {
@@ -1631,7 +1652,7 @@ export class ListRenderer extends Component {
(c) => c.name === cell.getAttribute("name")
);
this.cellToFocus = { column, record };
record.switchMode("edit");
this.props.list.enterEditMode(record);
return true;
}
@@ -1833,7 +1854,7 @@ export class ListRenderer extends Component {
if (ev.target.closest(".ui-autocomplete")) {
return;
}
this.props.list.unselectRecord(true);
this.props.list.leaveEditMode();
}
calculateColumnWidth(column) {
@@ -2000,13 +2021,11 @@ export class ListRenderer extends Component {
* @param {HTMLElement} [params.previous]
*/
async sortDrop(dataRowId, { element, previous }) {
if (this.props.list.editedRecord) {
this.props.list.unselectRecord(true);
}
this.props.list.leaveEditMode();
element.classList.remove("o_row_draggable");
const refId = previous ? previous.dataset.id : null;
this.resequencePromise = this.props.list.resequence(dataRowId, refId, {
handleField: this.props.archInfo.handleField,
handleField: this.props.list.handleField,
});
await this.resequencePromise;
element.classList.add("o_row_draggable");
@@ -77,7 +77,7 @@
<t t-set="list" t-value="props.list"/>
</t>
</tbody>
<tfoot t-on-click="() => props.list.unselectRecord(true)" class="o_list_footer cursor-default" t-att-class="{o_sample_data_disabled: props.list.model.useSampleModel}">
<tfoot t-on-click="() => props.list.leaveEditMode()" class="o_list_footer cursor-default" t-att-class="{o_sample_data_disabled: props.list.model.useSampleModel}">
<tr>
<td t-if="hasSelectors"/>
<t t-foreach="state.columns" t-as="column" t-key="column.id">
@@ -150,7 +150,7 @@
>
<a href="#"
role="button"
t-on-click.stop.prevent="() => group.createRecord({}, props.editable === 'top')"
t-on-click.stop.prevent="() => group.addNewRecord({}, props.editable === 'top')"
t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)"
>
Add a line
@@ -1,7 +1,7 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { RelationalModel } from "../relational_model";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { ListArchParser } from "./list_arch_parser";
import { ListController } from "./list_controller";
import { ListRenderer } from "./list_renderer";
@@ -17,6 +17,8 @@ export const listView = {
Model: RelationalModel,
buttonTemplate: "web.ListView.Buttons",
limit: 80,
props: (genericProps, view) => {
const { ArchParser } = view;
const { arch, relatedModels, resModel } = genericProps;
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { Layout } from "@web/search/layout";
import { useModel } from "@web/views/model";
import { useModel } from "@web/model/model";
import { standardViewProps } from "@web/views/standard_view_props";
import { useSetupView } from "@web/views/view_hook";
import { SearchBar } from "@web/search/search_bar/search_bar";
@@ -4,7 +4,7 @@ import { Domain } from "@web/core/domain";
import { cartesian, sections, sortBy, symmetricalDifference } from "@web/core/utils/arrays";
import { KeepLast, Race } from "@web/core/utils/concurrency";
import { DEFAULT_INTERVAL } from "@web/search/utils/dates";
import { Model } from "@web/views/model";
import { Model } from "@web/model/model";
import { computeReportMeasures, processMeasure } from "@web/views/utils";
/**
+50 -47
View File
@@ -1,67 +1,70 @@
/** @odoo-module **/
import { useBus, useService } from "@web/core/utils/hooks";
import { RelationalModel } from "@web/views/relational_model";
import { Component, xml, onWillStart, onWillUpdateProps } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { pick } from "@web/core/utils/objects";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { Component, xml, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
const defaultActiveField = { attrs: {}, options: {}, domain: "[]", string: "" };
class StandaloneRelationalModel extends RelationalModel {
load(params = {}) {
if (params.values) {
const data = params.values;
if (params.mode) {
this.config.mode = params.mode;
}
this.root = this._createRoot(this.config, data);
return;
}
return super.load(params);
}
}
class _Record extends Component {
setup() {
const resModel = this.props.info.resModel;
const modelParams = {
resModel,
fields: this.props.fields,
viewMode: "form",
rootType: "record",
activeFields: this.getActiveFields(),
onRecordSaved: this.props.info.onRecordSaved || (() => {}),
onWillSaveRecord: this.props.info.onWillSaveRecord || (() => {}),
config: {
resModel,
fields: this.props.fields,
isMonoRecord: true,
activeFields: this.getActiveFields(),
resId: this.props.info.resId,
mode: this.props.info.mode,
},
hooks: {
onRecordSaved: this.props.info.onRecordSaved || (() => {}),
onWillSaveRecord: this.props.info.onWillSaveRecord || (() => {}),
onRecordChanged: this.props.info.onRecordChanged || (() => {}),
},
};
const modelServices = Object.fromEntries(
RelationalModel.services.map((servName) => {
StandaloneRelationalModel.services.map((servName) => {
return [servName, useService(servName)];
})
);
if (resModel) {
modelServices.orm = useService("orm");
}
modelServices.orm = useService("orm");
this.model = useState(new StandaloneRelationalModel(this.env, modelParams, modelServices));
this.model = new RelationalModel(this.env, modelParams, modelServices);
useBus(this.model.bus, "update", () => this.render(true));
let loadKey;
const load = (props) => {
const loadParams = {
resId: props.info.resId,
mode: props.info.mode,
values: props.values,
};
const nextLoadKey = JSON.stringify(loadParams);
if (loadKey === nextLoadKey) {
return;
}
loadKey = nextLoadKey;
return this.model.load(loadParams);
const loadWithValues = (values) => {
values = pick(values, ...Object.keys(modelParams.config.activeFields));
return this.model.load({ values });
};
onWillStart(() => load(this.props));
onWillUpdateProps((nextProps) => {
// don't wait because the keeplast may make willUpdateProps hang forever
load(nextProps);
onWillStart(() => {
if (this.props.values) {
return loadWithValues(this.props.values);
} else {
return this.model.load();
}
});
onWillUpdateProps((nextProps) => {
if (nextProps.values) {
return loadWithValues(nextProps.values);
} else if (nextProps.info.resId !== this.model.root.resId) {
return this.model.root.load(nextProps.info.resId);
}
});
if (this.props.info.onRecordChanged) {
const load = this.model.load;
this.model.load = async (...args) => {
const res = await load.call(this.model, ...args);
const root = this.model.root;
root.onChanges = async () => {
const changes = root.getChanges();
this.props.info.onRecordChanged(root, changes);
};
return res;
};
}
}
getActiveFields() {
File diff suppressed because it is too large Load Diff
+1 -81
View File
@@ -1,15 +1,11 @@
/** @odoo-module */
import { Domain } from "@web/core/domain";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
export const X2M_TYPES = ["one2many", "many2many"];
const RELATIONAL_TYPES = [...X2M_TYPES, "many2one"];
const NUMERIC_TYPES = ["integer", "float", "monetary"];
/** @typedef {import("./relational_model").OrderTerm} OrderTerm */
/**
* @typedef ViewActiveActions {
* @property {"view"} type
@@ -69,7 +65,7 @@ export function addDependencies(deps, activeFields, fields) {
};
}
if (!(name in fields)) {
fields[name] = { ...dependency };
fields[name] = { ...dependency, name };
}
}
}
@@ -173,18 +169,6 @@ export const computeReportMeasures = (fields, fieldAttrs, activeMeasures) => {
return Object.fromEntries(sortedMeasures);
};
/**
* @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);
}
/**
* @param {String} fieldName
* @param {Object} rawAttrs
@@ -242,32 +226,6 @@ export function getDecoration(rootNode) {
return decorations;
}
/**
* @param {number | number[]} idsList
* @returns {number[]}
*/
export function getIds(idsList) {
if (Array.isArray(idsList)) {
if (idsList.length === 2 && typeof idsList[1] === "string") {
return [idsList[0]];
} else {
return idsList;
}
} else if (idsList) {
return [idsList];
} else {
return [];
}
}
/**
* @param {any} field
* @returns {boolean}
*/
export function isRelational(field) {
return field && RELATIONAL_TYPES.includes(field.type);
}
/**
* @param {any} field
* @returns {boolean}
@@ -338,44 +296,6 @@ export function processMeasure(measure) {
return measure === "__count__" ? "__count" : measure;
}
/**
* @typedef {Object} OrderTerm ?
* @property {string} name
* @property {boolean} asc
*/
/**
* @param {OrderTerm[]} orderBy
* @returns {string}
*/
export function orderByToString(orderBy) {
return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", ");
}
/**
* @param {any} string
* @return {OrderTerm[]}
*/
export function stringToOrderBy(string) {
if (!string) {
return [];
}
return string.split(",").map((order) => {
const splitOrder = order.trim().split(" ");
if (splitOrder.length === 2) {
return {
name: splitOrder[0],
asc: splitOrder[1].toLowerCase() === "asc",
};
} else {
return {
name: splitOrder[0],
asc: true,
};
}
});
}
/**
* Transforms a string into a valid expression to be injected
* in a template as a props via setAttribute.
+1 -1
View File
@@ -69,7 +69,7 @@ export function getDefaultConfig() {
return config;
}
/** @typedef {import("./relational_model").OrderTerm} OrderTerm */
/** @typedef {import("./utils").OrderTerm} OrderTerm */
/** @typedef {Object} ViewProps
* @property {string} resModel
@@ -74,9 +74,8 @@ export function useViewButtons(model, ref, options = {}) {
buttonContext,
onClose: async () => {
if (!closeDialog && status(comp) !== "destroyed") {
const reload = options.reload || (() => model.root.load());
const reload = options.reload || (() => model.load());
await reload();
comp.render(true); // FIXME WOWL reactivity
}
},
});
@@ -35,7 +35,7 @@ export class FormViewDialog extends Component {
this.props.close();
},
saveRecord: async (record, { saveAndNew }) => {
const saved = await record.save({ stayInEdition: true, noReload: true });
const saved = await record.save({ noReload: true });
if (saved) {
await this.props.onRecordSaved(record);
if (saveAndNew) {
@@ -45,7 +45,8 @@ export class FormViewDialog extends Component {
delete context[k];
}
});
await record.model.load({ resId: null, context });
await record.load(false, context);
// await record.model.load({ resId: null, context }); // FIXME context
} else {
this.props.close();
}
@@ -57,12 +57,11 @@ export class AttachDocumentWidget extends Component {
async onFileUploaded(files) {
const { action, record } = this.props;
if (action) {
const { model, resId, resModel } = record;
const { resId, resModel } = record;
await this.env.services.orm.call(resModel, action, [resId], {
attachment_ids: files.map((file) => file.id),
});
await record.load();
model.notify();
}
}
@@ -1,8 +1,8 @@
/* @odoo-module */
import { evalDomain } from "@web/core/domain";
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { evalDomain } from "@web/views/utils";
import { Component, xml } from "@odoo/owl";
const viewWidgetRegistry = registry.category("view_widgets");
@@ -673,6 +673,9 @@ function makeActionManager(env) {
onError(this.onError);
}
onError(error) {
if (!this.isMounted) {
reject(error);
}
cleanDomFromBootstrap();
if (this.isMounted) {
// the error occurred on the controller which is
@@ -768,7 +771,6 @@ function makeActionManager(env) {
ControllerComponent.props = {
"*": true,
};
let nextDialog = null;
if (action.target === "new") {
cleanDomFromBootstrap();
@@ -41,6 +41,16 @@ export class SettingsFormController extends formView.Controller {
this.initialApp = "module" in this.props.context ? this.props.context.module : "";
}
get modelParams() {
const headerFields = Object.values(this.archInfo.fieldNodes)
.filter((fieldNode) => fieldNode.options.isHeaderField)
.map((fieldNode) => fieldNode.name);
return {
...super.modelParams,
headerFields,
};
}
/**
* @override
*/
@@ -49,7 +59,7 @@ export class SettingsFormController extends formView.Controller {
return true;
}
if (
this.model.root.isDirty &&
(await this.model.root.isDirty()) &&
!["execute"].includes(clickParams.name) &&
!clickParams.noSaveDialog
) {
@@ -63,8 +73,9 @@ export class SettingsFormController extends formView.Controller {
return this.env._t("Settings");
}
beforeLeave() {
if (this.model.root.isDirty) {
async beforeLeave() {
const dirty = await this.model.root.isDirty();
if (dirty) {
return this._confirmSave();
}
}
@@ -116,7 +127,7 @@ export class SettingsFormController extends formView.Controller {
},
cancel: async () => {
await this.model.root.discard();
await this.model.root.save({ stayInEdition: true });
await this.model.root.save();
_continue = true;
resolve();
},
@@ -1,44 +1,47 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { intersection } from "@web/core/utils/arrays";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { formView } from "@web/views/form/form_view";
import { SettingsFormController } from "./settings_form_controller";
import { SettingsFormRenderer } from "./settings_form_renderer";
import { SettingsFormCompiler } from "./settings_form_compiler";
import BasicModel from "web.BasicModel";
import { SettingsArchParser } from "./settings_form_arch_parser";
const BaseSettingsModel = BasicModel.extend({
isNew(id) {
return this.localData[id].model === "res.config.settings"
? true
: this._super.apply(this, arguments);
},
_applyChange: function (recordID, changes, options) {
// Check if the changes isHeaderField.
const record = this.localData[recordID];
let isHeaderField = false;
for (const fieldName of Object.keys(changes)) {
const fieldInfo = record.fieldsInfo[options.viewType][fieldName];
isHeaderField = fieldInfo.options && fieldInfo.options.isHeaderField;
class SettingRecord extends formView.Model.Record {
_update(changes) {
const changedFields = Object.keys(changes);
let dirty = true;
if (intersection(changedFields, this.model._headerFields).length === changedFields.length) {
dirty = this.dirty;
}
if (isHeaderField) {
options.doNotSetDirty = true;
}
return this._super.apply(this, arguments);
},
});
const prom = super._update(...arguments);
this.dirty = dirty;
return prom;
}
}
class SettingsRelationalModel extends formView.Model {}
SettingsRelationalModel.LegacyModel = BaseSettingsModel;
class SettingModel extends formView.Model {
setup(params) {
super.setup(...arguments);
this._headerFields = params.headerFields;
}
_getNextConfig() {
const nextConfig = super._getNextConfig(...arguments);
nextConfig.resId = false;
return nextConfig;
}
}
SettingModel.Record = SettingRecord;
export const settingsFormView = {
...formView,
display: {},
buttonTemplate: "web.SettingsFormView.Buttons",
ArchParser: SettingsArchParser,
Model: SettingsRelationalModel,
Model: SettingModel,
ControlPanel: ControlPanel,
Controller: SettingsFormController,
Compiler: SettingsFormCompiler,

Some files were not shown because too many files have changed in this diff Show More