From 7bfbd75d57ebf6d9c1a3023208bf2d340b56c0e9 Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Thu, 20 Jul 2023 08:58:54 +0000 Subject: [PATCH 01/33] [FIX] core: web_read() on new records This completes 96a98f4f4c9885ced0d44829341a951ca4a963c5 in the case where a many2one field has a new record as value, even when that many2one field is not a delegate field. Part-of: odoo/odoo#114024 --- addons/web/models/models.py | 21 ++++++++--- .../test_new_api/tests/test_unity_read.py | 35 +++++++++++++++++++ odoo/fields.py | 4 +-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/addons/web/models/models.py b/addons/web/models/models.py index 240eddf7b9c..b5c1e30e51d 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -104,11 +104,17 @@ class Base(models.AbstractModel): return vals for field_name, field_spec in specification.items(): - if not field_spec: + field = self._fields.get(field_name) + if field is None: continue - field = self._fields[field_name] - if field.type == 'many2one' and 'fields' in field_spec: + if field.type == 'many2one': + if 'fields' not in field_spec: + for values in values_list: + if isinstance(values[field_name], NewId): + values[field_name] = values[field_name].origin + continue + co_records = self[field_name] if 'context' in field_spec: co_records = co_records.with_context(**field_spec['context']) @@ -128,9 +134,13 @@ class Base(models.AbstractModel): for values in values_list: if values[field_name] is False: continue - values[field_name] = many2one_data[values[field_name]] + vals = many2one_data[values[field_name]] + values[field_name] = vals['id'] and vals elif field.type in ('one2many', 'many2many'): + if not field_spec: + continue + co_records = self[field_name] if 'order' in field_spec and field_spec['order']: @@ -164,6 +174,9 @@ class Base(models.AbstractModel): values[field_name] = [x2many_data.get(id_) or {'id': id_} for id_ in values[field_name]] elif field.type in ('reference', 'many2one_reference'): + if not field_spec: + continue + values_by_id = { vals['id']: vals for vals in values_list diff --git a/odoo/addons/test_new_api/tests/test_unity_read.py b/odoo/addons/test_new_api/tests/test_unity_read.py index 3fe3f3c67a2..b9c97d495b0 100644 --- a/odoo/addons/test_new_api/tests/test_unity_read.py +++ b/odoo/addons/test_new_api/tests/test_unity_read.py @@ -152,6 +152,41 @@ class TestUnityRead(TransactionCase): } }]) + def test_read_many2one_with_new_record(self): + values = {'author_id': {'id': self.author.id}} + new_course = self.course.new(values, origin=self.course) + + # new_course.author_id is a new record + self.assertTrue(new_course.author_id) + self.assertFalse(new_course.author_id.id) + + result = new_course.web_read({ + 'display_name': {}, + 'author_id': {}, + }) + self.assertEqual(result, [ + { + 'id': new_course.id, + 'display_name': 'introduction to OWL', + 'author_id': self.author.id, + } + ]) + + result = new_course.web_read({ + 'display_name': {}, + 'author_id': {'fields': {'display_name': {}}}, + }) + self.assertEqual(result, [ + { + 'id': new_course.id, + 'display_name': 'introduction to OWL', + 'author_id': { + 'id': self.author.id, + 'display_name': 'ged' + } + } + ]) + def test_new_record_with_inherits(self): # virtualize a record new_account = self.account.new(origin=self.account) diff --git a/odoo/fields.py b/odoo/fields.py index 53a361e93f5..8b7b110a0f4 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -3032,10 +3032,8 @@ class Many2one(_Relational): except MissingError: # Should not happen, unless the foreign key is missing. return False - elif self.delegate: - return value.id else: - return value._origin.id + return value.id def convert_to_write(self, value, record): if type(value) in IdType: From 8723f020c3587a900c811b8cc23f53fe34b98df3 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Wed, 19 Jul 2023 12:10:51 +0000 Subject: [PATCH 02/33] [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@7d2baaa0c726a7b0dd4fe7226862f960c9c59b73 [2] odoo/odoo@f5e6494da3aedb545ead34d0d7f4b1b86499cb7f Task~3179751 Part-of: odoo/odoo#114024 Co-authored-by: Aaron Bohy Co-authored-by: FrancoisGe Co-authored-by: Jorge Pinna Puissant Co-authored-by: Pierre Rousseau --- addons/web/__manifest__.py | 3 +- addons/web/static/src/core/domain.js | 12 + addons/web/static/src/core/utils/reactive.js | 2 +- addons/web/static/src/core/utils/timing.js | 19 + .../crash_manager_warning_dialog.js | 30 - .../src/legacy/js/views/abstract_model.js | 124 - .../src/legacy/js/views/basic/basic_model.js | 5404 ----------------- .../legacy/js/views/field_manager_mixin.js | 165 - .../views/standalone_field_manager_mixin.js | 60 - .../static/src/legacy/js/views/view_utils.js | 112 - .../web/static/src/{views => model}/model.js | 73 +- .../src/model/relational_model/datapoint.js | 92 + .../relational_model/dynamic_group_list.js | 291 + .../model/relational_model/dynamic_list.js | 388 ++ .../relational_model/dynamic_record_list.js | 184 + .../src/model/relational_model/group.js | 132 + .../src/model/relational_model/record.js | 901 +++ .../relational_model/relational_model.js | 657 ++ .../src/model/relational_model/static_list.js | 890 +++ .../src/model/relational_model/utils.js | 523 ++ .../src/{views => model}/sample_server.js | 103 +- addons/web/static/src/search/search_model.js | 2 +- .../web/static/src/search/utils/order_by.js | 39 + .../src/views/basic_relational_model.js | 1445 ----- .../views/calendar/calendar_arch_parser.js | 6 +- .../calendar_common_popover.js | 12 +- .../calendar_common_popover.xml | 10 +- .../src/views/calendar/calendar_controller.js | 2 +- .../src/views/calendar/calendar_model.js | 10 +- addons/web/static/src/views/debug_items.js | 40 +- .../static/src/views/fields/ace/ace_field.js | 30 +- .../badge_selection/badge_selection_field.js | 48 +- .../src/views/fields/binary/binary_field.js | 15 +- .../src/views/fields/binary/binary_field.xml | 2 +- .../src/views/fields/color/color_field.js | 12 +- .../src/views/fields/color/color_field.xml | 4 +- .../copy_clipboard/copy_clipboard_field.js | 2 +- addons/web/static/src/views/fields/field.js | 160 +- .../font_selection/font_selection_field.js | 1 - .../src/views/fields/image/image_field.js | 18 +- .../src/views/fields/input_field_hook.js | 9 +- .../many2many_binary_field.js | 9 +- .../many2many_checkboxes_field.js | 25 +- .../many2many_tags/many2many_tags_field.js | 1 - .../views/fields/many2one/many2one_field.js | 4 +- .../fields/progress_bar/progress_bar_field.js | 99 +- .../progress_bar/progress_bar_field.xml | 7 +- .../fields/properties/properties_field.js | 8 +- .../src/views/fields/radio/radio_field.js | 60 +- .../views/fields/reference/reference_field.js | 220 +- .../fields/reference/reference_field.xml | 2 +- .../src/views/fields/relational_utils.js | 191 +- .../views/fields/selection/selection_field.js | 43 +- .../src/views/fields/standard_field_props.js | 2 +- .../views/fields/statusbar/statusbar_field.js | 64 +- .../src/views/fields/translation_button.js | 2 +- .../src/views/fields/x2many/x2many_field.js | 32 +- .../static/src/views/form/form_arch_parser.js | 31 +- .../static/src/views/form/form_compiler.js | 4 +- .../static/src/views/form/form_controller.js | 248 +- .../form_error_dialog/form_error_dialog.js | 39 - .../form_error_dialog/form_error_dialog.xml | 2 +- .../static/src/views/form/form_renderer.js | 2 +- .../form_status_indicator.js | 2 +- addons/web/static/src/views/form/form_view.js | 2 +- .../src/views/graph/graph_controller.js | 2 +- .../web/static/src/views/graph/graph_model.js | 2 +- .../src/views/kanban/kanban_arch_parser.js | 34 +- .../src/views/kanban/kanban_controller.js | 133 +- .../src/views/kanban/kanban_controller.xml | 1 + .../static/src/views/kanban/kanban_header.js | 7 +- .../static/src/views/kanban/kanban_model.js | 229 - .../static/src/views/kanban/kanban_record.js | 26 +- .../kanban/kanban_record_quick_create.js | 78 +- .../src/views/kanban/kanban_renderer.js | 22 +- .../src/views/kanban/kanban_renderer.xml | 2 + .../static/src/views/kanban/kanban_view.js | 6 +- .../src/views/kanban/progress_bar_hook.js | 4 +- addons/web/static/src/views/legacy_utils.js | 147 - .../static/src/views/list/list_arch_parser.js | 43 +- .../views/list/list_confirmation_dialog.js | 1 - .../views/list/list_confirmation_dialog.xml | 2 +- .../static/src/views/list/list_controller.js | 208 +- .../static/src/views/list/list_controller.xml | 2 +- .../static/src/views/list/list_renderer.js | 171 +- .../static/src/views/list/list_renderer.xml | 4 +- addons/web/static/src/views/list/list_view.js | 4 +- .../src/views/pivot/pivot_controller.js | 2 +- .../web/static/src/views/pivot/pivot_model.js | 2 +- addons/web/static/src/views/record.js | 97 +- .../web/static/src/views/relational_model.js | 3607 ----------- addons/web/static/src/views/utils.js | 82 +- addons/web/static/src/views/view.js | 2 +- .../src/views/view_button/view_button_hook.js | 3 +- .../views/view_dialogs/form_view_dialog.js | 5 +- .../attach_document/attach_document.js | 3 +- addons/web/static/src/views/widgets/widget.js | 2 +- .../src/webclient/actions/action_service.js | 4 +- .../settings_form_controller.js | 19 +- .../settings_form_view/settings_form_view.js | 49 +- .../settings_form_view/settings_form_view.xml | 2 +- .../tests/core/debug/debug_manager_tests.js | 6 +- .../static/tests/core/utils/hooks_tests.js | 75 + .../static/tests/core/utils/timing_tests.js | 626 +- .../web/static/tests/helpers/mock_server.js | 211 +- .../web/static/tests/helpers/mock_services.js | 2 +- .../tests/legacy/views/basic_model_tests.js | 2498 -------- .../fields/many2one_barcode_field_tests.js | 2 +- .../tests/mobile/views/form_view_tests.js | 4 +- .../tests/mobile/views/kanban_view_tests.js | 8 +- .../view_dialog/select_create_dialog_tests.js | 4 +- .../{views => model}/sample_server_tests.js | 16 +- addons/web/static/tests/qunit.js | 50 + .../static/tests/search/search_panel_tests.js | 4 +- .../calendar/calendar_arch_parser_tests.js | 2 +- .../views/calendar/calendar_view_tests.js | 48 +- .../static/tests/views/calendar/helpers.js | 11 +- .../views/fields/ace_editor_field_tests.js | 4 +- .../tests/views/fields/binary_field_tests.js | 8 +- .../tests/views/fields/char_field_tests.js | 2 +- .../tests/views/fields/color_field_tests.js | 43 +- .../views/fields/datetime_field_tests.js | 6 +- .../tests/views/fields/domain_field_tests.js | 10 +- .../tests/views/fields/email_field_tests.js | 2 +- .../tests/views/fields/image_field_tests.js | 64 +- .../fields/many2many_binary_field_tests.js | 30 +- .../many2many_checkboxes_field_tests.js | 4 +- .../views/fields/many2many_field_tests.js | 185 +- .../fields/many2many_tags_field_tests.js | 100 +- .../fields/many2one_barcode_field_tests.js | 4 +- .../views/fields/many2one_field_tests.js | 372 +- .../views/fields/monetary_field_tests.js | 2 +- .../views/fields/one2many_field_tests.js | 1909 +++--- .../views/fields/pdf_viewer_field_tests.js | 2 +- .../views/fields/progress_bar_field_tests.js | 8 +- .../views/fields/properties_field_tests.js | 10 +- .../tests/views/fields/radio_field_tests.js | 73 +- .../views/fields/reference_field_tests.js | 91 +- .../views/fields/selection_field_tests.js | 57 +- .../views/fields/statusbar_field_tests.js | 56 +- .../tests/views/form/form_compiler_tests.js | 14 +- .../tests/views/form/form_view_tests.js | 917 +-- .../static/tests/views/graph_view_tests.js | 2 +- .../tests/views/kanban/kanban_view_tests.js | 989 ++- .../web/static/tests/views/list_view_tests.js | 549 +- .../static/tests/views/pivot_view_tests.js | 8 +- addons/web/static/tests/views/record_tests.js | 36 +- .../view_dialogs/form_view_dialog_tests.js | 9 +- .../select_create_dialog_tests.js | 15 +- .../views/widgets/attach_document_tests.js | 14 +- .../webclient/actions/close_action_tests.js | 4 +- .../webclient/actions/concurrency_tests.js | 66 +- .../webclient/actions/error_handling_tests.js | 2 +- .../webclient/actions/load_state_tests.js | 90 +- .../webclient/actions/push_state_tests.js | 3 +- .../webclient/actions/server_action_tests.js | 4 +- .../tests/webclient/actions/target_tests.js | 10 +- .../webclient/actions/window_action_tests.js | 194 +- .../settings_form_view_tests.js | 37 +- 159 files changed, 9099 insertions(+), 18611 deletions(-) delete mode 100644 addons/web/static/src/legacy/js/_deprecated/crash_manager_warning_dialog.js delete mode 100644 addons/web/static/src/legacy/js/views/abstract_model.js delete mode 100644 addons/web/static/src/legacy/js/views/basic/basic_model.js delete mode 100644 addons/web/static/src/legacy/js/views/field_manager_mixin.js delete mode 100644 addons/web/static/src/legacy/js/views/standalone_field_manager_mixin.js delete mode 100644 addons/web/static/src/legacy/js/views/view_utils.js rename addons/web/static/src/{views => model}/model.js (69%) create mode 100644 addons/web/static/src/model/relational_model/datapoint.js create mode 100644 addons/web/static/src/model/relational_model/dynamic_group_list.js create mode 100644 addons/web/static/src/model/relational_model/dynamic_list.js create mode 100644 addons/web/static/src/model/relational_model/dynamic_record_list.js create mode 100644 addons/web/static/src/model/relational_model/group.js create mode 100644 addons/web/static/src/model/relational_model/record.js create mode 100644 addons/web/static/src/model/relational_model/relational_model.js create mode 100644 addons/web/static/src/model/relational_model/static_list.js create mode 100644 addons/web/static/src/model/relational_model/utils.js rename addons/web/static/src/{views => model}/sample_server.js (90%) create mode 100644 addons/web/static/src/search/utils/order_by.js delete mode 100644 addons/web/static/src/views/basic_relational_model.js delete mode 100644 addons/web/static/src/views/kanban/kanban_model.js delete mode 100644 addons/web/static/src/views/legacy_utils.js delete mode 100644 addons/web/static/src/views/relational_model.js delete mode 100644 addons/web/static/tests/legacy/views/basic_model_tests.js rename addons/web/static/tests/{views => model}/sample_server_tests.js (97%) diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index bb58bf57d3a..87af9250a1f 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -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', diff --git a/addons/web/static/src/core/domain.js b/addons/web/static/src/core/domain.js index bff10755bdd..889dde0b0c3 100644 --- a/addons/web/static/src/core/domain.js +++ b/addons/web/static/src/core/domain.js @@ -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} */ diff --git a/addons/web/static/src/core/utils/reactive.js b/addons/web/static/src/core/utils/reactive.js index c4770ebf070..ebf48988657 100644 --- a/addons/web/static/src/core/utils/reactive.js +++ b/addons/web/static/src/core/utils/reactive.js @@ -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) { diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js index 03246e2c172..1f5f43dee0a 100644 --- a/addons/web/static/src/core/utils/timing.js +++ b/addons/web/static/src/core/utils/timing.js @@ -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 diff --git a/addons/web/static/src/legacy/js/_deprecated/crash_manager_warning_dialog.js b/addons/web/static/src/legacy/js/_deprecated/crash_manager_warning_dialog.js deleted file mode 100644 index 9697257d59f..00000000000 --- a/addons/web/static/src/legacy/js/_deprecated/crash_manager_warning_dialog.js +++ /dev/null @@ -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}); - }, -}); diff --git a/addons/web/static/src/legacy/js/views/abstract_model.js b/addons/web/static/src/legacy/js/views/abstract_model.js deleted file mode 100644 index 439829eb85f..00000000000 --- a/addons/web/static/src/legacy/js/views/abstract_model.js +++ /dev/null @@ -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; diff --git a/addons/web/static/src/legacy/js/views/basic/basic_model.js b/addons/web/static/src/legacy/js/views/basic/basic_model.js deleted file mode 100644 index 803d7591bf6..00000000000 --- a/addons/web/static/src/legacy/js/views/basic/basic_model.js +++ /dev/null @@ -1,5404 +0,0 @@ -/** @odoo-module alias=web.BasicModel **/ - -/** - * Basic Model - * - * This class contains all the logic necessary to communicate between the - * python models and the web client. More specifically, its job is to give a - * simple unified API to the rest of the web client (in particular, the views and - * the field widgets) to query and modify actual records in db. - * - * From a high level perspective, BasicModel is essentially a hashmap with - * integer keys and some data and metadata object as value. Each object in this - * hashmap represents a piece of data, and can be reloaded and modified by using - * its id as key in many methods. - * - * Here is a description of what those data point look like: - * var dataPoint = { - * _cache: {Object|undefined} - * _changes: {Object|null}, - * aggregateValues: {Object}, - * context: {Object}, - * count: {integer}, - * data: {Object|Object[]}, - * domain: {*[]}, - * fields: {Object}, - * fieldsInfo: {Object}, - * getContext: {function}, - * getDomain: {function}, - * getFieldNames: {function}, - * groupedBy: {string[]}, - * id: {integer}, - * isOpen: {boolean}, - * loadMoreOffset: {integer}, - * limit: {integer}, - * model: {string}, - * offset: {integer}, - * openGroupByDefault: {boolean}, - * orderedBy: {Object[]}, - * orderedResIDs: {integer[]}, - * parentID: {string}, - * rawContext: {Object}, - * relationField: {string}, - * res_id: {integer|null}, - * res_ids: {integer[]}, - * specialData: {Object}, - * _specialDataCache: {Object}, - * static: {boolean}, - * type: {string} 'record' | 'list' - * value: ?, - * }; - * - * Notes: - * - id: is totally unrelated to res_id. id is a web client local concept - * - res_id: if set to a number or a virtual id (a virtual id is a character - * string composed of an integer and has a dash and other information), it - * is an actual id for a record in the server database. If set to - * 'virtual_' + number, it is a record not yet saved (so, in create mode). - * - res_ids: if set, it represent the context in which the data point is actually - * used. For example, a given record in a form view (opened from a list view) - * might have a res_id = 2 and res_ids = [1,2,3] - * - offset: this is mainly used for pagination. Useful when we need to load - * another page, then we can simply change the offset and reload. - * - count is basically the number of records being manipulated. We can't use - * res_ids, because we might have a very large number of records, or a - * domain, and the res_ids would be the current page, not the full set. - * - model is the actual name of a (odoo) model, such as 'res.partner' - * - fields contains the description of all the fields from the model. Note that - * these properties might have been modified by a view (for example, with - * required=true. So, the fields kind of depends of the context of the - * data point. - * - field_names: list of some relevant field names (string). Usually, it - * denotes the fields present in the view. Only those fields should be - * loaded. - * - _cache and _changes are private, they should not leak out of the basicModel - * and be used by anyone else. - * - * Commands: - * commands are the base commands for x2many (0 -> 6), but with a - * slight twist: each [0, _, values] command is augmented with a virtual id: - * it means that when the command is added in basicmodel, it generates an id - * looking like this: 'virtual_' + number, and uses this id to identify the - * element, so it can be edited later. - */ - -import { intersection, sortBy, unique } from "@web/core/utils/arrays"; -import { uniqueId } from "@web/core/utils/functions"; -import { omit, pick } from "@web/core/utils/objects"; -import { sprintf } from "@web/core/utils/strings"; -import AbstractModel from "web.AbstractModel"; -import concurrency from "web.concurrency"; -import Context from "web.Context"; -import core from "web.core"; -import Domain from "web.Domain"; -import session from "web.session"; -import utils from "web.utils"; -import viewUtils from "web.viewUtils"; - -var _t = core._t; - -const SENTINEL = null; - -// field types that can be aggregated in grouped views -const AGGREGATABLE_TYPES = ['float', 'integer', 'monetary']; - -var x2ManyCommands = { - // (0, virtualID, {values}) - CREATE: 0, - create: function (virtualID, values) { - delete values.id; - return [x2ManyCommands.CREATE, virtualID || false, values]; - }, - // (1, id, {values}) - UPDATE: 1, - update: function (id, values) { - delete values.id; - return [x2ManyCommands.UPDATE, id, values]; - }, - // (2, id[, _]) - DELETE: 2, - delete: function (id) { - return [x2ManyCommands.DELETE, id, false]; - }, - // (3, id[, _]) removes relation, but not linked record itself - FORGET: 3, - forget: function (id) { - return [x2ManyCommands.FORGET, id, false]; - }, - // (4, id[, _]) - LINK_TO: 4, - link_to: function (id) { - return [x2ManyCommands.LINK_TO, id, false]; - }, - // (5[, _[, _]]) - DELETE_ALL: 5, - delete_all: function () { - return [5, false, false]; - }, - // (6, _, ids) replaces all linked records with provided ids - REPLACE_WITH: 6, - replace_with: function (ids) { - return [6, false, ids]; - } -}; - -var BasicModel = AbstractModel.extend({ - // constants - OPEN_GROUP_LIMIT: 10, // after this limit, groups are automatically folded - - /** - * @override - */ - init: function () { - // this mutex is necessary to make sure some operations are done - // sequentially, for example, an onchange needs to be completed before a - // save is performed. - this.mutex = new concurrency.Mutex(); - this.bypassMutex = false; // never set this to true manually (see @executeDirectly) - - // this array is used to accumulate RPC requests done in the same call - // stack, so that they can be batched in the minimum number of RPCs - this.batchedRPCsRequests = []; - - this.localData = Object.create(null); - // used to generate dataPoint ids. Note that the counter is set to 0 for - // each instance, and this is mandatory for the sample data feature to - // work: we need both the main model and the sample model to generate the - // same datapoint ids for their common data (groups, when there are real - // groups in database), so that we can easily do the mapping between - // real and sample data. - this.__id = 0; - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Add a default record to a list object. This method actually makes a new - * record with the _makeDefaultRecord method, then adds it to the list object. - * The default record is added in the data directly. This is meant to be used - * by list or kanban controllers (i.e. not for x2manys in form views, as in - * this case, we store changes as commands). - * - * @param {string} listID a valid handle for a list object - * @param {Object} [options] - * @param {string} [options.position=top] if the new record should be added - * on top or on bottom of the list - * @returns {Promise} resolves to the id of the new created record - */ - addDefaultRecord: function (listID, options) { - var self = this; - var list = this.localData[listID]; - var context = Object.assign({}, this._getDefaultContext(list), this._getContext(list)); - - var position = (options && options.position) || 'top'; - var params = { - context: context, - fields: list.fields, - fieldsInfo: list.fieldsInfo, - parentID: list.id, - position: position, - viewType: list.viewType, - }; - return this._makeDefaultRecord(list.model, params).then(function (id) { - list.count++; - if (position === 'top') { - list.data.unshift(id); - } else { - list.data.push(id); - } - var record = self.localData[id]; - list._cache[record.res_id] = id; - return id; - }); - }, - /** - * Completes the fields and fieldsInfo of a dataPoint with the given ones. - * It is useful for the cases where a record element is shared between - * various views, such as a one2many with a tree and a form view. - * - * @param {string} datapointID a valid element ID (of type 'list' or 'record') - * @param {Object} viewInfo - * @param {Object} viewInfo.fields - * @param {Object} viewInfo.fieldInfo - * @param {string} viewInfo.viewType - * @returns {Promise} resolved when the fieldInfo have been set on the given - * datapoint and all its children, and all rawChanges have been applied - */ - addFieldsInfo: async function (dataPointID, viewInfo) { - var dataPoint = this.localData[dataPointID]; - dataPoint.fields = Object.assign({}, dataPoint.fields, viewInfo.fields); - // complete the given fieldInfo with the fields of the main view, so - // that those field will be reloaded if a reload is triggered by the - // secondary view - dataPoint.fieldsInfo = dataPoint.fieldsInfo || {}; - const mainFieldInfo = dataPoint.fieldsInfo[dataPoint[viewInfo.viewType]]; - dataPoint.fieldsInfo[viewInfo.viewType] = Object.assign( - {}, - mainFieldInfo, - viewInfo.fieldInfo - ); - - // Some fields in the new fields info might not be in the previous one, - // so we might have stored changes for them (e.g. coming from onchange - // RPCs), that we haven't been able to process earlier (because those - // fields were unknown at that time). So we now try to process them. - if (dataPoint.type === 'record') { - await this.applyRawChanges(dataPointID, viewInfo.viewType); - } - const proms = []; - const fieldInfo = dataPoint.fieldsInfo[viewInfo.viewType]; - // recursively apply the new field info on sub datapoints - if (dataPoint.type === 'list') { - // case 'list': on all datapoints in the list - Object.values(dataPoint._cache).forEach(subDataPointID => { - proms.push(this.addFieldsInfo(subDataPointID, { - fields: dataPoint.fields, - fieldInfo: dataPoint.fieldsInfo[viewInfo.viewType], - viewType: viewInfo.viewType, - })); - }); - } else { - // case 'record': on datapoints of all x2many fields - const values = Object.assign({}, dataPoint.data, dataPoint._changes); - Object.keys(fieldInfo).forEach(fieldName => { - const fieldType = dataPoint.fields[fieldName].type; - if (fieldType === 'one2many' || fieldType === 'many2many') { - const mode = fieldInfo[fieldName].mode; - const views = fieldInfo[fieldName].views; - const x2mDataPointID = values[fieldName]; - if (views[mode] && x2mDataPointID) { - proms.push(this.addFieldsInfo(x2mDataPointID, { - fields: views[mode].fields, - fieldInfo: views[mode].fieldsInfo[mode], - viewType: mode, - })); - } - } - }); - } - return Promise.all(proms); - }, - /** - * Onchange RPCs may return values for fields that are not in the current - * view. Those fields might even be unknown when the onchange returns (e.g. - * in x2manys, we only know the fields that are used in the inner view, but - * not those used in the potential form view opened in a dialog when a sub- - * record is clicked). When this happens, we can't infer their type, so the - * given value can't be processed. It is instead stored in the '_rawChanges' - * key of the record, without any processing. Later on, if this record is - * displayed in another view (e.g. the user clicked on it in the x2many - * list, and the record opens in a dialog), those changes that were left - * behind must be applied. This function applies changes stored in - * '_rawChanges' for a given viewType. - * - * @param {string} recordID local resource id of a record - * @param {string} viewType the current viewType - * @returns {Promise} resolves to the id of the record - */ - applyRawChanges: function (recordID, viewType) { - var record = this.localData[recordID]; - return this._applyOnChange(record._rawChanges, record, { viewType }).then(function () { - return record.id; - }); - }, - /** - * Returns true if a record can be abandoned. - * - * Case for not abandoning the record: - * - * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any - * `onchange` from a `default_get`) - * 2. registered in a list on addition - * - * 2.1. registered as non-new addition - * 2.2. registered as new additon on update - * - * 3. record is not new - * - * Otherwise, the record can be abandoned. - * - * This is useful when discarding changes on this record, as it means that - * we must keep the record even if some fields are invalids (e.g. required - * field is empty). - * - * @param {string} id id for a local resource - * @returns {boolean} - */ - canBeAbandoned: function (id) { - // 1. no drop if flagged - if (this.localData[id]._noAbandon) { - return false; - } - // 2. no drop in a list on "ADD in some cases - var record = this.localData[id]; - var parent = this.localData[record.parentID]; - if (parent) { - var entry = parent._savePoint.find((p) => p.operation === "ADD" && p.id === id); - if (entry) { - // 2.1. no drop on non-new addition in list - if (!entry.isNew) { - return false; - } - // 2.2. no drop on new addition on "UPDATE" - var lastEntry = parent._savePoint.at(-1); - if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) { - return false; - } - } - } - // 3. drop new records - return this.isNew(id); - }, - /** - * Delete a list of records, then, if the records have a parent, reload it. - * - * @todo we should remove the deleted records from the localData - * @todo why can't we infer modelName? Because of grouped datapoint - * --> res_id doesn't correspond to the model and we don't have the - * information about the related model - * - * @param {string[]} recordIds list of local resources ids. They should all - * be of type 'record', be of the same model and have the same parent. - * @param {string} modelName mode name used to unlink the records - * @returns {Promise} - */ - deleteRecords: function (recordIds, modelName) { - var self = this; - var records = recordIds.map((id) => self.localData[id]); - var context = Object.assign(records[0].getContext(), session.user_context); - return this._rpc({ - model: modelName, - method: 'unlink', - args: [records.map(r => r.res_id)], - context: context, - }) - .then(function () { - records.forEach((record) => { - // discard any changes to prevent creating a new record with auto-save - self.discardChanges(record.id); - var parent = record.parentID && self.localData[record.parentID]; - if (parent && parent.type === 'list') { - parent.data = parent.data.filter((x) => x !== record.id); - delete self.localData[record.id]; - // Check if we are on last page and all records are deleted from current - // page i.e. if there is no state.data.length then go to previous page - if (!parent.data.length && parent.offset > 0) { - parent.offset = Math.max(parent.offset - parent.limit, 0); - } - } else { - record.res_ids.splice(record.offset, 1); - record.offset = Math.min(record.offset, record.res_ids.length - 1); - record.res_id = record.res_ids[record.offset]; - record.count--; - } - }); - }); - }, - /** - * Discard all changes in a local resource. Basically, it removes - * everything that was stored in a _changes key. - * - * @param {string} id local resource id - * @param {Object} [options] - * @param {boolean} [options.rollback=false] if true, the changes will - * be reset to the last _savePoint, otherwise, they are reset to null - */ - discardChanges: function (id, options) { - options = options || {}; - var element = this.localData[id]; - var isNew = this.isNew(id); - var rollback = 'rollback' in options ? options.rollback : isNew; - var initialOffset = element.offset; - element._domains = {}; - this._visitChildren(element, function (elem) { - if (rollback && elem._savePoint) { - if (elem._savePoint instanceof Array) { - elem._changes = elem._savePoint.slice(0); - } else { - elem._changes = Object.assign({}, elem._savePoint); - } - elem._isDirty = !isNew; - } else { - elem._changes = null; - elem._isDirty = false; - } - elem.offset = 0; - if (elem.tempLimitIncrement) { - elem.limit -= elem.tempLimitIncrement; - delete elem.tempLimitIncrement; - } - elem.specialData = Object.assign({}, elem._specialDataSavepoint); - }); - element.offset = initialOffset; - }, - /** - * Duplicate a record (by calling the 'copy' route) - * - * @param {string} recordID id for a local resource - * @returns {Promise} resolves to the id of duplicate record - */ - duplicateRecord: function (recordID) { - var self = this; - var record = this.localData[recordID]; - var context = this._getContext(record); - return this._rpc({ - model: record.model, - method: 'copy', - args: [record.data.id], - context: context, - }) - .then(function (res_id) { - var index = record.res_ids.indexOf(record.res_id); - record.res_ids.splice(index + 1, 0, res_id); - return self.load({ - fieldsInfo: record.fieldsInfo, - fields: record.fields, - modelName: record.model, - res_id: res_id, - res_ids: record.res_ids.slice(0), - viewType: record.viewType, - context: context, - }); - }); - }, - /** - * This method allows to execute a callback for which '_notifyChanges' and - * 'save' will bypass the mutex. This is useful when we are leaving Odoo - * (closing tab/browser), and we want to quickly save pending changes (in - * an 'onbeforeunload' handler, which is mostly sync). - * - * This function should never be called except when we are leaving Odoo. - * - * @param {Function} callback - */ - executeDirectly(callback) { - this.bypassMutex = true; - callback(); - this.bypassMutex = false; - }, - /** - * For list resources, this freezes the current records order. - * - * @param {string} listID a valid element ID of type list - */ - freezeOrder: function (listID) { - var list = this.localData[listID]; - if (list.type === 'record') { - return; - } - list = this._applyX2ManyOperations(list); - this._sortList(list); - this.localData[listID].orderedResIDs = list.res_ids; - }, - /** - * The __get method first argument is the handle returned by the load method. - * It is optional (the handle can be undefined). In some case, it makes - * sense to use the handle as a key, for example the BasicModel holds the - * data for various records, each with its local ID. - * - * synchronous method, it assumes that the resource has already been loaded. - * - * @param {string} id local id for the resource - * @param {any} options - * @param {boolean} [options.env=false] if true, will only return res_id - * (if record) or res_ids (if list) - * @param {boolean} [options.raw=false] if true, will not follow relations - * @returns {Object} - */ - __get: function (id, options) { - var self = this; - options = options || {}; - - if (!(id in this.localData)) { - return null; - } - - var element = this.localData[id]; - - if (options.env) { - var env = { - ids: element.res_ids ? element.res_ids.slice(0) : [], - }; - if (element.type === 'record') { - env.currentId = this.isNew(element.id) ? undefined : element.res_id; - } - return env; - } - - if (element.type === 'record') { - var data = Object.assign({}, element.data, element._changes); - var relDataPoint; - for (var fieldName in data) { - var field = element.fields[fieldName]; - if (data[fieldName] === null) { - data[fieldName] = false; - } - if (!field) { - continue; - } - - // get relational datapoint - if (field.type === 'many2one') { - if (options.raw) { - relDataPoint = this.localData[data[fieldName]]; - data[fieldName] = relDataPoint ? relDataPoint.res_id : false; - } else { - data[fieldName] = this.__get(data[fieldName]) || false; - } - } else if (field.type === 'reference') { - if (options.raw) { - relDataPoint = this.localData[data[fieldName]]; - data[fieldName] = relDataPoint ? - relDataPoint.model + ',' + relDataPoint.res_id : - false; - } else { - data[fieldName] = this.__get(data[fieldName]) || false; - } - } else if (field.type === 'one2many' || field.type === 'many2many') { - if (options.raw) { - if (typeof data[fieldName] === 'string') { - relDataPoint = this.localData[data[fieldName]]; - relDataPoint = this._applyX2ManyOperations(relDataPoint); - data[fieldName] = relDataPoint.res_ids; - } else { - // no datapoint has been created yet (because the loading of relational - // data has been batched, and hasn't started yet), so the value is still - // the list of ids in the relation - data[fieldName] = data[fieldName] || []; - } - } else { - data[fieldName] = this.__get(data[fieldName]) || []; - } - } - } - var record = { - context: Object.assign({}, element.context), - count: element.count, - data: data, - domain: element.domain.slice(0), - evalModifiers: element.evalModifiers, - fields: element.fields, - fieldsInfo: element.fieldsInfo, - getContext: element.getContext, - getDomain: element.getDomain, - getFieldNames: element.getFieldNames, - id: element.id, - isDirty: element.isDirty, - isNew: element.isNew, - limit: element.limit, - model: element.model, - offset: element.offset, - ref: element.ref, - res_ids: element.res_ids.slice(0), - specialData: Object.assign({}, element.specialData), - type: 'record', - viewType: element.viewType, - }; - - if (!this.isNew(element.id)) { - record.res_id = element.res_id; - } - var evalContext; - Object.defineProperty(record, 'evalContext', { - get: function () { - evalContext = evalContext || self._getEvalContext(element); - return evalContext; - }, - }); - return record; - } - - // apply potential changes (only for x2many lists) - element = this._applyX2ManyOperations(element); - this._sortList(element); - - if (!element.orderedResIDs && element._changes) { - element._changes.forEach((change) => { - if (change.operation === 'ADD' && change.isNew) { - element.data = element.data.filter((x) => x !== change.id); - if (change.position === 'top') { - element.data.unshift(change.id); - } else { - element.data.push(change.id); - } - } - }); - } - - var list = { - aggregateValues: Object.assign({}, element.aggregateValues), - context: Object.assign({}, element.context), - count: element.count, - data: element.data.map((elemID) => { - return self.__get(elemID, options); - }), - domain: element.domain.slice(0), - fields: element.fields, - getContext: element.getContext, - getDomain: element.getDomain, - getFieldNames: element.getFieldNames, - groupedBy: element.groupedBy, - groupsCount: element.groupsCount, - groupsLimit: element.groupsLimit, - groupsOffset: element.groupsOffset, - id: element.id, - isDirty: element.isDirty, - isM2MGrouped: element.isM2MGrouped, - isNew: element.isNew, - isOpen: element.isOpen, - isSample: this.isSampleModel, - limit: element.limit, - model: element.model, - offset: element.offset, - orderedBy: element.orderedBy, - res_id: element.res_id, - res_ids: element.res_ids.slice(0), - type: 'list', - value: element.value, - range: element.range, - viewType: element.viewType, - }; - if (element.fieldsInfo) { - list.fieldsInfo = element.fieldsInfo; - } - return list; - }, - /** - * Generate default values for a given record. Those values are stored in - * the '_changes' key of the record. For relational fields, sub-dataPoints - * are created, and missing relational data is fetched. - * Typically, this function is called when a new record is created. It may - * also be called when a one2many subrecord is open in a form view (dialog), - * to generate the default values for the fields displayed in the o2m form - * view, but not in the list or kanban (mainly to correctly create - * sub-dataPoints for relational fields). - * - * @param {string} recordID local id for a record - * @param {Object} [options] - * @param {string} [options.viewType] current viewType. If not set, we will - * assume main viewType from the record - * @param {Array} [options.fieldNames] list of field names for which a - * default value must be generated (used to complete the values dict) - * @returns {Promise} - */ - generateDefaultValues(recordID, options = {}) { - const record = this.localData[recordID]; - const viewType = options.viewType || record.viewType; - const fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]); - const numericFields = ['float', 'integer', 'monetary']; - const proms = []; - record._changes = record._changes || {}; - fieldNames.forEach(fieldName => { - record.data[fieldName] = null; - if (!(fieldName in record._changes)) { - const field = record.fields[fieldName]; - if (numericFields.includes(field.type)) { - record._changes[fieldName] = 0; - } else if (field.type === 'one2many' || field.type === 'many2many') { - proms.push(this._processX2ManyCommands(record, fieldName, [], options)); - } else { - record._changes[fieldName] = null; - } - } - }); - return Promise.all(proms); - }, - /** - * Returns the current display_name for the record. - * - * @param {string} id the localID for a valid record element - * @returns {string} - */ - getName: function (id) { - var record = this.localData[id]; - var returnValue = ''; - if (record._changes && 'display_name' in record._changes) { - returnValue = record._changes.display_name; - } - else if ('display_name' in record.data) { - returnValue = record.data.display_name; - } - return returnValue || _t("New"); - }, - /** - * Returns true if a record is dirty. A record is considered dirty if it has - * some unsaved changes, marked by the _isDirty property on the record or - * one of its subrecords. - * - * @param {string} id - the local resource id - * @returns {boolean} - */ - isDirty: function (id) { - var isDirty = false; - this._visitChildren(this.localData[id], function (r) { - if (r._isDirty) { - isDirty = true; - } - }); - return isDirty; - }, - /** - * Returns true iff the datapoint is of type list and either: - * - is not grouped, and contains no records - * - is grouped, and contains columns, but all columns are empty - * In these cases, we will generate sample data to display, instead of an - * empty state. - * - * @override - */ - _isEmpty(dataPointID) { - const dataPoint = this.localData[dataPointID]; - if (dataPoint.type === 'list') { - const hasRecords = dataPoint.count === 0; - if (dataPoint.groupedBy.length) { - return dataPoint.data.length > 0 && hasRecords; - } else { - return hasRecords; - } - } - return false; - }, - /** - * Check if a localData is new, meaning if it is in the process of being - * created and no actual record exists in db. Note: if the localData is not - * of the "record" type, then it is always considered as not new. - * - * Note: A virtual id is a character string composed of an integer and has - * a dash and other information. - * E.g: in calendar, the recursive event have virtual id linked to a real id - * virtual event id "23-20170418020000" is linked to the event id 23 - * - * @param {string} id id for a local resource - * @returns {boolean} - */ - isNew: function (id) { - var data = this.localData[id]; - if (data.type !== "record") { - return false; - } - var res_id = data.res_id; - if (typeof res_id === 'number') { - return false; - } else if (typeof res_id === 'string' && /^[0-9]+-/.test(res_id)) { - return false; - } - return true; - }, - /** - * Main entry point, the goal of this method is to fetch and process all - * data (following relations if necessary) for a given record/list. - * - * @todo document all params - * - * @private - * @param {any} params - * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field - * @param {Object} params.fields contains the description of each field - * @param {string} [params.type] 'record' or 'list' - * @param {string} [params.recordID] an ID for an existing resource. - * @returns {Promise} resolves to a local id, or handle - */ - __load: async function (params) { - await this._super(...arguments); - params.type = params.type || (params.res_id !== undefined ? 'record' : 'list'); - // FIXME: the following seems only to be used by the basic_model_tests - // so it should probably be removed and the tests should be adapted - params.viewType = params.viewType || 'default'; - if (!params.fieldsInfo) { - var fieldsInfo = {}; - for (var fieldName in params.fieldNames) { - fieldsInfo[params.fieldNames[fieldName]] = {}; - } - params.fieldsInfo = {}; - params.fieldsInfo[params.viewType] = fieldsInfo; - } - - if (params.type === 'record' && params.res_id === undefined) { - params.allowWarning = true; - return this._makeDefaultRecord(params.modelName, params); - } - var dataPoint = this._makeDataPoint(params); - return this._load(dataPoint).then(function () { - return dataPoint.id; - }); - }, - /** - * Returns the list of res_ids for a given list of local ids. - * - * @param {string[]} localIds - * @returns {integer[]} - */ - localIdsToResIds: function (localIds) { - return localIds.map(localId => this.localData[localId].res_id); - }, - /** - * This helper method is designed to help developpers that want to use a - * field widget outside of a view. In that case, we want a way to create - * data without actually performing a fetch. - * - * @param {string} model name of the model - * @param {Object[]} fields a description of field properties - * @param {Object} [fieldInfo] various field info that we want to set - * @returns {Promise} the local id for the created resource - */ - makeRecord: function (model, fields, fieldInfo) { - var self = this; - var defs = []; - var record_fields = {}; - fields.forEach((field) => { - record_fields[field.name] = pick(field, 'type', 'relation', 'domain', 'selection'); - }); - fieldInfo = fieldInfo || {}; - var fieldsInfo = {}; - fieldsInfo.default = {}; - fields.forEach((field) => { - fieldsInfo.default[field.name] = fieldInfo[field.name] || {}; - }); - var record = this._makeDataPoint({ - modelName: model, - fields: record_fields, - fieldsInfo: fieldsInfo, - viewType: 'default', - }); - fields.forEach((field) => { - var dataPoint; - record.data[field.name] = null; - if (field.type === 'many2one') { - if (field.value) { - var id = Array.isArray(field.value) ? field.value[0] : field.value; - var display_name = Array.isArray(field.value) ? field.value[1] : undefined; - dataPoint = self._makeDataPoint({ - modelName: field.relation, - data: { - id: id, - display_name: display_name, - }, - parentID: record.id, - }); - record.data[field.name] = dataPoint.id; - if (display_name === undefined) { - defs.push(self._fetchNameGet(dataPoint)); - } - } - } else if (field.type === 'reference' && field.value) { - const ref = field.value.split(','); - dataPoint = self._makeDataPoint({ - context: record.context, - data: { id: parseInt(ref[1], 10) }, - modelName: ref[0], - parentID: record.id, - }); - defs.push(self._fetchNameGet(dataPoint)); - record.data[field.name] = dataPoint.id; - } else if (field.type === 'one2many' || field.type === 'many2many') { - var relatedFieldsInfo = {}; - relatedFieldsInfo.default = {}; - (field.fields || []).forEach((field) => { - relatedFieldsInfo.default[field.name] = {}; - }); - var dpParams = { - fieldsInfo: relatedFieldsInfo, - modelName: field.relation, - parentID: record.id, - static: true, - type: 'list', - viewType: 'default', - }; - var needLoad = false; - // As value, you could either pass: - // - a list of ids related to the record - // - a list of object - // We only need to load the datapoint in the first case. - if (field.value && field.value.length) { - if (typeof field.value[0] === "object") { - dpParams.res_ids = field.value.map((v) => v.id); - dataPoint = self._makeDataPoint(dpParams); - field.value.forEach((data) => { - var recordDP = self._makeDataPoint({ - data: data, - modelName: field.relation, - parentID: dataPoint.id, - type: 'record', - }); - dataPoint.data.push(recordDP.id); - dataPoint._cache[recordDP.res_id] = recordDP.id; - }); - } else { - dpParams.res_ids = field.value; - dataPoint = self._makeDataPoint(dpParams); - needLoad = true; - } - } else { - dpParams.res_ids = []; - dataPoint = self._makeDataPoint(dpParams); - } - - if (needLoad) { - defs.push(self._load(dataPoint)); - } - record.data[field.name] = dataPoint.id; - } else if (field.value) { - record.data[field.name] = field.value; - } - }); - return Promise.all(defs).then(function () { - return record.id; - }); - }, - /** - * This is an extremely important method. All changes in any field go - * through this method. It will then apply them in the local state, check - * if onchanges needs to be applied, actually do them if necessary, then - * resolves with the list of changed fields. - * - * @param {string} record_id - * @param {Object} changes a map field => new value - * @param {Object} [options] will be transferred to the applyChange method - * @see _applyChange - * @returns {Promise} list of changed fields - */ - notifyChanges: function (record_id, changes, options) { - const notifyChanges = () => this._applyChange(record_id, changes, options); - if (this.bypassMutex) { - return notifyChanges(); - } - return this.mutex.exec(notifyChanges); - }, - /** - * Reload all data for a given resource. At any time there is at most one - * reload operation active. - * - * @private - * @param {string} id local id for a resource - * @param {Object} [options] - * @param {boolean} [options.keepChanges=false] if true, doesn't discard the - * changes on the record before reloading it - * @returns {Promise} resolves to the id of the resource - */ - __reload: async function (id, options) { - await this._super(...arguments); - return this.mutex.exec(this._reload.bind(this, id, options)); - }, - /** - * In some case, we may need to remove an element from a list, without going - * through the notifyChanges machinery. The motivation for this is when the - * user click on 'Add a line' in a field one2many with a required field, - * then clicks somewhere else. The new line need to be discarded, but we - * don't want to trigger a real notifyChanges (no need for that, and also, - * we don't want to rerender the UI). - * - * @param {string} elementID some valid element id. It is necessary that the - * corresponding element has a parent. - */ - removeLine: function (elementID) { - var record = this.localData[elementID]; - var parent = this.localData[record.parentID]; - if (parent.static) { - // x2Many case: the new record has been stored in _changes, as a - // command so we remove the command(s) related to that record - parent._changes = parent._changes.filter( change => { - if (change.id === elementID && - change.operation === 'ADD' && // For now, only an ADD command increases limits - parent.tempLimitIncrement) { - // The record will be deleted from the _changes. - // So we won't be passing into the logic of _applyX2ManyOperations anymore - // implying that we have to cancel out the effects of an ADD command here - parent.tempLimitIncrement--; - parent.limit--; - } - return change.id !== elementID; - }); - } else { - // main list view case: the new record is in data - parent.data = parent.data.filter((x) => x !== elementID); - parent.count--; - } - }, - /** - * Resequences records. - * - * @param {string} modelName the resIDs model - * @param {Array} resIDs the new sequence of ids - * @param {string} parentID the localID of the parent - * @param {object} [options] - * @param {integer} [options.offset] - * @param {string} [options.field] the field name used as sequence - * @returns {Promise} resolves to the local id of the parent - */ - resequence: function (modelName, resIDs, parentID, options) { - options = options || {}; - if ((resIDs.length <= 1)) { - return Promise.resolve(parentID); // there is nothing to sort - } - var self = this; - var data = this.localData[parentID]; - var params = { - model: modelName, - ids: resIDs, - context: data.getContext(), - }; - if (options.offset) { - params.offset = options.offset; - } - if (options.field) { - params.field = options.field; - } - return this._rpc({ - route: '/web/dataset/resequence', - params: params, - }) - .then(function (wasResequenced) { - if (!wasResequenced) { - // the field on which the resequence was triggered does not - // exist, so no resequence happened server-side - return Promise.resolve(); - } - var field = params.field ? params.field : 'sequence'; - - return self._rpc({ - model: modelName, - method: 'read', - args: [resIDs, [field]], - context: data.getContext(), - }).then(function (records) { - if (data.data.length) { - var dataType = self.localData[data.data[0]].type; - if (dataType === 'record') { - data.data.forEach((dataPoint) => { - var recordData = self.localData[dataPoint].data; - var inRecords = records.find(({ id }) => id === recordData.id); - if (inRecords) { - recordData[field] = inRecords[field]; - } - }); - data.data = sortBy(data.data, function (d) { - return self.localData[d].data[field]; - }); - } - if (dataType === 'list') { - data.data = sortBy(data.data, function (d) { - return resIDs.indexOf(self.localData[d].res_id); - }); - } - } - data.res_ids = []; - data.data.forEach((d) => { - var dataPoint = self.localData[d]; - if (dataPoint.type === 'record') { - data.res_ids.push(dataPoint.res_id); - } else { - data.res_ids = data.res_ids.concat(dataPoint.res_ids); - } - }); - self._updateParentResIDs(data); - return parentID; - }) - }); - }, - /** - * Save a local resource, if needed. This is a complicated operation, - * - it needs to check all changes, - * - generate commands for x2many fields, - * - call the /create or /write method according to the record status - * - After that, it has to reload all data, in case something changed, server side. - * - * @param {string} recordID local resource - * @param {Object} [options] - * @param {boolean} [options.reload=true] if true, data will be reloaded - * @param {boolean} [options.savePoint=false] if true, the record will only - * be 'locally' saved: its changes written in a _savePoint key that can - * be restored later by call discardChanges with option rollback to true - * @param {string} [options.viewType] current viewType. If not set, we will - * assume main viewType from the record - * @returns {Promise} - * Resolved with the list of field names (whose value has been modified) - */ - save: function (recordID, options) { - var self = this; - function _save() { - options = options || {}; - var record = self.localData[recordID]; - if (options.savePoint) { - self._visitChildren(record, function (rec) { - var newValue = rec._changes || rec.data; - if (newValue instanceof Array) { - rec._savePoint = newValue.slice(0); - } else { - rec._savePoint = Object.assign({}, newValue); - } - }); - - // save the viewType of edition, so that the correct readonly modifiers - // can be evaluated when the record will be saved - for (let fieldName in (record._changes || {})) { - record._editionViewType[fieldName] = options.viewType; - } - } - var shouldReload = 'reload' in options ? options.reload : true; - var method = self.isNew(recordID) ? 'create' : 'write'; - if (record._changes) { - // id never changes, and should not be written - delete record._changes.id; - } - var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'}); - - // id field should never be written/changed - delete changes.id; - - if (method === 'create') { - var fieldNames = record.getFieldNames(); - fieldNames.forEach((name) => { - if (changes[name] === null) { - delete changes[name]; - } - }); - } - - var prom = new Promise(function (resolve, reject) { - var changedFields = Object.keys(changes); - - if (options.savePoint) { - resolve(changedFields); - return; - } - - // in the case of a write, only perform the RPC if there are changes to save - if (method === 'create' || changedFields.length) { - var args = method === 'write' ? [[record.data.id], changes] : [changes]; - self._rpc({ - model: record.model, - method: method, - args: args, - context: record.getContext(), - }).then(function (id) { - if (method === 'create') { - record.res_id = id; // create returns an id, write returns a boolean - record.data.id = id; - record.offset = record.res_ids.length; - record.res_ids.push(id); - record.count++; - } - - var _changes = record._changes; - - // Erase changes as they have been applied - record._changes = {}; - var data = Object.assign({}, record.data, record._changes); - for (var fieldName in record.fields) { - var type = record.fields[fieldName].type; - if (type === 'many2many' || type === 'one2many') { - if (data[fieldName]) { - self.localData[data[fieldName]]._changes = []; - } - } - } - - self.unfreezeOrder(record.id); - - // Update the data directly or reload them - if (shouldReload) { - self._fetchRecord(record).then( - function () { - resolve(changedFields); - }, - reject, - ); - } else { - Object.assign(record.data, _changes); - - record._changes = {}; - for (const fieldName in record.fields) { - if (['many2many', 'one2many'].includes(record.fields[fieldName].type) && record.data[fieldName]) { - self.localData[record.data[fieldName]]._changes = []; - } - } - resolve(changedFields); - } - }).guardedCatch(reject); - - } else { - resolve(changedFields); - } - }); - prom.then(function () { - self._updateDuplicateRecords(record.id, (id) => { - Object.assign(self.localData[id].data, record.data); - }); - record._isDirty = false; - }); - return prom; - } - if (this.bypassMutex) { - return _save(); - } else { - return this.mutex.exec(_save); - } - }, - /** - * Manually sets a resource as dirty. This is used to notify that a field - * has been modified, but with an invalid value. In that case, the value is - * not sent to the basic model, but the record should still be flagged as - * dirty so that it isn't discarded without any warning. - * - * @param {string} id a resource id - */ - setDirty: function (id) { - this.localData[id]._isDirty = true; - }, - /** - * For list resources, this changes the orderedBy key. - * - * @param {string} list_id id for the list resource - * @param {string} fieldName valid field name - * @returns {Promise} - */ - setSort: function (list_id, fieldName) { - var list = this.localData[list_id]; - if (list.type === 'record') { - return; - } else if (list._changes) { - list._changes.forEach((change) => { - delete change.isNew; - }); - } - if (list.orderedBy.length === 0) { - list.orderedBy.push({name: fieldName, asc: true}); - } else if (list.orderedBy[0].name === fieldName){ - if (!list.orderedResIDs) { - list.orderedBy[0].asc = !list.orderedBy[0].asc; - } - } else { - var orderedBy = list.orderedBy.filter(o => o.name !== fieldName); - list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy); - } - - list.orderedResIDs = null; - if (list.static) { - // sorting might require to fetch the field for records where the - // sort field is still unknown (i.e. on other pages for example) - return this._fetchUngroupedList(list); - } - return Promise.resolve(); - }, - /** - * For a given resource of type 'record', get the active field, if any. - * - * Since the ORM can support both `active` and `x_active` fields for - * the archiving mechanism, check if any such field exists and prioritize - * them. The `active` field should always take priority over its custom - * version. - * - * @param {Object} record local resource - * @returns {String|undefined} the field name to use for archiving purposes - * ('active', 'x_active') or undefined if no such field is present - */ - getActiveField: function (record) { - const fields = Object.keys(record.fields); - const has_active = fields.includes('active'); - if (has_active) { - return 'active'; - } - const has_x_active = fields.includes('x_active'); - return has_x_active?'x_active':undefined - }, - /** - * Archive the given records - * - * @param {integer[]} resIDs ids of the records to archive - * @param {string} parentID id of the parent resource to reload - * @returns {Promise} resolves to the parent id - */ - actionArchive: function (resIDs, parentID) { - var self = this; - var parent = this.localData[parentID]; - return this._rpc({ - model: parent.model, - method: 'action_archive', - args: [resIDs], - }) - .then(function (action) { - if (Object.keys(action || {}).length > 0) { - return new Promise(function (resolve, reject) { - self.do_action(action, { - on_close: function (result) { - return self.trigger_up('reload', { - onSuccess: resolve, - }); - } - }); - }); - } else { - return self.reload(parentID); - } - }).then(function (datapoint) { - // if there are no records to display and we are not on first page(we check it - // by checking offset is greater than limit i.e. we are not on first page) - // reason for adding logic after reload to make sure there is no records after operation - if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { - parent.offset = Math.max(parent.offset - parent.limit, 0); - return self.reload(parentID); - } - return datapoint; - }); - }, - /** - * Unarchive the given records - * - * @param {integer[]} resIDs ids of the records to unarchive - * @param {string} parentID id of the parent resource to reload - * @returns {Promise} resolves to the parent id - */ - actionUnarchive: function (resIDs, parentID) { - var self = this; - var parent = this.localData[parentID]; - return this._rpc({ - model: parent.model, - method: 'action_unarchive', - args: [resIDs], - }) - .then(function (action) { - // optionally clear the DataManager's cache - if (Object.keys(action || {}).length > 0) { - return new Promise(function (resolve, reject) { - self.do_action(action, { - on_close: function () { - return self.trigger_up('reload', { - onSuccess: resolve, - }); - } - }); - }); - } else { - return self.reload(parentID); - } - }).then(function (datapoint) { - // if there are no records to display and we are not on first page(we check it - // by checking offset is greater than limit i.e. we are not on first page) - // reason for adding logic after reload to make sure there is no records after operation - if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { - parent.offset = Math.max(parent.offset - parent.limit, 0); - return self.reload(parentID); - } - return datapoint; - }); - }, - /** - * Toggle (open/close) a group in a grouped list, then fetches relevant - * data - * - * @param {string} groupId - * @returns {Promise} resolves to the group id - */ - toggleGroup: function (groupId) { - var self = this; - var group = this.localData[groupId]; - if (group.isOpen) { - group.isOpen = false; - group.data = []; - group.res_ids = []; - group.offset = 0; - this._updateParentResIDs(group); - return Promise.resolve(groupId); - } - if (!group.isOpen) { - group.isOpen = true; - var def; - if (group.count > 0) { - def = this._load(group).then(function () { - self._updateParentResIDs(group); - }); - } - return Promise.resolve(def).then(function () { - return groupId; - }); - } - }, - /** - * For a list datapoint, unfreezes the current records order and sorts it. - * For a record datapoint, unfreezes the x2many list datapoints. - * - * @param {string} elementID a valid element ID - */ - unfreezeOrder: function (elementID) { - var list = this.localData[elementID]; - if (list.type === 'record') { - var data = Object.assign({}, list.data, list._changes); - for (var fieldName in data) { - var field = list.fields[fieldName]; - if (!field || !data[fieldName]) { - continue; - } - if (field.type === 'one2many' || field.type === 'many2many') { - var recordlist = this.localData[data[fieldName]]; - recordlist.orderedResIDs = null; - for (var index in recordlist.data) { - this.unfreezeOrder(recordlist.data[index]); - } - } - } - return; - } - list.orderedResIDs = null; - this._sortList(list); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add a default record to a list object. This method actually makes a new - * record with the _makeDefaultRecord method, then adds it to the list object - * as a 'ADD' command in its _changes. This is meant to be used x2many lists, - * not by list or kanban controllers. - * - * @private - * @param {Object} list a valid list object - * @param {Object} [options] - * @param {string} [options.position=top] if the new record should be added - * on top or on bottom of the list - * @param {Array} [options.[context]] additional context to be merged before - * calling the default_get (eg. to set default values). - * If several contexts are found, multiple records are added - * @param {boolean} [options.allowWarning=false] if true, the default record - * operation can complete, even if a warning is raised - * @returns {Promise<[string]>} resolves to the new records ids - */ - _addX2ManyDefaultRecord: function (list, options) { - var self = this; - var position = options && options.position || 'top'; - var params = { - fields: list.fields, - fieldsInfo: list.fieldsInfo, - parentID: list.id, - position: position, - viewType: options.viewType || list.viewType, - allowWarning: options && options.allowWarning - }; - - var additionalContexts = options && options.context; - var makeDefaultRecords = []; - if (additionalContexts){ - additionalContexts.forEach((context) => { - params.context = self._getContext(list, {additionalContext: context, full: true, sanitize_default_values: true}); - makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); - }); - } else { - params.context = self._getContext(list, {sanitize_default_values: true}); - makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); - } - - return Promise.all(makeDefaultRecords).then(function (resultIds){ - var ids = []; - resultIds.forEach((id) => { - ids.push(id); - - list._changes.push({operation: 'ADD', id: id, position: position, isNew: true}); - var record = self.localData[id]; - list._cache[record.res_id] = id; - if (list.orderedResIDs) { - var index = list.offset + (position !== 'top' ? list.limit : 0); - list.orderedResIDs.splice(index, 0, record.res_id); - // list could be a copy of the original one - self.localData[list.id].orderedResIDs = list.orderedResIDs; - } - }); - - return ids; - }); - }, - /** - * This method is the private version of notifyChanges. Unlike - * notifyChanges, it is not protected by a mutex. Every changes from the - * user to the model go through this method. - * - * @param {string} recordID - * @param {Object} changes - * @param {Object} [options] - * @param {boolean} [options.doNotSetDirty=false] if this flag is set to - * true, then we will not tag the record as dirty. This should be avoided - * for most situations. - * @param {boolean} [options.notifyChange=true] if this flag is set to - * false, then we will not notify and not trigger the onchange, even though - * it was changed. - * @param {string} [options.viewType] current viewType. If not set, we will assume - * main viewType from the record - * @param {boolean} [options.allowWarning=false] if true, change - * operation can complete, even if a warning is raised - * (only supported by X2ManyChange) - * @returns {Promise} list of changed fields - */ - _applyChange: function (recordID, changes, options) { - var self = this; - var record = this.localData[recordID]; - var field; - var defs = []; - options = options || {}; - record._changes = record._changes || {}; - if (!options.doNotSetDirty) { - record._isDirty = true; - } - var initialData = {}; - this._visitChildren(record, function (elem) { - initialData[elem.id] = $.extend(true, {}, pick(elem, 'data', '_changes')); - }); - - // apply changes to local data - for (var fieldName in changes) { - field = record.fields[fieldName]; - if (field && (field.type === 'one2many' || field.type === 'many2many')) { - defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options)); - } else if (field && (field.type === 'many2one' || field.type === 'reference')) { - defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName], options)); - } else { - record._changes[fieldName] = changes[fieldName]; - } - } - - if (options.notifyChange === false) { - return Promise.all(defs).then(function () { - return Promise.resolve(Object.keys(changes)); - }); - } - - return Promise.all(defs).then(function () { - var onChangeFields = []; // the fields that have changed and that have an on_change - for (var fieldName in changes) { - field = record.fields[fieldName]; - if (field && field.onChange) { - var isX2Many = field.type === 'one2many' || field.type === 'many2many'; - if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) { - onChangeFields.push(fieldName); - } - } - } - return new Promise(function (resolve, reject) { - if (onChangeFields.length) { - self._performOnChange(record, onChangeFields, { viewType: options.viewType }) - .then(function (result) { - delete record._warning; - resolve(Object.keys(changes).concat(Object.keys(result && result.value || {}))); - }).guardedCatch(function () { - self._visitChildren(record, function (elem) { - Object.assign(elem, initialData[elem.id]); - }); - reject(); - }); - } else { - resolve(Object.keys(changes)); - } - }).then(function (fieldNames) { - return self._fetchSpecialData(record).then(function (fieldNames2) { - // Return the names of the fields that changed (onchange or - // associated special data change) - return fieldNames.concat(fieldNames2); - }); - }); - }); - }, - /** - * Apply an x2one (either a many2one or a reference field) change. There is - * a need for this function because the server only gives an id when a - * onchange modifies a many2one field. For this reason, we need (sometimes) - * to do a read of display_name. - * - * Moreover, for the many2one case, a new value can sometimes be set (i.e. - * a display_name is given, but no id). When this happens, we first do a - * name_create. - * - * @param {Object} record - * @param {string} fieldName - * @param {Object} [data] - * @param {Object} [options] - * @param {string} [options.viewType] current viewType. If not set, we will - * assume main viewType from the record - * @returns {Promise} - */ - _applyX2OneChange: async function (record, fieldName, data, options) { - options = options || {}; - var self = this; - if (!data || (!data.id && !data.display_name)) { - record._changes[fieldName] = false; - return Promise.resolve(); - } - - const field = record.fields[fieldName]; - const coModel = field.type === 'reference' ? data.model : field.relation; - const allowedTypes = ['many2one', 'reference']; - if (allowedTypes.includes(field.type) && !data.id && data.display_name) { - // only display_name given -> do a name_create - const result = await this._rpc({ - model: coModel, - method: 'name_create', - args: [data.display_name], - context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), - }); - // Check if a record is really created. Models without defined - // _rec_name cannot create record based on name_create. - if (!result) { - record._changes[fieldName] = false; - return Promise.resolve(); - } - data = {id: result[0], display_name: result[1]}; - } - - // here, we check that the many2one really changed. If the res_id is the - // same, we do not need to do any extra work. It can happen when the - // user edited a manyone (with the small form view button) with an - // onchange. In that case, the onchange is triggered, but the actual - // value did not change. - var relatedID; - if (record._changes && fieldName in record._changes) { - relatedID = record._changes[fieldName]; - } else { - relatedID = record.data[fieldName]; - } - var relatedRecord = this.localData[relatedID]; - if (relatedRecord && (data.id === this.localData[relatedID].res_id)) { - return Promise.resolve(); - } - var rel_data = pick(data, 'id', 'display_name'); - - const viewType = options.viewType || record.viewType; - const fieldInfo = record.fieldsInfo[viewType][fieldName] || {}; - const fieldOptions = fieldInfo.options || {}; - - // the reference field doesn't store its co-model in its field metadata - // but directly in the data (as the co-model isn't fixed) - var def; - if (rel_data.display_name === undefined || fieldOptions.always_reload) { - // TODO: refactor this to use _fetchNameGet - def = this._rpc({ - model: coModel, - method: 'read', - args: [data.id, ['display_name']], - context: this._getContext(record, { fieldName, viewType }), - }) - .then(function (result) { - rel_data.display_name = result[0]['display_name']; - }); - } - return Promise.resolve(def).then(function () { - var rec = self._makeDataPoint({ - context: record.context, - data: rel_data, - fields: {}, - fieldsInfo: {}, - modelName: coModel, - parentID: record.id, - }); - record._changes[fieldName] = rec.id; - }); - }, - /** - * Applies the result of an onchange RPC on a record. - * - * @private - * @param {Object} values the result of the onchange RPC (a mapping of - * fieldnames to their value) - * @param {Object} record - * @param {Object} [options={}] - * @param {string} [options.viewType] current viewType. If not set, we will assume - * main viewType from the record - * @param {string} [options.firstOnChange] set to true if this is the first - * onchange (if so, some initialization will need to be done) - * @returns {Promise} - */ - _applyOnChange: function (values, record, options = {}) { - var self = this; - var defs = []; - var rec; - const viewType = options.viewType || record.viewType; - record._changes = record._changes || {}; - - for (let name in (values || {})) { - const val = values[name]; - var field = record.fields[name]; - if (!field) { - // this field is unknown so we can't process it for now (it is not - // in the current view anyway, otherwise it wouldn't be unknown. - // we store its value without processing it, so that if we later - // on switch to another view in which this field is displayed, - // we could process it as we would know its type then. - // use case: an onchange sends a create command for a one2many, - // in the dict of values, there is a value for a field that is - // not in the one2many list, but that is in the one2many form. - record._rawChanges[name] = val; - // LPE TODO 1 taskid-2261084: remove this entire comment including code snippet - // when the change in behavior has been thoroughly tested. - // It is impossible to distinguish between values returned by the default_get - // and those returned by the onchange. Since those are not in _changes, they won't be saved. - // if (options.firstOnChange) { - // record._changes[name] = val; - // } - continue; - } - if (record._rawChanges[name]) { - // if previous _rawChanges exists, clear them since the field is now knwon - // and restoring outdated onchange over posterious change is wrong - delete record._rawChanges[name]; - } - var oldValue = name in record._changes ? record._changes[name] : record.data[name]; - var id; - if (field.type === 'many2one') { - id = false; - // in some case, the value returned by the onchange can - // be false (no value), so we need to avoid creating a - // local record for that. - if (val) { - // when the value isn't false, it can be either - // an array [id, display_name] or just an id. - var data = Array.isArray(val) ? - {id: val[0], display_name: val[1]} : - {id: val}; - if (!oldValue || (self.localData[oldValue].res_id !== data.id)) { - // only register a change if the value has changed - rec = self._makeDataPoint({ - context: record.context, - data: data, - modelName: field.relation, - parentID: record.id, - }); - id = rec.id; - record._changes[name] = id; - } - } else if (oldValue !== false) { - // if no value is provided for a m2o, - // we should only consider the field updated if it had a value before - record._changes[name] = false; - } - } else if (field.type === 'reference') { - id = false; - if (val) { - var ref = val.split(','); - var modelName = ref[0]; - var resID = parseInt(ref[1]); - if (!oldValue || self.localData[oldValue].res_id !== resID || - self.localData[oldValue].model !== modelName) { - // only register a change if the value has changed - rec = self._makeDataPoint({ - context: record.context, - data: {id: parseInt(ref[1])}, - modelName: modelName, - parentID: record.id, - }); - defs.push(self._fetchNameGet(rec)); - id = rec.id; - record._changes[name] = id; - } - } else { - record._changes[name] = id; - } - } else if (field.type === 'one2many' || field.type === 'many2many') { - var listId = record._changes[name] || record.data[name]; - var list; - if (listId) { - list = self.localData[listId]; - } else { - var fieldInfo = record.fieldsInfo[viewType][name]; - if (!fieldInfo) { - // ignore changes of x2many not in view - continue; - } - var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - list = self._makeDataPoint({ - fields: view ? view.fields : fieldInfo.relatedFields, - fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, - limit: fieldInfo.limit, - modelName: field.relation, - parentID: record.id, - static: true, - type: 'list', - viewType: view ? view.type : fieldInfo.viewType, - }); - } - // TODO: before registering the changes, verify that the x2many - // value has changed - record._changes[name] = list.id; - list._changes = list._changes || []; - - // save it in case of a [5] which will remove the _changes - var oldChanges = list._changes; - val.forEach((command) => { - var rec, recID; - if (command[0] === 0 || command[0] === 1) { - // CREATE or UPDATE - if (command[0] === 0 && command[1]) { - // updating an existing (virtual) record - var previousChange = oldChanges.find(operation => { - var child = self.localData[operation.id]; - return child && (child.ref === command[1]); - }); - recID = previousChange && previousChange.id; - rec = self.localData[recID]; - } - if (command[0] === 1 && command[1]) { - // updating an existing record - rec = self.localData[list._cache[command[1]]]; - } - if (!rec) { - var params = { - context: list.context, - fields: list.fields, - fieldsInfo: list.fieldsInfo, - modelName: list.model, - parentID: list.id, - viewType: list.viewType, - ref: command[1], - }; - if (command[0] === 1) { - params.res_id = command[1]; - } - rec = self._makeDataPoint(params); - list._cache[rec.res_id] = rec.id; - if (options.firstOnChange) { - // this is necessary so the fields are initialized - rec.getFieldNames().forEach(fieldName => { - if (!(fieldName in rec.data)) { - rec.data[fieldName] = null; - } - }); - } - } - // Do not abandon the record if it has been created - // from `default_get`. The list has a savepoint only - // after having fully executed `default_get`. - rec._noAbandon = !list._savePoint; - list._changes.push({operation: 'ADD', id: rec.id}); - if (command[0] === 1) { - list._changes.push({operation: 'UPDATE', id: rec.id}); - } - defs.push(self._applyOnChange(command[2], rec, { - firstOnChange: options.firstOnChange, - })); - } else if (command[0] === 4) { - // LINK TO - linkRecord(list, command[1]); - } else if (command[0] === 5) { - // DELETE ALL - list._changes = [{operation: 'REMOVE_ALL'}]; - } else if (command[0] === 6) { - list._changes = [{operation: 'REMOVE_ALL'}]; - Object.values(command[2] || {}).forEach((resID) => { - linkRecord(list, resID); - }); - } - }); - var def = self._readUngroupedList(list).then(function (list) { - var x2ManysDef = self._fetchX2ManysBatched(list); - var referencesDef = self._fetchReferencesBatched(list); - return Promise.all([x2ManysDef, referencesDef]); - }); - defs.push(def); - } else { - var newValue = self._parseServerValue(field, val); - if (newValue !== oldValue) { - record._changes[name] = newValue; - } - } - } - return Promise.all(defs); - - // inner function that adds a record (based on its res_id) to a list - // dataPoint (used for onchanges that return commands 4 (LINK TO) or - // commands 6 (REPLACE WITH)) - function linkRecord (list, resID) { - rec = self.localData[list._cache[resID]]; - if (rec) { - // modifications done on a record are discarded if the onchange - // uses a LINK TO or a REPLACE WITH - self.discardChanges(rec.id); - } - // the dataPoint id will be set when the record will be fetched (for - // now, this dataPoint may not exist yet) - list._changes.push({ - operation: 'ADD', - id: rec ? rec.id : null, - resID: resID, - }); - } - }, - /** - * When an operation is applied to a x2many field, the field widgets - * generate one (or more) command, which describes the exact operation. - * This method tries to interpret these commands and apply them to the - * localData. - * - * @param {Object} record - * @param {string} fieldName - * @param {Object} command A command object. It should have a 'operation' - * key. For example, it looks like {operation: ADD, id: 'partner_1'} - * @param {Object} [options] - * @param {string} [options.viewType] current viewType. If not set, we will assume - * main viewType from the record - * @param {boolean} [options.allowWarning=false] if true, change - * operation can complete, even if a warning is raised - * (only supported by the 'CREATE' command.operation) - * @returns {Promise} - */ - _applyX2ManyChange: async function (record, fieldName, command, options) { - if (command.operation === 'TRIGGER_ONCHANGE') { - // the purpose of this operation is to trigger an onchange RPC, so - // there is no need to apply any change on the record (the changes - // have probably been already applied and saved, usecase: many2many - // edition in a dialog) - return Promise.resolve(); - } - - var self = this; - var localID = (record._changes && record._changes[fieldName]) || record.data[fieldName]; - var list = this.localData[localID]; - var field = record.fields[fieldName]; - var viewType = (options && options.viewType) || record.viewType; - var fieldInfo = record.fieldsInfo[viewType][fieldName]; - var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - var def, rec; - var defs = []; - list._changes = list._changes || []; - - switch (command.operation) { - case 'ADD': - // for now, we are in the context of a one2many field - // the command should look like this: - // { operation: 'ADD', id: localID } - // The corresponding record may contain value for fields that - // are unknown in the list (e.g. fields that are in the - // subrecord form view but not in the kanban or list view), so - // to ensure that onchanges are correctly handled, we extend the - // list's fields with those in the created record - var newRecord = this.localData[command.id]; - list.fields = Object.assign(newRecord.fields, list.fields); - list.fieldsInfo = Object.assign(newRecord.fieldsInfo, list.fieldsInfo); - newRecord.fields = list.fields; - newRecord.fieldsInfo = list.fieldsInfo; - newRecord.viewType = list.viewType; - list._cache[newRecord.res_id] = newRecord.id; - list._changes.push(command); - break; - case 'ADD_M2M': - // force to use link command instead of create command - list._forceM2MLink = true; - // handle multiple add: command[2] may be a dict of values (1 - // record added) or an array of dict of values - var data = Array.isArray(command.ids) ? command.ids : [command.ids]; - - // name_create records for which there is no id (typically, could - // be the case of a quick_create in a many2many_tags, so data.length - // is 1) - for (const r of data) { - if (!r.id && r.display_name) { - const prom = this._rpc({ - model: field.relation, - method: 'name_create', - args: [r.display_name], - context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), - }).then(result => { - r.id = result[0]; - r.display_name = result[1]; - }); - defs.push(prom); - } - } - await Promise.all(defs); - - // Ensure the local data repository (list) boundaries can handle incoming records (data) - if (data.length + list.res_ids.length > list.limit) { - list.limit = data.length + list.res_ids.length; - } - - var list_records = {}; - data.forEach((d) => { - rec = self._makeDataPoint({ - context: record.context, - modelName: field.relation, - fields: view ? view.fields : fieldInfo.relatedFields, - fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, - res_id: d.id, - data: d, - viewType: view ? view.type : fieldInfo.viewType, - parentID: list.id, - }); - list_records[d.id] = rec; - list._cache[rec.res_id] = rec.id; - list._changes.push({operation: 'ADD', id: rec.id}); - }); - // read list's records as we only have their ids and optionally their display_name - // (we can't use function readUngroupedList because those records are only in the - // _changes so this is a very specific case) - // this could be optimized by registering the fetched records in the list's _cache - // so that if a record is removed and then re-added, it won't be fetched twice - var fieldNames = list.getFieldNames(); - if (fieldNames.length) { - def = this._rpc({ - model: list.model, - method: 'read', - args: [data.map((d) => d.id), fieldNames], - context: Object.assign({}, record.context, field.context, list.getContext()), - }).then(function (records) { - records.forEach((record) => { - list_records[record.id].data = record; - self._parseServerData(fieldNames, list, record); - }); - return Promise.all([ - self._fetchX2ManysBatched(list), - self._fetchReferencesBatched(list) - ]); - }); - defs.push(def); - } - break; - case 'CREATE': - var createOptions = Object.assign({ - context: command.context, - position: command.position - }, options || {}); - createOptions.viewType = fieldInfo.mode; - - def = this._addX2ManyDefaultRecord(list, createOptions).then(function (ids) { - ids.forEach((id) => { - if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) { - list.tempLimitIncrement = (list.tempLimitIncrement || 0) + 1; - list.limit += 1; - } - // FIXME: hack for lunch widget, which does useless default_get and onchange - if (command.data) { - return self._applyChange(id, command.data); - } - }); - }); - defs.push(def); - break; - case 'UPDATE': - list._changes.push({operation: 'UPDATE', id: command.id}); - if (command.data) { - defs.push(this._applyChange(command.id, command.data, { - viewType: view && view.type, - })); - } - break; - case 'FORGET': - // Unlink the record of list. - list._forceM2MUnlink = true; - case 'DELETE': - // filter out existing operations involving the current - // dataPoint, and add a 'DELETE' or 'FORGET' operation only if there is - // no 'ADD' operation for that dataPoint, as it would mean - // that the record wasn't in the relation yet - var idsToRemove = command.ids; - - list._changes = list._changes.filter(change => { - var idInCommands = command.ids.includes(change.id); - if (idInCommands && change.operation === 'ADD') { - idsToRemove = idsToRemove.filter(id => id !== change.id); - } - return !idInCommands; - }); - idsToRemove.forEach(id => { - var operation = list._forceM2MUnlink ? 'FORGET': 'DELETE'; - list._changes.push({operation: operation, id: id}); - }); - break; - case 'DELETE_ALL': - // first remove all pending 'ADD' operations - list._changes = list._changes.filter(change => change.operation !== 'ADD'); - - // then apply 'DELETE' on existing records - return this._applyX2ManyChange(record, fieldName, { - operation: 'DELETE', - ids: list.res_ids - }, options); - case 'REPLACE_WITH': - // this is certainly not optimal... and not sure that it is - // correct if some ids are added and some other are removed - list._changes = []; - var newIds = command.ids.filter((x) => list.res_ids.indexOf(x) < 0); - var removedIds = list.res_ids.filter((x) => command.ids.indexOf(x) < 0); - var addDef, removedDef, values; - if (newIds.length) { - values = newIds.map((id) => { - return { id: id }; - }); - addDef = this._applyX2ManyChange(record, fieldName, { - operation: 'ADD_M2M', - ids: values - }, options); - } - if (removedIds.length) { - var listData = list.data.map((localId) => self.localData[localId]); - removedDef = this._applyX2ManyChange(record, fieldName, { - operation: 'DELETE', - ids: removedIds.map((resID) => { - if (resID in list._cache) { - return list._cache[resID]; - } - return listData.find((list) => list.res_id === resID).id; - }), - }, options); - } - return Promise.all([addDef, removedDef]); - case 'MULTI': - // allows batching multiple operations - command.commands.forEach((innerCommand) => { - defs.push(self._applyX2ManyChange( - record, - fieldName, - innerCommand, - options - )); - }); - break; - } - - return Promise.all(defs).then(function () { - // ensure to fetch up to 'limit' records (may be useful if records of - // the current page have been removed) - return self._readUngroupedList(list).then(function () { - return self._fetchX2ManysBatched(list); - }); - }); - }, - /** - * In dataPoints of type list for x2manys, the changes are stored as a list - * of operations (being of type 'ADD', 'DELETE', 'FORGET', UPDATE' or 'REMOVE_ALL'). - * This function applies the operation of such a dataPoint without altering - * the original dataPoint. It returns a copy of the dataPoint in which the - * 'count', 'data' and 'res_ids' keys have been updated. - * - * @private - * @param {Object} dataPoint of type list - * @param {Object} [options] mostly contains the range of operations to apply - * @param {Object} [options.from=0] the index of the first operation to apply - * @param {Object} [options.to=length] the index of the last operation to apply - * @param {Object} [options.position] if set, each new operation will be set - * accordingly at the top or the bottom of the list - * @returns {Object} element of type list in which the commands have been - * applied - */ - _applyX2ManyOperations: function (list, options) { - if (!list.static) { - // this function only applies on x2many lists - return list; - } - var self = this; - list = Object.assign({}, list); - list.res_ids = list.res_ids.slice(0); - var changes = list._changes || []; - if (options) { - var to = options.to === 0 ? 0 : (options.to || changes.length); - changes = changes.slice(options.from || 0, to); - } - changes.forEach((change) => { - var relRecord; - if (change.id) { - relRecord = self.localData[change.id]; - } - switch (change.operation) { - case 'ADD': - list.count++; - var resID = relRecord ? relRecord.res_id : change.resID; - if (change.position === 'top' && (options ? options.position !== 'bottom' : true)) { - list.res_ids.unshift(resID); - } else { - list.res_ids.push(resID); - } - break; - case 'FORGET': - case 'DELETE': - list.count--; - // FIXME awa: there is no "relRecord" for o2m field - // seems like using change.id does the trick -> check with framework JS - var deletedResID = relRecord ? relRecord.res_id : change.id; - list.res_ids = list.res_ids.filter((resID) => resID !== deletedResID); - break; - case 'REMOVE_ALL': - list.count = 0; - list.res_ids = []; - break; - case 'UPDATE': - // nothing to do for UPDATE commands - break; - } - }); - this._setDataInRange(list); - return list; - }, - /** - * Helper method to build a 'spec', that is a description of all fields in - * the view that have a onchange defined on them. - * - * An onchange spec is necessary as an argument to the /onchange route. It - * looks like this: { field: "1", anotherField: "", relation.subField: "1"} - * - * The first onchange call will fill up the record with default values, so - * we need to send every field name known to us in this case. - * - * @see _performOnChange - * - * @param {Object} record resource object of type 'record' - * @param {string} [viewType] current viewType. If not set, we will assume - * main viewType from the record - * @returns {Object} with two keys - * - 'hasOnchange': true iff there is at least a field with onchange - * - 'onchangeSpec': the onchange spec - */ - _buildOnchangeSpecs: function (record, viewType) { - let hasOnchange = false; - const onchangeSpec = {}; - var fieldsInfo = record.fieldsInfo[viewType || record.viewType]; - generateSpecs(fieldsInfo, record.fields); - - // recursively generates the onchange specs for fields in fieldsInfo, - // and their subviews - function generateSpecs(fieldsInfo, fields, prefix) { - prefix = prefix || ''; - Object.keys(fieldsInfo).forEach((name) => { - var field = fields[name]; - var fieldInfo = fieldsInfo[name]; - var key = prefix + name; - onchangeSpec[key] = (field.onChange) || ""; - if (field.onChange) { - hasOnchange = true; - } - if (field.type === 'one2many' || field.type === 'many2many') { - Object.values(fieldInfo.views || {}).forEach((view) => { - generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.'); - }); - } - }); - } - return { hasOnchange, onchangeSpec }; - }, - /** - * Ensures that dataPoint ids are always synchronized between the main and - * sample models when being in sample mode. Here, we now that __id in the - * sample model is always greater than __id in the main model (as it - * contains strictly more datapoints). - * - * @override - */ - async _callSampleModel() { - await this._super(...arguments); - if (this._isInSampleMode) { - this.__id = this.sampleModel.__id; - } - }, - /** - * Compute the default value that the handle field should take. - * We need to compute this in order for new lines to be added at the correct position. - * - * @private - * @param {Object} listID - * @param {string} position - * @return {Object} empty object if no overrie has to be done, or: - * field: the name of the field to override, - * value: the value to use for that field - */ - _computeOverrideDefaultFields: function (listID, position) { - var list = this.localData[listID]; - var handleField; - - // Here listID is actually just parentID, it's not yet confirmed - // to be a list. - // If we are not in the case that interests us, - // listID will be undefined and this check will work. - if (!list) { - return {}; - } - - position = position || 'bottom'; - - // Let's find if there is a field with handle. - if (!list.fieldsInfo) { - return {}; - } - for (var field in list.fieldsInfo.list) { - if (list.fieldsInfo.list[field].widget === 'handle') { - handleField = field; - break; - // If there are 2 handle fields on the same list, - // we take the first one we find. - // And that will be alphabetically on the field name... - } - } - - if (!handleField) { - return {}; - } - - // We don't want to override the default value - // if the list is not ordered by the handle field. - var isOrderedByHandle = list.orderedBy - && list.orderedBy.length - && list.orderedBy[0].asc === true - && list.orderedBy[0].name === handleField; - - if (!isOrderedByHandle) { - return {}; - } - - // We compute the list (get) to apply the pending changes before doing our work, - // otherwise new lines might not be taken into account. - // We use raw: true because we only need to load the first level of relation. - var computedList = this.get(list.id, {raw: true}); - - // We don't need to worry about the position of a new line if the list is empty. - if (!computedList || !computedList.data || !computedList.data.length) { - return {}; - } - - // If there are less elements in the list than the limit of - // the page then take the index of the last existing line. - - // If the button is at the top, we want the new element on - // the first line of the page. - - // If the button is at the bottom, we want the new element - // after the last line of the page - // (= theorically it will be the first element of the next page). - - // We ignore list.offset because computedList.data - // will only have the current page elements. - - var index = Math.min( - computedList.data.length - 1, - position !== 'top' ? list.limit - 1 : 0 - ); - - // This positioning will almost be correct. There might just be - // an issue if several other lines have the same handleFieldValue. - - // TODO ideally: if there is an element with the same handleFieldValue, - // that one and all the following elements must be incremented - // by 1 (at least until there is a gap in the numbering). - - // We don't do it now because it's not an important case. - // However, we can for sure increment by 1 if we are on the last page. - var handleFieldValue = computedList.data[index].data[handleField]; - if (position === 'top') { - handleFieldValue--; - } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) { - handleFieldValue++; - } - return { - field: handleField, - value: handleFieldValue, - }; - }, - /** - * Evaluate modifiers - * - * @private - * @param {Object} element a valid element object, which will serve as eval - * context. - * @param {Object} modifiers - * @returns {Object} - * @throws {Error} if one of the modifier domains is invalid - */ - _evalModifiers: function (element, modifiers) { - let evalContext = null; - const evaluated = {}; - for (const k of ['invisible', 'column_invisible', 'readonly', 'required']) { - const mod = modifiers[k]; - if (mod === undefined || mod === false || mod === true) { - if (k in modifiers) { - evaluated[k] = !!mod; - } - continue; - } - try { - evalContext = evalContext || this._getEvalContext(element); - evaluated[k] = new Domain(mod, evalContext).compute(evalContext); - } catch (e) { - throw new Error(sprintf('for modifier "%s": %s', k, e.message)); - } - } - return evaluated; - }, - /** - * Fetch the type of model (e.g. 'res.partner') contained in the model_field - * @private - * @param {Object} record - an element from the localData - * @param {Object} fieldName - the name of the field - * @param {Object} fieldInfo - the info of the field - * @returns {Promise} - */ - _fetchModelFieldReference: async function (record, fieldName, fieldInfo) { - const modelField = fieldInfo.options.model_field; - const value = record._changes && record._changes[modelField] || record.data[modelField]; - if (value) { - if (record.fields[modelField].type !== "many2one" - || record.fields[modelField].relation !== 'ir.model') { - throw new Error(`The model_field of the reference field - ${fieldName} must be a many2one('ir.model').`); - } - const modelId = this.localData[value].res_id; - const resourceRef = record.specialData[fieldName]; - if (resourceRef && modelId === resourceRef.modelId) { - resourceRef.hasChanged = false; - return Promise.resolve(resourceRef); - } else { - const result = await this._rpc({ - model: 'ir.model', - method: 'read', - args: [modelId, ['id', 'model']], - }); - // Checks the case where the data on the backend side are not synchronized - // (modelFieldName != referenceFieldName) when opening the edit view (!_changes). - // We want to avoid resynchronization in order not to modify the data - // without being requested. - if (!record._changes && record.data[fieldName] - && result[0].model !== this.localData[record.data[fieldName]].model) { - const modelFieldName = record.fields[modelField].string; - const referenceFieldName = record.fields[fieldName].string; - - this.displayNotification({ - title: sprintf( - _t(`'%s' is unsynchronized with '%s'.`), - referenceFieldName, - modelFieldName, - ), - message: sprintf( - _t(`If you change %s or %s, the synchronization will be reapplied and the data will be modified.`), - modelFieldName, - referenceFieldName, - ), - sticky: true, - type: 'danger', - }); - return false; - } - return { - modelName: result[0].model, - modelId: result[0].id, - hasChanged: true, - }; - } - } - }, - /** - * Fetch display_name for a record datapoint. - * - * @param {Object} dataPoint - * @returns {Promise} - */ - _fetchNameGet: function (dataPoint) { - return this._rpc({ - model: dataPoint.model, - method: 'read', - args: [dataPoint.res_id, ['display_name']], - context: dataPoint.getContext(), - }).then(function (result) { - dataPoint.data.display_name = result[0]['display_name']; - }); - }, - /** - * Fetch display_name for a field of type Many2one or Reference - * - * @private - * @params {Object} list: must be a datapoint of type list - * (for example: a datapoint representing a x2many) - * @params {string} fieldName: the name of a field of type Many2one or Reference - * @returns {Promise} - */ - _fetchNameGets: function (list, fieldName) { - var self = this; - // We first get the model this way because if list.data is empty - // the forEach below will not make it. - var model = list.fields[fieldName].relation; - var records = []; - var ids = []; - list = this._applyX2ManyOperations(list); - - list.data.forEach((localId) => { - var record = self.localData[localId]; - var data = record._changes || record.data; - var many2oneId = data[fieldName]; - if (!many2oneId) { return; } - var many2oneRecord = self.localData[many2oneId]; - records.push(many2oneRecord); - ids.push(many2oneRecord.res_id); - // We need to calculate the model this way too because - // field .relation is not set for a reference field. - model = many2oneRecord.model; - }); - - if (!ids.length) { - return Promise.resolve(); - } - return this._rpc({ - model: model, - method: 'read', - args: [unique(ids), ['display_name']], - context: list.context, - }) - .then(function (readRecords) { - records.forEach((record) => { - const readRecord = readRecords.find(readRecord => readRecord['id'] === record.data.id); - record.data.display_name = readRecord['display_name']; - }); - }); - }, - /** - * For a given resource of type 'record', fetch all data. - * - * @param {Object} record local resource - * @param {Object} [options] - * @param {string[]} [options.fieldNames] the list of fields to fetch. If - * not given, fetch all the fields in record.fieldNames (+ display_name) - * @param {string} [options.viewType] the type of view for which the record - * is fetched (usefull to load the adequate fields), by defaults, uses - * record.viewType - * @returns {Promise} resolves to the record or is rejected in - * case no id given were valid ids - */ - _fetchRecord: function (record, options) { - var self = this; - options = options || {}; - var fieldNames = options.fieldNames || record.getFieldNames(options); - fieldNames = unique(fieldNames.concat(['display_name'])); - return this._rpc({ - model: record.model, - method: 'read', - args: [[record.res_id], fieldNames], - context: Object.assign({bin_size: true}, record.getContext()), - }) - .then(function (result) { - if (result.length === 0) { - return Promise.reject(); - } - result = result[0]; - record.data = Object.assign({}, record.data, result); - }) - .then(function () { - self._parseServerData(fieldNames, record, record.data); - }) - .then(function () { - return Promise.all([ - self._fetchX2Manys(record, options), - self._fetchReferences(record, options) - ]).then(function () { - return self._postprocess(record, options); - }); - }); - }, - /** - * Fetch the `display_name` for a reference field. - * - * @private - * @param {Object} record - * @param {string} fieldName - * @returns {Promise} - */ - _fetchReference: function (record, fieldName) { - var self = this; - var def; - var value = record._changes && record._changes[fieldName] || record.data[fieldName]; - var model = value && value.split(',')[0]; - var resID = value && parseInt(value.split(',')[1]); - if (model && model !== 'False' && resID) { - def = self._rpc({ - model: model, - method: 'read', - args: [resID, ['display_name']], - context: record.getContext({fieldName: fieldName}), - }).then(function (result) { - return self._makeDataPoint({ - data: result[0], - modelName: model, - parentID: record.id, - }); - }); - } - return Promise.resolve(def); - }, - /** - * Fetches data for reference fields and assigns these data to newly - * created datapoint. - * Then places datapoint reference into parent record. - * - * @param {Object} datapoints a collection of ids classed by model, - * @see _getDataToFetchByModel - * @param {string} model - * @param {string} fieldName - * @returns {Promise} - */ - _fetchReferenceData: function (datapoints, model, fieldName) { - var self = this; - var ids = Object.keys(datapoints).map((id) => parseInt(id)); - // we need one parent for the context (they all have the same) - var parent = datapoints[ids[0]][0]; - var def = self._rpc({ - model: model, - method: 'read', - args: [ids, ['display_name']], - context: self.localData[parent].getContext({fieldName: fieldName, withoutRecordData: true}), - }).then(function (result) { - result.forEach((el) => { - var parentIDs = datapoints[el['id']]; - parentIDs.forEach((parentID) => { - var parent = self.localData[parentID]; - var referenceDp = self._makeDataPoint({ - data: el, - modelName: model, - parentID: parent.id, - }); - parent.data[fieldName] = referenceDp.id; - }); - }); - }); - return def; - }, - /** - * Fetch the extra data (`display_name`) for the reference fields of the record - * model. - * - * @private - * @param {Object} record - * @returns {Promise} - */ - _fetchReferences: function (record, options) { - var self = this; - var defs = []; - var fieldNames = options && options.fieldNames || record.getFieldNames(); - fieldNames.forEach((fieldName) => { - var field = record.fields[fieldName]; - if (field.type === 'reference') { - var def = self._fetchReference(record, fieldName).then(function (dataPoint) { - if (dataPoint) { - record.data[fieldName] = dataPoint.id; - } - }); - defs.push(def); - } - }); - return Promise.all(defs); - }, - /** - * Batch requests for one reference field in list (one request by different - * model in the field values). - * - * @see _fetchReferencesBatched - * @param {Object} list - * @param {string} fieldName - * @returns {Promise} - */ - _fetchReferenceBatched: function (list, fieldName) { - var self = this; - list = this._applyX2ManyOperations(list); - this._sortList(list); - - var toFetch = this._getDataToFetchByModel(list, fieldName); - var defs = []; - // one display_name read by model - Object.entries(toFetch).forEach(([model, datapoints]) => { - defs.push(self._fetchReferenceData(datapoints, model, fieldName)); - }); - - return Promise.all(defs); - }, - /** - * Batch requests for references for datapoint of type list. - * - * @param {Object} list - * @returns {Promise} - */ - _fetchReferencesBatched: function (list) { - var defs = []; - var fieldNames = list.getFieldNames(); - for (var i = 0; i < fieldNames.length; i++) { - var field = list.fields[fieldNames[i]]; - if (field.type === 'reference') { - defs.push(this._fetchReferenceBatched(list, fieldNames[i])); - } - } - return Promise.all(defs); - }, - /** - * Batch reference requests for all records in list. - * - * @see _fetchReferencesSingleBatch - * @param {Object} list a valid resource object - * @param {string} fieldName - * @returns {Promise} - */ - _fetchReferenceSingleBatch: function (list, fieldName) { - var self = this; - - // collect ids by model - var toFetch = {}; - list.data.forEach((groupIndex) => { - var group = self.localData[groupIndex]; - self._getDataToFetchByModel(group, fieldName, toFetch); - }); - - var defs = []; - // one display_name read by model - Object.entries(toFetch).forEach(([model, datapoints]) => { - defs.push(self._fetchReferenceData(datapoints, model, fieldName)); - }); - - return Promise.all(defs); - }, - /** - * Batch requests for all reference field in list's children. - * Called by _readGroup to make only one display_name 'read' rpc by fieldName. - * - * @param {Object} list a valid resource object - * @returns {Promise} - */ - _fetchReferencesSingleBatch: function (list) { - var defs = []; - var fieldNames = list.getFieldNames(); - for (var fIndex in fieldNames) { - var field = list.fields[fieldNames[fIndex]]; - if (field.type === 'reference') { - defs.push(this._fetchReferenceSingleBatch(list, fieldNames[fIndex])); - } - } - return Promise.all(defs); - }, - /** - * Fetch model data from server, relationally to fieldName and resulted - * field relation. For example, if fieldName is "tag_ids" and referred to - * project.tags, it will fetch project.tags' related fields where its id is - * contained in toFetch.ids array. - * - * @param {Object} list a valid resource object - * @param {Object} toFetch a list of records and res_ids, - * @see _getDataToFetch - * @param {string} fieldName - * @returns {Promise} - */ - _fetchRelatedData: function (list, toFetch, fieldName) { - var self = this; - var ids = Object.keys(toFetch || {}); - for (var i = 0; i < ids.length; i++) { - ids[i] = Number(ids[i]); - } - var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; - - if (!ids.length || fieldInfo.__no_fetch) { - return Promise.resolve(); - } - - var def; - var fieldNames = Object.keys(fieldInfo.relatedFields || {}); - if (fieldNames.length) { - var field = list.fields[fieldName]; - def = this._rpc({ - model: field.relation, - method: 'read', - args: [ids, fieldNames], - context: list.getContext({withoutRecordData: true}) || {}, - }); - } else { - def = Promise.resolve( - ids.map((id) => { - return { id: id }; - }) - ); - } - return def.then(function (result) { - var records = unique(Object.values(toFetch || {}).flat()); - self._updateRecordsData(records, fieldName, result); - }); - }, - /** - * Check the AbstractField specializations that are (will be) used by the - * given record and fetch the special data they will need. Special data are - * data that the rendering of the record won't need if it was not using - * particular widgets (example of these can be found at the methods which - * start with _fetchSpecial). - * - * @param {Object} record - an element from the localData - * @param {Object} options - * @returns {Promise} - * The promise is resolved with an array containing the names of - * the field whose special data has been changed. - */ - _fetchSpecialData: function (record, options) { - var self = this; - var specialFieldNames = []; - var fieldNames = (options && options.fieldNames) || record.getFieldNames(); - return Promise.all(fieldNames.map((name) => { - var viewType = (options && options.viewType) || record.viewType; - var fieldInfo = record.fieldsInfo[viewType][name] || {}; - let specialData; - if ("specialData" in fieldInfo) { - specialData = fieldInfo.specialData; - } else if (fieldInfo.Widget) { - specialData = fieldInfo.Widget.prototype.specialData; - } - if (specialData) { - return self[specialData](record, name, fieldInfo).then(function (data) { - if (data === undefined) { - return; - } - record.specialData[name] = data; - specialFieldNames.push(name); - }); - } - })).then(function () { - return specialFieldNames; - }); - }, - /** - * Fetches all the m2o records associated to the given fieldName. If the - * given fieldName is not a m2o field, nothing is done. - * - * @param {Object} record - an element from the localData - * @param {Object} fieldName - the name of the field - * @param {Object} fieldInfo - * @param {string[]} [fieldsToRead] - the m2os fields to read (id and - * display_name are automatic). - * @returns {Promise} - * The promise is resolved with the fetched special data. If this - * data is the same as the previously fetched one (for the given - * parameters), no RPC is done and the promise is resolved with - * the undefined value. - */ - _fetchSpecialMany2ones: function (record, fieldName, fieldInfo, fieldsToRead) { - var field = record.fields[fieldName]; - if (field.type !== "many2one") { - return Promise.resolve(); - } - - var context = record.getContext({fieldName: fieldName}); - var domain = record.getDomain({fieldName: fieldName}); - if (domain.length) { - var localID = (record._changes && fieldName in record._changes) ? - record._changes[fieldName] : - record.data[fieldName]; - if (localID) { - var element = this.localData[localID]; - domain = ["|", ["id", "=", element.data.id]].concat(domain); - } - } - - // avoid rpc if not necessary - var hasChanged = this._saveSpecialDataCache(record, fieldName, { - context: context, - domain: domain, - }); - if (!hasChanged) { - return Promise.resolve(); - } - - return this._rpc({ - model: field.relation, - method: 'search_read', - fields: ["display_name"].concat(fieldsToRead || []), - context: context, - domain: domain, - }); - }, - /** - * Fetches all the relation records associated to the given fieldName. If - * the given fieldName is not a relational field, nothing is done. - * - * @param {Object} record - an element from the localData - * @param {Object} fieldName - the name of the field - * @returns {Promise} - * The promise is resolved with the fetched special data. If this - * data is the same as the previously fetched one (for the given - * parameters), no RPC is done and the promise is resolved with - * the undefined value. - */ - _fetchSpecialRelation: function (record, fieldName) { - var field = record.fields[fieldName]; - if (!["many2one", "many2many", "one2many"].includes(field.type)) { - return Promise.resolve(); - } - - var context = record.getContext({fieldName: fieldName}); - var domain = record.getDomain({fieldName: fieldName}); - - // avoid rpc if not necessary - var hasChanged = this._saveSpecialDataCache(record, fieldName, { - context: context, - domain: domain, - }); - if (!hasChanged) { - return Promise.resolve(); - } - - return this._rpc({ - model: field.relation, - method: 'name_search', - args: ["", domain], - context: context - }); - }, - /** - * Fetches the `display_name` associated to the reference widget if the field is - * a `char` (which is a supported case). - * - * @private - * @param {Object} record - an element from the localData - * @param {Object} fieldName - the name of the field - * @param {Object} fieldInfo - the info of the field - * @returns {Promise} - */ - _fetchSpecialReference: function (record, fieldName, fieldInfo) { - var field = record.fields[fieldName]; - if (field.type === 'char') { - // if the widget reference is set on a char field, the display_name - // needs to be fetched a posteriori - return Promise.resolve(this._fetchReference(record, fieldName)); - } else if (fieldInfo.options.model_field) { - return this._fetchModelFieldReference(record, fieldName, fieldInfo); - } - return Promise.resolve(); - }, - /** - * Fetches all the m2o records associated to the given fieldName. If the - * given fieldName is not a m2o field, nothing is done. The difference with - * _fetchSpecialMany2ones is that the field given by options.fold_field is - * also fetched. - * - * @param {Object} record - an element from the localData - * @param {Object} fieldName - the name of the field - * @param {Object} fieldInfo - * @returns {Promise} - * The promise is resolved with the fetched special data. If this - * data is the same as the previously fetched one (for the given - * parameters), no RPC is done and the promise is resolved with - * the undefined value. - */ - _fetchSpecialStatus: function (record, fieldName, fieldInfo) { - var foldField = fieldInfo.options.fold_field; - var fieldsToRead = foldField ? [foldField] : []; - return this._fetchSpecialMany2ones(record, fieldName, fieldInfo, fieldsToRead).then(function (m2os) { - Object.values(m2os || {}).forEach((m2o) => { - m2o.fold = foldField ? m2o[foldField] : false; - }); - return m2os; - }); - }, - /** - * Fetch all data in a ungrouped list - * - * @param {Object} list a valid resource object - * @param {Object} [options] - * @param {boolean} [options.enableRelationalFetch=true] if false, will not - * fetch x2m and relational data (that will be done by _readGroup in this - * case). - * @returns {Promise} resolves to the fecthed list - */ - _fetchUngroupedList: function (list, options) { - options = Object.assign({ enableRelationalFetch: true }, options || {}); - var self = this; - var def; - if (list.static) { - def = this._readUngroupedList(list).then(function () { - if (list.parentID && self.isNew(list.parentID)) { - // list from a default_get, so fetch display_name for many2one fields - var many2ones = self._getMany2OneFieldNames(list); - var defs = many2ones.map((name) => self._fetchNameGets(list, name)); - return Promise.all(defs); - } - }); - } else { - def = this._searchReadUngroupedList(list); - } - return def.then(function () { - if (options.enableRelationalFetch) { - return Promise.all([ - self._fetchX2ManysBatched(list), - self._fetchReferencesBatched(list) - ]); - } - }).then(function () { - return list; - }); - }, - /** - * batch requests for 1 x2m in list - * - * @see _fetchX2ManysBatched - * @param {Object} list - * @param {string} fieldName - * @returns {Promise} - */ - _fetchX2ManyBatched: function (list, fieldName) { - list = this._applyX2ManyOperations(list); - this._sortList(list); - - var toFetch = this._getDataToFetch(list, fieldName); - return this._fetchRelatedData(list, toFetch, fieldName); - }, - /** - * X2Manys have to be fetched by separate rpcs (their data are stored on - * different models). This method takes a record, look at its x2many fields, - * then, if necessary, create a local resource and fetch the corresponding - * data. - * - * It also tries to reuse data, if it can find an existing list, to prevent - * useless rpcs. - * - * @param {Object} record local resource - * @param {Object} [options] - * @param {string[]} [options.fieldNames] the list of fields to fetch. - * If not given, fetch all the fields in record.fieldNames - * @param {string} [options.viewType] the type of view for which the main - * record is fetched (useful to load the adequate fields), by defaults, - * uses record.viewType - * @returns {Promise} - */ - _fetchX2Manys: function (record, options) { - var self = this; - var defs = []; - options = options || {}; - var fieldNames = options.fieldNames || record.getFieldNames(options); - var viewType = options.viewType || record.viewType; - fieldNames.forEach((fieldName) => { - var field = record.fields[fieldName]; - if (field.type === 'one2many' || field.type === 'many2many') { - var fieldInfo = record.fieldsInfo[viewType][fieldName]; - var rawContext = fieldInfo && fieldInfo.context; - var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - var fieldsInfo = view ? view.fieldsInfo : (fieldInfo.fieldsInfo || {}); - var ids = record.data[fieldName] || []; - - // remove default_* & *_view_ref keys from record context - const recordContext = { ...record.context }; - for (const key in recordContext) { - if (key.startsWith("default_") || key.endsWith("_view_ref")) { - delete recordContext[key]; - } - } - - var list = self._makeDataPoint({ - count: ids.length, - context: Object.assign({}, recordContext, field.context), - fieldsInfo: fieldsInfo, - fields: view ? view.fields : fieldInfo.relatedFields, - limit: fieldInfo.limit, - modelName: field.relation, - res_ids: ids, - static: true, - type: 'list', - orderedBy: fieldInfo.orderedBy, - parentID: record.id, - rawContext: rawContext, - relationField: field.relation_field, - viewType: view ? view.type : fieldInfo.viewType, - }); - record.data[fieldName] = list.id; - if (!fieldInfo.__no_fetch) { - var def = self._readUngroupedList(list, { withoutRecordData: true }).then(function () { - return Promise.all([ - self._fetchX2ManysBatched(list), - self._fetchReferencesBatched(list) - ]); - }); - defs.push(def); - } - } - }); - return Promise.all(defs); - }, - /** - * batch request for x2ms for datapoint of type list - * - * @param {Object} list - * @returns {Promise} - */ - _fetchX2ManysBatched: function (list) { - var defs = []; - var fieldNames = list.getFieldNames(); - for (var i = 0; i < fieldNames.length; i++) { - var field = list.fields[fieldNames[i]]; - if (field.type === 'many2many' || field.type === 'one2many') { - defs.push(this._fetchX2ManyBatched(list, fieldNames[i])); - } - } - return Promise.all(defs); - }, - /** - * For a non-static list, batches requests for all its sublists' records. - * Make only one rpc for all records on the concerned field. - * - * @see _fetchX2ManysSingleBatch - * @param {Object} list a valid resource object, its data must be another - * list containing records - * @param {string} fieldName - * @returns {Promise} - */ - _fetchX2ManySingleBatch: function (list, fieldName) { - var self = this; - var toFetch = {}; - list.data.forEach((groupIndex) => { - var group = self.localData[groupIndex]; - var nextDataToFetch = self._getDataToFetch(group, fieldName); - Object.keys(nextDataToFetch).forEach((id) => { - if (toFetch[id]) { - toFetch[id] = toFetch[id].concat(nextDataToFetch[id]); - } else { - toFetch[id] = nextDataToFetch[id]; - } - }); - }); - return self._fetchRelatedData(list, toFetch, fieldName); - }, - /** - * Batch requests for all x2m in list's children. - * Called by _readGroup to make only one 'read' rpc by fieldName. - * - * @param {Object} list a valid resource object - * @returns {Promise} - */ - _fetchX2ManysSingleBatch: function (list) { - var defs = []; - var fieldNames = list.getFieldNames(); - for (var i = 0; i < fieldNames.length; i++) { - var field = list.fields[fieldNames[i]]; - if (field.type === 'many2many' || field.type === 'one2many'){ - defs.push(this._fetchX2ManySingleBatch(list, fieldNames[i])); - } - } - return Promise.all(defs); - }, - /** - * Generates an object mapping field names to their changed value in a given - * record (i.e. maps to the new value for basic fields, to the res_id for - * many2ones and to commands for x2manys). - * - * @private - * @param {Object} record - * @param {Object} [options] - * @param {boolean} [options.changesOnly=true] if true, only generates - * commands for fields that have changed (concerns x2many fields only) - * @param {boolean} [options.withReadonly=false] if false, doesn't generate - * changes for readonly fields - * @param {string} [options.viewType] current viewType. If not set, we will - * assume main viewType from the record. Note that if an editionViewType is - * specified for a field, it will take the priority over the viewType arg. - * @returns {Object} a map from changed fields to their new value - */ - _generateChanges: function (record, options) { - options = options || {}; - var viewType = options.viewType || record.viewType; - var changes; - const changesOnly = 'changesOnly' in options ? !!options.changesOnly : true; - if (!changesOnly) { - changes = Object.assign({}, record.data, record._changes); - } else { - changes = Object.assign({}, record._changes); - } - var withReadonly = options.withReadonly || false; - var commands = this._generateX2ManyCommands(record, { - changesOnly: changesOnly, - withReadonly: withReadonly, - }); - for (var fieldName in record.fields) { - // remove readonly fields from the list of changes - if (!withReadonly && fieldName in changes || fieldName in commands) { - var editionViewType = record._editionViewType[fieldName] || viewType; - if (this._isFieldProtected(record, fieldName, editionViewType)) { - delete changes[fieldName]; - continue; - } - } - - // process relational fields and handle the null case - var type = record.fields[fieldName].type; - var value; - if (type === 'one2many' || type === 'many2many') { - if (!changesOnly || (commands[fieldName] && commands[fieldName].length)) { // replace localId by commands - changes[fieldName] = commands[fieldName]; - } else { // no command -> no change for that field - delete changes[fieldName]; - } - } else if (type === 'many2one' && fieldName in changes) { - value = changes[fieldName]; - changes[fieldName] = value ? this.localData[value].res_id : false; - } else if (type === 'reference' && fieldName in changes) { - value = changes[fieldName]; - changes[fieldName] = value ? - this.localData[value].model + ',' + this.localData[value].res_id : - false; - } else if (type === 'char' && changes[fieldName] === '') { - changes[fieldName] = false; - } else if (changes[fieldName] === null) { - changes[fieldName] = false; - } - } - return changes; - }, - /** - * Generates an object mapping field names to their current value in a given - * record. If the record is inside a one2many, the returned object contains - * an additional key (the corresponding many2one field name) mapping to the - * current value of the parent record. - * - * @param {Object} record - * @param {Object} [options] This option object will be given to the private - * method _generateX2ManyCommands. In particular, it is useful to be able - * to send changesOnly:true to get all data, not only the current changes. - * @returns {Object} the data - */ - _generateOnChangeData: function (record, options) { - options = Object.assign({}, options || {}, {withReadonly: true}); - var data = {}; - if (!options.firstOnChange) { - var commands = this._generateX2ManyCommands(record, options); - data = Object.assign(this.get(record.id, {raw: true}).data, commands); - // 'display_name' is automatically added to the list of fields to fetch, - // when fetching a record, even if it doesn't appear in the view. However, - // only the fields in the view must be passed to the onchange RPC, so we - // remove it from the data sent by RPC if it isn't in the view. - var hasDisplayName = Object.values(record.fieldsInfo || {}).some((fieldsInfo) => { - return 'display_name' in fieldsInfo; - }); - if (!hasDisplayName) { - delete data.display_name; - } - } - - // one2many records have a parentID - if (record.parentID) { - var parent = this.localData[record.parentID]; - // parent is the list element containing all the records in the - // one2many and parent.parentID is the ID of the main record - // if there is a relation field, this means that record is an elem - // in a one2many. The relation field is the corresponding many2one - if (parent.parentID && parent.relationField) { - var parentRecord = this.localData[parent.parentID]; - data[parent.relationField] = this._generateOnChangeData(parentRecord); - } - } - - return data; - }, - /** - * Read all x2many fields and generate the commands for the server to create - * or write them... - * - * @param {Object} record - * @param {Object} [options] - * @param {string} [options.fieldNames] if given, generates the commands for - * these fields only - * @param {boolean} [changesOnly=false] if true, only generates commands for - * fields that have changed - * @param {boolean} [options.withReadonly=false] if false, doesn't generate - * changes for readonly fields in commands - * @returns {Object} a map from some field names to commands - */ - _generateX2ManyCommands: function (record, options) { - var self = this; - options = options || {}; - const changesOnly = options.changesOnly; - var fields = record.fields; - if (options.fieldNames) { - fields = pick(fields, ...options.fieldNames); - } - var commands = {}; - var data = Object.assign({}, record.data, record._changes); - var type; - for (var fieldName in fields) { - type = fields[fieldName].type; - - if (type === 'many2many' || type === 'one2many') { - if (!data[fieldName]) { - // skip if this field is empty - continue; - } - commands[fieldName] = []; - var list = this.localData[data[fieldName]]; - if (options.changesOnly && (!list._changes || !list._changes.length)) { - // if only changes are requested, skip if there is no change - continue; - } - var oldResIDs = list.res_ids.slice(0); - var relRecordAdded = []; - var relRecordUpdated = []; - Object.values(list._changes || {}).forEach((change) => { - if (change.operation === 'ADD' && change.id) { - relRecordAdded.push(self.localData[change.id]); - } else if (change.operation === 'UPDATE' && !self.isNew(change.id)) { - // ignore new records that would have been updated - // afterwards, as all their changes would already - // be aggregated in the CREATE command - relRecordUpdated.push(self.localData[change.id]); - } - }); - list = this._applyX2ManyOperations(list); - this._sortList(list); - if (type === 'many2many' || list._forceM2MLink) { - var relRecordCreated = relRecordAdded.filter(rec => typeof rec.res_id === 'string'); - const relRecordCreatedResId = relRecordCreated.map(r => r.res_id) - var realIDs = list.res_ids.filter((x) => relRecordCreatedResId.indexOf(x) < 0) - // deliberately generate a single 'replace' command instead - // of a 'delete' and a 'link' commands with the exact diff - // because 1) performance-wise it doesn't change anything - // and 2) to guard against concurrent updates (policy: force - // a complete override of the actual value of the m2m) - commands[fieldName].push(x2ManyCommands.replace_with(realIDs)); - relRecordCreated.forEach((relRecord) => { - var changes = self._generateChanges(relRecord, options); - commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); - }); - // generate update commands for records that have been - // updated (it may happen with editable lists) - relRecordUpdated.forEach((relRecord) => { - var changes = self._generateChanges(relRecord, options); - if (Object.keys(changes || {}).length > 0) { - var command = x2ManyCommands.update(relRecord.res_id, changes); - commands[fieldName].push(command); - } - }); - } else if (type === 'one2many') { - var removedIds = oldResIDs.filter((x) => list.res_ids.indexOf(x) < 0); - var addedIds = list.res_ids.filter((x) => oldResIDs.indexOf(x) < 0); - var keptIds = intersection(oldResIDs, list.res_ids); - - // the didChange variable keeps track of the fact that at - // least one id was updated - var didChange = false; - var changes, command, relRecord; - for (var i = 0; i < list.res_ids.length; i++) { - if (keptIds.includes(list.res_ids[i])) { - // this is an id that already existed - relRecord = relRecordUpdated.find((record) => record.res_id === list.res_ids[i]); - changes = relRecord ? this._generateChanges(relRecord, options) : {}; - if (Object.keys(changes || {}).length > 0) { - command = x2ManyCommands.update(relRecord.res_id, changes); - didChange = true; - } else { - command = x2ManyCommands.link_to(list.res_ids[i]); - } - commands[fieldName].push(command); - } else if (addedIds.includes(list.res_ids[i])) { - // this is a new id (maybe existing in DB, but new in JS) - relRecord = relRecordAdded.find((record) => record.res_id === list.res_ids[i]); - if (!relRecord) { - commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i])); - continue; - } - changes = this._generateChanges(relRecord, options); - if (!this.isNew(relRecord.id)) { - // the subrecord already exists in db - commands[fieldName].push(x2ManyCommands.link_to(relRecord.res_id)); - if (changesOnly ? Object.keys(changes).length : this.isDirty(relRecord.id)) { - delete changes.id; - commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes)); - } - } else { - // the subrecord is new, so create it - - // we may have received values from an onchange for fields that are - // not in the view, and that we don't even know, as we don't have the - // fields_get of models of related fields. We save those values - // anyway, but for many2ones, we have to extract the id from the pair - // [id, display_name] - const rawChangesEntries = Object.entries(relRecord._rawChanges); - for (const [fieldName, value] of rawChangesEntries) { - const isMany2OneValue = Array.isArray(value) && - value.length === 2 && - Number.isInteger(value[0]) && - typeof value[1] === 'string'; - changes[fieldName] = isMany2OneValue ? value[0] : value; - } - - commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); - } - } - } - if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { - // in this situation, we have no changed ids, no added - // ids and no removed ids, so we can safely ignore the - // last changes - commands[fieldName] = []; - } - // add delete commands - for (i = 0; i < removedIds.length; i++) { - if (list._forceM2MUnlink) { - commands[fieldName].push(x2ManyCommands.forget(removedIds[i])); - } else { - commands[fieldName].push(x2ManyCommands.delete(removedIds[i])); - } - } - } - } - } - return commands; - }, - /** - * Every RPC done by the model need to add some context, which is a - * combination of the context of the session, of the record/list, and/or of - * the concerned field. This method combines all these contexts and evaluate - * them with the proper evalcontext. - * - * @param {Object} element an element from the localData - * @param {Object} [options] - * @param {string|Object} [options.additionalContext] - * another context to evaluate and merge to the returned context - * @param {string} [options.fieldName] - * if given, this field's context is added to the context, instead of - * the element's context (except if options.full is true) - * @param {boolean} [options.full=false] - * if true or nor fieldName or additionalContext given in options, - * the element's context is added to the context - * @param {boolean} [options.withoutRecordData=false] - * if true the record data will be replaced by a sentinel in the eval - * context (useful for read rpcs, where we don't want the context to - * depend on data) - * @returns {Object} the evaluated context - */ - _getContext: function (element, options) { - options = options || {}; - var context = new Context(session.user_context); - - if (options.full || !(options.fieldName || options.additionalContext)) { - const keys_to_omit = Object.keys(element.context || {}).filter((key) => String(key).startsWith('default_')); - var context_to_add = options.sanitize_default_values ? - omit(element.context, ...keys_to_omit) - : element.context; - context.add(context_to_add); - } - if (options.fieldName) { - var viewType = options.viewType || element.viewType; - var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; - if (fieldInfo && fieldInfo.context) { - if (fieldInfo.context !== "{}") { - context.add(fieldInfo.context); - } - } else { - var fieldParams = element.fields[options.fieldName]; - if (fieldParams.context) { - context.add(fieldParams.context); - } - } - } - if (element.rawContext) { - var rawContext = new Context(element.rawContext); - var evalContext = this._getEvalContext(this.localData[element.parentID], false, options.withoutRecordData); - evalContext.id = evalContext.id || false; - rawContext.set_eval_context(evalContext); - context.add(rawContext); - } - if (options.additionalContext) { - context.add(options.additionalContext); - } - - if (context.__contexts.length > 1) { - context.set_eval_context(this._getEvalContext(element, false, options.withoutRecordData)); - const evaluatedContext = context.eval(); - if (options.withoutRecordData) { - for (const key in evaluatedContext) { - if (evaluatedContext[key] === SENTINEL) { - delete evaluatedContext[key]; - } - } - if (evaluatedContext.parent) { - for (const key in evaluatedContext.parent) { - if (evaluatedContext.parent[key] === SENTINEL) { - delete evaluatedContext.parent[key]; - } - } - } - } - return evaluatedContext; - } else { - return Object.assign({}, session.user_context); - } - }, - /** - * Collects from a record a list of ids to fetch, according to fieldName, - * and a list of records where to set the result of the fetch. - * - * @param {Object} list a list containing records we want to get the ids, - * it assumes _applyX2ManyOperations and _sort have been already called on - * this list - * @param {string} fieldName - * @return {Object} a list of records and res_ids - */ - _getDataToFetch: function (list, fieldName) { - var self = this; - var field = list.fields[fieldName]; - var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; - var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; - var fields = view ? view.fields : fieldInfo.relatedFields; - var viewType = view ? view.type : fieldInfo.viewType; - - var toFetch = {}; - - // flattens the list.data ids in a grouped case - let dataPointIds = list.data; - for (let i = 0; i < list.groupedBy.length; i++) { - dataPointIds = dataPointIds.reduce((acc, groupId) => - acc.concat(this.localData[groupId].data), []); - } - - dataPointIds.forEach(function (dataPoint) { - var record = self.localData[dataPoint]; - if (typeof record.data[fieldName] === 'string'){ - // in this case, the value is a local ID, which means that the - // record has already been processed. It can happen for example - // when a user adds a record in a m2m relation, or loads more - // records in a kanban column - return; - } - - Object.values(record.data[fieldName] || {}).forEach((id) => { - toFetch[id] = toFetch[id] || []; - toFetch[id].push(record); - }); - - var m2mList = self._makeDataPoint({ - fieldsInfo: fieldsInfo, - fields: fields, - modelName: field.relation, - parentID: record.id, - res_ids: record.data[fieldName], - static: true, - type: 'list', - viewType: viewType, - }); - record.data[fieldName] = m2mList.id; - }); - - return toFetch; - }, - /** - * Determines and returns from a list a collection of ids classed by - * their model. - * - * @param {Object} list a valid resource object - * @param {string} fieldName - * @param {Object} [toFetchAcc] an object to store fetching data. Used when - * batching reference across multiple groups. - * [modelName: string]: { - * [recordId: number]: datapointId[] - * } - * @returns {Object} each key represent a model and contain a sub-object - * where each key represent an id (res_id) containing an array of - * webclient id (referred to a datapoint, so not a res_id). - */ - _getDataToFetchByModel: function (list, fieldName, toFetchAcc) { - var self = this; - var toFetch = toFetchAcc || {}; - list.data.forEach((dataPoint) => { - var record = self.localData[dataPoint]; - var value = record.data[fieldName]; - // if the reference field has already been fetched, the value is a - // datapoint ID, and in this case there's nothing to do - if (value && !self.localData[value]) { - var model = value.split(',')[0]; - var resID = value.split(',')[1]; - if (!(model in toFetch)) { - toFetch[model] = {}; - } - // there could be multiple datapoints with the same model/resID - if (toFetch[model][resID]) { - toFetch[model][resID].push(dataPoint); - } else { - toFetch[model][resID] = [dataPoint]; - } - } - }); - return toFetch; - }, - /** - * Given a dataPoint of type list (that may be a group), returns an object - * with 'default_' keys to be used to create new records in that group. - * - * @private - * @param {Object} dataPoint - * @returns {Object} - */ - _getDefaultContext: function (dataPoint) { - var defaultContext = {}; - while (dataPoint.parentID) { - var parent = this.localData[dataPoint.parentID]; - var groupByField = parent.groupedBy[0].split(':')[0]; - var value = viewUtils.getGroupValue(dataPoint, parent.groupedBy[0]); - if (value) { - defaultContext['default_' + groupByField] = value; - } - dataPoint = parent; - } - return defaultContext; - }, - /** - * Some records are associated to a/some domain(s). This method allows to - * retrieve them, evaluated. - * - * @param {Object} element an element from the localData - * @param {Object} [options] - * @param {string} [options.fieldName] - * the name of the field whose domain needs to be returned - * @returns {Array} the evaluated domain - */ - _getDomain: function (element, options) { - if (options && options.fieldName) { - if (element._domains[options.fieldName]) { - return Domain.prototype.stringToArray( - element._domains[options.fieldName], - this._getEvalContext(element, true) - ); - } - var viewType = options.viewType || element.viewType; - var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; - if (fieldInfo && fieldInfo.domain) { - return Domain.prototype.stringToArray( - fieldInfo.domain, - this._getEvalContext(element, true) - ); - } - var fieldParams = element.fields[options.fieldName]; - if (fieldParams.domain) { - return Domain.prototype.stringToArray( - fieldParams.domain, - this._getEvalContext(element, true) - ); - } - return []; - } - - return Domain.prototype.stringToArray( - element.domain, - this._getEvalContext(element, true) - ); - }, - /** - * Returns the evaluation context that should be used when evaluating the - * context/domain associated to a given element from the localData. - * - * It is actually quite subtle. We need to add some magic keys: active_id - * and active_ids. Also, the session user context is added in the mix to be - * sure. This allows some domains to use the uid key for example - * - * @param {Object} element - an element from the localData - * @param {boolean} [forDomain=false] if true, evaluates x2manys as a list of - * ids instead of a list of commands - * @param {boolean} [withoutRecordData=false] if true, replaces the record - * values by null (same inside the parent key) - * @returns {Object} - */ - _getEvalContext: function (element, forDomain, withoutRecordData) { - var evalContext = element.type === 'record' ? this._getRecordEvalContext(element, forDomain) : {}; - if (withoutRecordData) { - const entries = Object.keys(evalContext).map((key) => [key, SENTINEL]); - evalContext = Object.fromEntries(entries); - } - if (element.parentID) { - var parent = this.localData[element.parentID]; - if (parent.type === 'list' && parent.parentID) { - parent = this.localData[parent.parentID]; - } - if (parent.type === 'record') { - evalContext.parent = this._getRecordEvalContext(parent, forDomain); - if (withoutRecordData) { - const entries = Object.keys(evalContext.parent).map((key) => [key, SENTINEL]); - evalContext.parent = Object.fromEntries(entries); - } - } - } - // Uses "current_company_id" because "company_id" would conflict with all the company_id fields - // in general, the actual "company_id" field of the form should be used for m2o domains, not this fallback - let current_company_id; - if (session.user_context.allowed_company_ids) { - current_company_id = session.user_context.allowed_company_ids[0]; - } else { - current_company_id = session.user_companies ? - session.user_companies.current_company : - false; - } - return Object.assign( - { - active_id: evalContext.id || false, - active_ids: evalContext.id ? [evalContext.id] : [], - active_model: element.model, - current_company_id, - id: evalContext.id || false, - }, - session.user_context, - element.context, - evalContext, - ); - }, - - _getLazyEvalContext: function (element, forDomain) { - var evalContext = element.type === 'record' ? this._getLazyRecordEvalContext(element, forDomain) : {}; - - if (element.parentID) { - var parent = this.localData[element.parentID]; - if (parent.type === 'list' && parent.parentID) { - parent = this.localData[parent.parentID]; - } - if (parent.type === 'record') { - evalContext.parent = this._getLazyRecordEvalContext(parent, forDomain); - } - } - // Uses "current_company_id" because "company_id" would conflict with all the company_id fields - // in general, the actual "company_id" field of the form should be used for m2o domains, not this fallback - let current_company_id; - if (session.user_context.allowed_company_ids) { - current_company_id = session.user_context.allowed_company_ids[0]; - } else { - current_company_id = session.user_companies ? - session.user_companies.current_company : - false; - } - let fallbackContext = Object.assign( - { - active_id: evalContext.id || false, - active_ids: evalContext.id ? [evalContext.id] : [], - active_model: element.model, - current_company_id, - id: evalContext.id || false, - }, - session.user_context, - element.context, - ); - - return new Proxy( - {}, { - has(target, key) { - return key in target || key in evalContext || key in fallbackContext; - }, - get (target, key) { - switch (true) { - case key in target: - return target[key]; - case key in evalContext: - return evalContext[key]; - default: - return fallbackContext[key];; - } - }, - ownKeys(target) { - return [...new Set([...Reflect.ownKeys(target), ...Object.keys(fallbackContext), ...Object.keys(evalContext)])]; - }, - getOwnPropertyDescriptor(target, prop) { - return Reflect.getOwnPropertyDescriptor(target, prop) || { writable: true, configurable: true, enumerable: true }; - }, - } - ); - }, - /** - * Returns the list of field names of the given element according to its - * default view type. - * - * @param {Object} element an element from the localData - * @param {Object} [options] - * @param {Object} [options.viewType] current viewType. If not set, we will - * assume main viewType from the record - * @returns {string[]} the list of field names - */ - _getFieldNames: function (element, options) { - var fieldsInfo = element.fieldsInfo; - var viewType = options && options.viewType || element.viewType; - return Object.keys(fieldsInfo && fieldsInfo[viewType] || {}); - }, - /** - * Get a subset of a list resource keeping only the props to keep. - * This permits to a web_read_group to keep some of the properties - * of a previous list if it is still in its results. - * - * @private - * @param {Object} list - * @param {boolean} options.onlyGroup true when we only fetched the group's own data - * @param {boolean} options.hasSubgroups true when the fetched group has subgroups - * @returns {Object} subset of list - */ - _getGroupedListPropsToKeep(list, options) { - const { isOpen, offset, id } = list; - let propsToKeep = { isOpen, offset, id }; - - if (options.onlyGroup || isOpen && options.hasSubgroups) { - // If the group is opened and contains subgroups, - // also keep its data to keep internal state of - // sub-groups - // Also keep data if we only reload groups' own data - propsToKeep.data = list.data; - if (options.onlyGroup) { - // keep count and res_ids as in this case the group - // won't be search_read again. This situation happens - // when using kanban quick_create where the record is manually - // added to the datapoint before getting here. - propsToKeep.res_ids = list.res_ids; - propsToKeep.count = list.count; - } - } - - return propsToKeep; - }, - /** - * Get many2one fields names in a datapoint. This is useful in order to - * fetch their names in the case of a default_get. - * - * @private - * @param {Object} datapoint a valid resource object - * @returns {string[]} list of field names that are many2one - */ - _getMany2OneFieldNames: function (datapoint) { - var many2ones = []; - for (const [name, field] of Object.entries(datapoint.fields)) { - if (field.type === 'many2one') { - many2ones.push(name); - } - } - return many2ones; - }, - /** - * Evaluate the record evaluation context. This method is supposed to be - * called by _getEvalContext. It basically only generates a dictionary of - * current values for the record, with commands for x2manys fields. - * - * @param {Object} record an element of type 'record' - * @returns Object - */ - _getRecordEvalContext: function (record) { - var relDataPoint; - var context = Object.assign({}, record.data, record._changes); - - for (var fieldName in context) { - var field = record.fields[fieldName]; - if (context[fieldName] === null) { - context[fieldName] = false; - } - if (!field || field.name === 'id') { - continue; - } - if (field.type === 'date' || field.type === 'datetime') { - if (context[fieldName]) { - context[fieldName] = JSON.parse(JSON.stringify(context[fieldName])); - } - continue; - } - if (field.type === 'many2one') { - relDataPoint = this.localData[context[fieldName]]; - context[fieldName] = relDataPoint ? relDataPoint.res_id : false; - continue; - } - if (field.type === 'one2many' || field.type === 'many2many') { - var ids; - if (!context[fieldName] || Array.isArray(context[fieldName])) { // no dataPoint created yet - ids = context[fieldName] ? context[fieldName].slice(0) : []; - } else { - relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]); - ids = relDataPoint.res_ids.slice(0); - } - if (field.type === 'one2many') { // Ids are evaluated as a list of ids - /* Filtering out virtual ids from the ids list - * The server will crash if there are virtual ids in there - * The webClient doesn't do literal id list comparison like ids == list - * Only relevant in o2m: m2m does create actual records in db - */ - ids = ids.filter(id => typeof id !== 'string'); - } - context[fieldName] = ids; - } - - } - return context; - }, - - _getLazyRecordEvalContext: function (record) { - let self = this; - let relDataPoint; - - return new Proxy({}, { - has(target, key) { - return key in target || key in record.fields; - }, - ownKeys(target) { - return [...new Set([...Reflect.ownKeys(target), ...Object.keys(record.fields)])]; - }, - getOwnPropertyDescriptor(target, prop) { - return Reflect.getOwnPropertyDescriptor(target, prop) || { writable: true, configurable: true, enumerable: true }; - }, - get( target, fieldName ) { - if (fieldName in target){ - return target[fieldName]; - } - const fieldValue = record._changes && fieldName in record._changes ? record._changes[fieldName] : record.data[fieldName]; - const field = record.fields[fieldName]; - if (fieldValue === null) { - return false; - } - if (fieldName === "id" && !fieldValue) { - return false; - } - - if (field.type === 'date' || field.type === 'datetime') { - if (fieldValue) { - return JSON.parse(JSON.stringify(fieldValue)); - } - } - if (field.type === 'many2one') { - relDataPoint = self.localData[fieldValue]; - return relDataPoint ? relDataPoint.res_id : false; - } - if (field.type === 'one2many' || field.type === 'many2many') { - let ids; - if (!fieldValue || Array.isArray(fieldValue)) { // no dataPoint created yet - ids = fieldValue ? fieldValue.slice(0) : []; - } else { - relDataPoint = self._applyX2ManyOperations(self.localData[fieldValue]); - ids = relDataPoint.res_ids.slice(0); - } - if (field.type === 'one2many') { // Ids are evaluated as a list of ids - /* Filtering out virtual ids from the ids list - * The server will crash if there are virtual ids in there - * The webClient doesn't do literal id list comparison like ids == list - * Only relevant in o2m: m2m does create actual records in db - */ - ids = ids.filter(id => typeof id !== 'string'); - } - return ids; - } - if (field.type === "properties" && Array.isArray(fieldValue)) { - // remove deleted properties to be able - // to filter based on empty properties field - return fieldValue.filter(definition => !definition.definition_deleted); - } - return fieldValue; - } - }); - }, - /** - * Get the domain for a list resource. - * - * @private - * @param {Object} list - * @returns {Array[]} list domain - */ - _getUngroupedListDomain(list) { - return list.domain || []; - }, - /** - * Returns true if the field is protected against changes, looking for a - * readonly modifier unless there is a force_save modifier (checking first - * in the modifiers, and if there is no readonly modifier, checking the - * readonly attribute of the field). - * - * @private - * @param {Object} record an element from the localData - * @param {string} fieldName - * @param {string} [viewType] current viewType. If not set, we will assume - * main viewType from the record - * @returns {boolean} - */ - _isFieldProtected: function (record, fieldName, viewType) { - viewType = viewType || record.viewType; - var fieldInfo = viewType && record.fieldsInfo && record.fieldsInfo[viewType][fieldName]; - if (fieldInfo) { - var rawModifiers = fieldInfo.modifiers || {}; - var modifiers = this._evalModifiers(record, pick(rawModifiers, 'readonly')); - return modifiers.readonly && !fieldInfo.force_save; - } else { - return false; - } - }, - /** - * Returns true iff value is considered to be set for the given field's type. - * - * @private - * @param {any} value a value for the field - * @param {string} fieldType a type of field - * @returns {boolean} - */ - _isFieldSet: function (value, fieldType) { - switch (fieldType) { - case 'boolean': - return true; - case 'one2many': - case 'many2many': - return value.length > 0; - default: - return value !== false; - } - }, - /** - * return true if a list element is 'valid'. Such an element is valid if it - * has no sub record with an unset required field. - * - * This method is meant to be used to check if a x2many change will trigger - * an onchange. - * - * @param {string} id id for a local resource of type 'list'. This is - * assumed to be a list element for an x2many - * @returns {boolean} - */ - _isX2ManyValid: function (id) { - var self = this; - var isValid = true; - var element = this.localData[id]; - Object.values(element._changes || {}).forEach((command) => { - if (command.operation === 'DELETE' || - command.operation === 'FORGET' || - (command.operation === 'ADD' && !command.isNew)|| - command.operation === 'REMOVE_ALL') { - return; - } - var recordData = self.get(command.id, {raw: true}).data; - var record = self.localData[command.id]; - element.getFieldNames().forEach((fieldName) => { - var field = element.fields[fieldName]; - var fieldInfo = element.fieldsInfo[element.viewType][fieldName]; - var rawModifiers = fieldInfo.modifiers || {}; - var modifiers = self._evalModifiers(record, pick(rawModifiers, 'required')); - if (modifiers.required && !self._isFieldSet(recordData[fieldName], field.type)) { - isValid = false; - } - }); - }); - return isValid; - }, - /** - * Helper method for the load entry point. - * - * @see load - * - * @param {Object} dataPoint some local resource - * @param {Object} [options] - * @param {string[]} [options.fieldNames] the fields to fetch for a record - * @param {boolean} [options.onlyGroups=false] - * @param {boolean} [options.keepEmptyGroups=false] if set, the groups not - * present in the read_group anymore (empty groups) will stay in the - * datapoint (used to mimic the kanban renderer behaviour for example) - * @returns {Promise} - */ - _load: function (dataPoint, options) { - if (options && options.onlyGroups && - !(dataPoint.type === 'list' && dataPoint.groupedBy.length)) { - return Promise.resolve(dataPoint); - } - - if (dataPoint.type === 'record') { - return this._fetchRecord(dataPoint, options); - } - if (dataPoint.type === 'list' && dataPoint.groupedBy.length) { - return this._readGroup(dataPoint, options); - } - if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { - return this._fetchUngroupedList(dataPoint, options); - } - }, - /** - * Turns a bag of properties into a valid local resource. Also, register - * the resource in the localData object. - * - * @param {Object} params - * @param {Object} [params.aggregateValues={}] - * @param {Object} [params.context={}] context of the action - * @param {integer} [params.count=0] number of record being manipulated - * @param {Object|Object[]} [params.data={}|[]] data of the record - * @param {*[]} [params.domain=[]] - * @param {Object} params.fields contains the description of each field - * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field - * @param {Object[]} [params.fieldNames] the name of fields to load, the list - * of all fields by default - * @param {string[]} [params.groupedBy=[]] - * @param {boolean} [params.isOpen] - * @param {integer} params.limit max number of records shown on screen (pager size) - * @param {string} params.modelName - * @param {integer} [params.offset] - * @param {boolean} [params.openGroupByDefault] - * @param {Object[]} [params.orderedBy=[]] - * @param {integer[]} [params.orderedResIDs] - * @param {string} [params.parentID] model name ID of the parent model - * @param {Object} [params.rawContext] - * @param {[type]} [params.ref] - * @param {string} [params.relationField] - * @param {integer|null} [params.res_id] actual id of record in the server - * @param {integer[]} [params.res_ids] context in which the data point is used, from a list of res_id - * @param {boolean} [params.static=false] - * @param {string} [params.type='record'|'list'] - * @param {[type]} [params.value] - * @param {Object} [params.range] only for datapoints representing groups - * coming from a groupBy on a date(time) field @see _readGroup format: - * {[fieldName:granularity]: {from: string, to: string}}, where - * 'from' (inclusive) and 'to' (exclusive) are the group bounds under the - * respective date format - * @param {string} [params.viewType] the type of the view, e.g. 'list' or 'form' - * @returns {Object} the resource created - */ - _makeDataPoint: function (params) { - var type = params.type || ('domain' in params && 'list') || 'record'; - var res_id, value; - var res_ids = params.res_ids || []; - var data = params.data || (type === 'record' ? {} : []); - var context = params.context; - var fields = Object.assign({ - display_name: {type: 'char'}, - id: {type: 'integer'}, - }, params.fields); - - const groupedBy = params.groupedBy || []; - let isM2MGrouped = false; - if (type === 'record') { - res_id = params.res_id || (params.data && params.data.id); - if (res_id) { - data.id = res_id; - } else { - res_id = uniqueId('virtual_'); - } - // it doesn't make sense for a record datapoint to have those keys - // besides, it will mess up x2m and actions down the line - context = omit(context, 'orderedBy', 'group_by'); - } else { - var isValueArray = params.value instanceof Array; - res_id = isValueArray ? params.value[0] : undefined; - value = isValueArray ? params.value[1] : params.value; - isM2MGrouped = groupedBy.some((group) => { - const [fieldName] = group.split(':'); - return fields[fieldName].type === "many2many"; - }); - } - - var dataPoint = { - _cache: type === 'list' ? {} : undefined, - _changes: null, - _domains: {}, - _rawChanges: {}, - aggregateValues: params.aggregateValues || {}, - context: context, - count: params.count || res_ids.length, - data: data, - domain: params.domain || [], - fields: fields, - fieldsInfo: params.fieldsInfo, - groupedBy, - groupsCount: 0, - groupsLimit: type === 'list' && params.groupsLimit || null, - groupsOffset: 0, - id: `${params.modelName}_${++this.__id}`, - isM2MGrouped, - isOpen: params.isOpen, - limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER), - loadMoreOffset: 0, - model: params.modelName, - offset: params.offset || (type === 'record' ? res_ids.indexOf(res_id) : 0), - openGroupByDefault: params.openGroupByDefault, - orderedBy: params.orderedBy || [], - orderedResIDs: params.orderedResIDs, - parentID: params.parentID, - rawContext: params.rawContext, - ref: params.ref || res_id, - relationField: params.relationField, - res_id: res_id, - res_ids: res_ids, - specialData: {}, - _specialDataCache: {}, - static: params.static || false, - type: type, // 'record' | 'list' - value: value, - range: params.range, - viewType: params.viewType, - }; - - // _editionViewType is a dict whose keys are field names and which is populated when a field - // is edited with the viewType as value. This is useful for one2manys to determine whether - // or not a field is readonly (using the readonly modifiers of the view in which the field - // has been edited) - dataPoint._editionViewType = {}; - - dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint); - dataPoint.getContext = this._getContext.bind(this, dataPoint); - dataPoint.getDomain = this._getDomain.bind(this, dataPoint); - dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint); - dataPoint.isDirty = this.isDirty.bind(this, dataPoint.id); - dataPoint.isNew = this.isNew.bind(this, dataPoint.id); - - this.localData[dataPoint.id] = dataPoint; - - return dataPoint; - }, - /** - * When one needs to create a record from scratch, a not so simple process - * needs to be done: - * - call the /default_get route to get default values - * - fetch all relational data - * - apply all onchanges if necessary - * - fetch all relational data - * - * This method tries to optimize the process as much as possible. Also, - * it is quite horrible and should be refactored at some point. - * - * @private - * @param {any} params - * @param {string} modelName model name - * @param {boolean} [params.allowWarning=false] if true, the default record - * operation can complete, even if a warning is raised - * @param {Object} params.context the context for the new record - * @param {Object} params.fieldsInfo contains the fieldInfo of each view, - * for each field - * @param {Object} params.fields contains the description of each field - * @param {Object} params.context the context for the new record - * @param {string} params.viewType the key in fieldsInfo of the fields to load - * @returns {Promise} resolves to the id for the created resource - */ - async _makeDefaultRecord(modelName, params) { - var targetView = params.viewType; - var fields = params.fields; - var fieldsInfo = params.fieldsInfo; - var fieldNames = Object.keys(fieldsInfo[targetView]); - - // Fields that are present in the originating view, that need to be initialized - // Hence preventing their value to crash when getting back to the originating view - var parentRecord = params.parentID && this.localData[params.parentID].type === 'list' ? this.localData[params.parentID] : null; - - if (parentRecord && parentRecord.viewType in parentRecord.fieldsInfo) { - var originView = parentRecord.viewType; - fieldNames = fieldNames.concat(Object.keys(parentRecord.fieldsInfo[originView])); - fieldsInfo[targetView] = Object.assign({}, parentRecord.fieldsInfo[originView], fieldsInfo[targetView]); - fields = Object.assign({}, parentRecord.fields, fields); - } - - var record = this._makeDataPoint({ - modelName: modelName, - fields: fields, - fieldsInfo: fieldsInfo, - context: params.context, - parentID: params.parentID, - res_ids: params.res_ids, - viewType: targetView, - }); - - await this.generateDefaultValues(record.id, {}, { fieldNames }); - try { - await this._performOnChange(record, [], { firstOnChange: true }); - } finally { - if (record._warning && params.allowWarning) { - delete record._warning; - } - } - if (record._warning) { - return Promise.reject(); - } - - // We want to overwrite the default value of the handle field (if any), - // in order for new lines to be added at the correct position. - // -> This is a rare case where the defaul_get from the server - // will be ignored by the view for a certain field (usually "sequence"). - var overrideDefaultFields = this._computeOverrideDefaultFields(params.parentID, params.position); - if (overrideDefaultFields.field) { - record._changes[overrideDefaultFields.field] = overrideDefaultFields.value; - } - - // fetch additional data (special data and many2one namegets for "always_reload" fields) - await this._postprocess(record); - // save initial changes, so they can be restored later, if we need to discard - this.save(record.id, { savePoint: true }); - return record.id; - }, - /** - * parse the server values to javascript framwork - * - * @param {[string]} fieldNames - * @param {Object} element the dataPoint used as parent for the created - * dataPoints - * @param {Object} data the server data to parse - */ - _parseServerData: function (fieldNames, element, data) { - var self = this; - fieldNames.forEach((fieldName) => { - var field = element.fields[fieldName]; - var val = data[fieldName]; - if (field.type === 'many2one') { - // process many2one: split [id, nameget] and create corresponding record - if (val !== false) { - // the many2one value is of the form [id, display_name] - var r = self._makeDataPoint({ - modelName: field.relation, - fields: { - display_name: {type: 'char'}, - id: {type: 'integer'}, - }, - data: { - display_name: val[1], - id: val[0], - }, - parentID: element.id, - }); - data[fieldName] = r.id; - } else { - // no value for the many2one - data[fieldName] = false; - } - } else { - data[fieldName] = self._parseServerValue(field, val); - } - }); - }, - /** - * This method is quite important: it is supposed to perform the /onchange - * rpc and apply the result. - * - * The changes that triggered the onchange are assumed to have already been - * applied to the record. - * - * @param {Object} record - * @param {string[]} fields changed fields (empty list in the case of first - * onchange) - * @param {Object} [options={}] - * @param {string} [options.viewType] current viewType. If not set, we will assume - * main viewType from the record - * @param {boolean} [options.firstOnChange=false] set to true if this is the - * first onchange - * @returns {Promise} - */ - async _performOnChange(record, fields, options = {}) { - const firstOnChange = options.firstOnChange; - let { hasOnchange, onchangeSpec } = this._buildOnchangeSpecs(record, options.viewType); - if (!firstOnChange && !hasOnchange) { - return; - } - var idList = record.data.id ? [record.data.id] : []; - const ctxOptions = { - full: true, - }; - if (fields.length === 1) { - fields = fields[0]; - // if only one field changed, add its context to the RPC context - ctxOptions.fieldName = fields; - } - var context = this._getContext(record, ctxOptions); - var currentData = this._generateOnChangeData(record, { - changesOnly: false, - firstOnChange, - }); - - const result = await this._rpc({ - model: record.model, - method: 'onchange', - args: [idList, currentData, fields, onchangeSpec], - context: context, - }); - if (!record._changes) { - // if the _changes key does not exist anymore, it means that - // it was removed by discarding the changes after the rpc - // to onchange. So, in that case, the proper response is to - // ignore the onchange. - return; - } - if (result.warning) { - this.trigger_up('warning', result.warning); - record._warning = true; - } - if (result.domain) { - record._domains = Object.assign(record._domains, result.domain); - } - await this._applyOnChange(result.value, record, { firstOnChange }); - return result; - }, - /** - * This function accumulates RPC requests done in the same call stack, and - * performs them in the next micro task tick so that similar requests can be - * batched in a single RPC. - * - * For now, only 'read' calls are supported. - * - * @private - * @param {Object} params - * @returns {Promise} - */ - _performRPC: function (params) { - var self = this; - - // save the RPC request - var request = Object.assign({}, params); - var prom = new Promise(function (resolve, reject) { - request.resolve = resolve; - request.reject = reject; - }); - this.batchedRPCsRequests.push(request); - - // empty the pool of RPC requests in the next micro tick - Promise.resolve().then(function () { - if (!self.batchedRPCsRequests.length) { - // pool has already been processed - return; - } - - // reset pool of RPC requests - var batchedRPCsRequests = self.batchedRPCsRequests; - self.batchedRPCsRequests = []; - - // batch similar requests - var batches = {}; - var key; - for (var i = 0; i < batchedRPCsRequests.length; i++) { - var request = batchedRPCsRequests[i]; - key = request.model + ',' + JSON.stringify(request.context); - if (!batches[key]) { - batches[key] = Object.assign({}, request, {requests: [request]}); - } else { - batches[key].ids = unique(batches[key].ids.concat(request.ids)); - batches[key].fieldNames = unique(batches[key].fieldNames.concat(request.fieldNames)); - batches[key].requests.push(request); - } - } - - // perform batched RPCs - function onSuccess(batch, results) { - for (var i = 0; i < batch.requests.length; i++) { - var request = batch.requests[i]; - var fieldNames = request.fieldNames.concat(['id']); - var filteredResults = results.filter(function (record) { - return request.ids.indexOf(record.id) >= 0; - }).map(function (record) { - return pick(record, ...fieldNames); - }); - request.resolve(filteredResults); - } - } - function onFailure(batch, error) { - for (var i = 0; i < batch.requests.length; i++) { - var request = batch.requests[i]; - request.reject(error); - } - } - for (key in batches) { - var batch = batches[key]; - self._rpc({ - model: batch.model, - method: 'read', - args: [batch.ids, batch.fieldNames], - context: batch.context, - }).then(onSuccess.bind(null, batch)).guardedCatch(onFailure.bind(null, batch)); - } - }); - - return prom; - }, - /** - * Once a record is created and some data has been fetched, we need to do - * quite a lot of computations to determine what needs to be fetched. This - * method is doing that. - * - * @see _fetchRecord @see _makeDefaultRecord - * - * @param {Object} record - * @param {Object} [options] - * @param {Object} [options.viewType] current viewType. If not set, we will - * assume main viewType from the record - * @returns {Promise} resolves to the finished resource - */ - _postprocess: function (record, options) { - var self = this; - var viewType = options && options.viewType || record.viewType; - var defs = []; - - record.getFieldNames(options).forEach((name) => { - var field = record.fields[name]; - var fieldInfo = record.fieldsInfo[viewType][name] || {}; - var options = fieldInfo.options || {}; - if (options.always_reload) { - if (record.fields[name].type === 'many2one') { - const _changes = record._changes || {}; - const relRecordId = _changes[name] || record.data[name]; - if (!relRecordId) { - return; // field is unset, no need to do the display_name read - } - var relRecord = self.localData[relRecordId]; - defs.push(self._rpc({ - model: field.relation, - method: 'read', - args: [relRecord.data.id, ['display_name']], - context: self._getContext(record, {fieldName: name, viewType: viewType, withoutRecordData: true}), - }) - .then(function (result) { - relRecord.data.display_name = result[0]['display_name']; - })); - } - } - }); - - defs.push(this._fetchSpecialData(record, options)); - - return Promise.all(defs).then(function () { - record._specialDataSavepoint = Object.assign({}, record.specialData); - return record; - }); - }, - /** - * Process x2many commands in a default record by transforming the list of - * commands in operations (pushed in _changes) and fetch the related - * records fields. - * - * Note that this method can be called recursively. - * - * @todo in master: factorize this code with the postprocessing of x2many in - * _applyOnChange - * - * @private - * @param {Object} record - * @param {string} fieldName - * @param {Array[Array]} commands - * @param {Object} [options] - * @param {string} [options.viewType] current viewType. If not set, we will - * assume main viewType from the record - * @returns {Promise} - */ - _processX2ManyCommands: function (record, fieldName, commands, options) { - var self = this; - options = options || {}; - var defs = []; - var field = record.fields[fieldName]; - var fieldInfo = record.fieldsInfo[options.viewType || record.viewType][fieldName] || {}; - var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; - var fields = view ? view.fields : fieldInfo.relatedFields; - var viewType = view ? view.type : fieldInfo.viewType; - - // remove default_* & *_view_ref keys from record context - const recordContext = { ...record.context }; - for (const key in recordContext) { - if (key.startsWith("default_") || key.endsWith("_view_ref")) { - delete recordContext[key]; - } - } - - var x2manyList = self._makeDataPoint({ - context: recordContext, - fieldsInfo: fieldsInfo, - fields: fields, - limit: fieldInfo.limit, - modelName: field.relation, - parentID: record.id, - rawContext: fieldInfo && fieldInfo.context, - relationField: field.relation_field, - res_ids: [], - static: true, - type: 'list', - viewType: viewType, - }); - record._changes[fieldName] = x2manyList.id; - x2manyList._changes = []; - var many2ones = {}; - var r; - commands = commands || []; // handle false value - var isCommandList = commands.length && Array.isArray(commands[0]); - if (!isCommandList) { - commands = [[6, false, commands]]; - } - commands.forEach((value) => { - // value is a command - if (value[0] === 0) { - // CREATE - r = self._makeDataPoint({ - modelName: x2manyList.model, - context: x2manyList.context, - fieldsInfo: fieldsInfo, - fields: fields, - parentID: x2manyList.id, - viewType: viewType, - }); - r._noAbandon = true; - x2manyList._changes.push({operation: 'ADD', id: r.id}); - x2manyList._cache[r.res_id] = r.id; - - // this is necessary so the fields are initialized - r.getFieldNames().forEach((fieldName) => { - r.data[fieldName] = null; - }); - - r._changes = Object.assign(r.data, value[2]); - for (var fieldName in r._changes) { - if (!r._changes[fieldName]) { - continue; - } - var isFieldInView = fieldName in r.fields; - if (isFieldInView) { - var field = r.fields[fieldName]; - var fieldType = field.type; - var rec; - if (fieldType === 'many2one') { - rec = self._makeDataPoint({ - context: r.context, - modelName: field.relation, - data: {id: r._changes[fieldName]}, - parentID: r.id, - }); - r._changes[fieldName] = rec.id; - many2ones[fieldName] = true; - } else if (fieldType === 'reference') { - var reference = r._changes[fieldName].split(','); - rec = self._makeDataPoint({ - context: r.context, - modelName: reference[0], - data: {id: parseInt(reference[1])}, - parentID: r.id, - }); - r._changes[fieldName] = rec.id; - many2ones[fieldName] = true; - } else if (['one2many', 'many2many'].includes(fieldType)) { - var x2mCommands = value[2][fieldName]; - defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands)); - } else { - r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]); - } - } - } - } - if (value[0] === 6) { - // REPLACE_WITH - value[2].forEach((res_id) => { - x2manyList._changes.push({operation: 'ADD', resID: res_id}); - }); - var def = self._readUngroupedList(x2manyList).then(function () { - return Promise.all([ - self._fetchX2ManysBatched(x2manyList), - self._fetchReferencesBatched(x2manyList) - ]); - }); - defs.push(def); - } - }); - - // fetch many2ones display_name - Object.keys(many2ones).forEach((name) => { - defs.push(self._fetchNameGets(x2manyList, name)); - }); - - return Promise.all(defs); - }, - /** - * Reads data from server for all missing fields. - * - * @private - * @param {Object} list a valid resource object - * @param {interger[]} resIDs - * @param {string[]} fieldNames to check and read if missing - * @returns {Promise} - */ - _readMissingFields: function (list, resIDs, fieldNames, withoutRecordData) { - var self = this; - - var missingIDs = []; - for (var i = 0, len = resIDs.length; i < len; i++) { - var resId = resIDs[i]; - var dataPointID = list._cache[resId]; - if (!dataPointID) { - missingIDs.push(resId); - continue; - } - var record = self.localData[dataPointID]; - var data = Object.assign({}, record.data, record._changes); - if (fieldNames.filter((x) => Object.keys(data).indexOf(x) < 0).length) { - missingIDs.push(resId); - } - } - - var def; - if (missingIDs.length && fieldNames.length) { - def = self._performRPC({ - context: list.getContext({ withoutRecordData: !!withoutRecordData }), - fieldNames: fieldNames, - ids: missingIDs, - method: 'read', - model: list.model, - }); - } else { - def = Promise.resolve(missingIDs.map((id) => { - return {id:id}; - })); - } - return def.then(function (records) { - resIDs.forEach((id) => { - var dataPoint; - var data = records.find((record) => record.id === id); - if (id in list._cache) { - dataPoint = self.localData[list._cache[id]]; - if (data) { - self._parseServerData(fieldNames, dataPoint, data); - Object.assign(dataPoint.data, data); - } - } else { - dataPoint = self._makeDataPoint({ - context: list.getContext(), - data: data, - fieldsInfo: list.fieldsInfo, - fields: list.fields, - modelName: list.model, - parentID: list.id, - viewType: list.viewType, - }); - self._parseServerData(fieldNames, dataPoint, dataPoint.data); - - // add many2one records - list._cache[id] = dataPoint.id; - } - // set the dataPoint id in potential 'ADD' operation adding the current record - Object.values(list._changes || {}).forEach((change) => { - if (change.operation === 'ADD' && !change.id && change.resID === id) { - change.id = dataPoint.id; - } - }); - }); - return list; - }); - }, - /** - * For a grouped list resource, this method fetches all group data by - * performing a /read_group. It also tries to read open subgroups if they - * were open before. - * - * @param {Object} list valid resource object - * @param {Object} [options] @see _load - * @returns {Promise} resolves to the fetched group object - */ - _readGroup: function (list, options) { - var self = this; - options = options || {}; - var groupByField = list.groupedBy[0]; - var rawGroupBy = groupByField.split(':')[0]; - var fields = unique(list.getFieldNames().concat(rawGroupBy)); - var orderedBy = list.orderedBy?.filter(order => { - return order.name === rawGroupBy || list.fields[order.name].group_operator !== undefined; - }) || []; - var openGroupsLimit = list.groupsLimit || self.OPEN_GROUP_LIMIT; - var expand = list.openGroupByDefault && options.fetchRecordsWithGroups; - return this._rpc({ - model: list.model, - method: 'web_read_group', - fields: fields, - domain: list.domain, - context: list.context, - groupBy: list.groupedBy, - limit: list.groupsLimit, - offset: list.groupsOffset, - orderBy: orderedBy, - lazy: true, - expand: expand, - expand_limit: expand ? list.limit : null, - expand_orderby: expand ? list.orderedBy : null, - }) - .then(function (result) { - var groups = result.groups; - list.groupsCount = result.length; - var previousGroups = list.data.map((groupID) => self.localData[groupID]); - list.data = []; - list.count = 0; - var defs = []; - var openGroupCount = 0; - - groups.forEach((group) => { - var aggregateValues = {}; - for (const [key, value] of Object.entries(group)) { - if (fields.includes(key) && key !== groupByField && - AGGREGATABLE_TYPES.includes(list.fields[key].type)) { - aggregateValues[key] = value; - } - } - // When a view is grouped, we need to display the name of each group in - // the 'title'. - var value = group[groupByField]; - if (list.fields[rawGroupBy].type === "selection") { - var choice = list.fields[rawGroupBy].selection.find(c => c[0] === value); - value = choice ? choice[1] : false; - } - var newGroup = self._makeDataPoint({ - modelName: list.model, - count: group[rawGroupBy + '_count'], - domain: group.__domain, - context: list.context, - fields: list.fields, - fieldsInfo: list.fieldsInfo, - value: value, - range: group.__range, - aggregateValues: aggregateValues, - groupedBy: list.groupedBy.slice(1), - orderedBy: list.orderedBy, - orderedResIDs: list.orderedResIDs, - limit: list.limit, - openGroupByDefault: list.openGroupByDefault, - parentID: list.id, - type: 'list', - viewType: list.viewType, - }); - var oldGroup = previousGroups.find(g => g.res_id === newGroup.res_id && g.value === newGroup.value); - if (oldGroup) { - delete self.localData[newGroup.id]; - // restore the internal state of the group - const oldPropsToKeep = self._getGroupedListPropsToKeep(oldGroup, { - onlyGroup: options.onlyGroups, - hasSubgroups: newGroup.groupedBy.length - }); - Object.assign(newGroup, oldPropsToKeep); - // set the limit such that all previously loaded records - // (e.g. if we are coming back to the kanban view from a - // form view) are reloaded - newGroup.limit = oldGroup.limit + oldGroup.loadMoreOffset; - self.localData[newGroup.id] = newGroup; - } else if (!newGroup.openGroupByDefault || openGroupCount >= openGroupsLimit) { - newGroup.isOpen = false; - } else if ('__fold' in group) { - newGroup.isOpen = !group.__fold; - } else { - // open the group iff it is a first level group - newGroup.isOpen = !self.localData[newGroup.parentID].parentID; - } - list.data.push(newGroup.id); - list.count += newGroup.count; - if (newGroup.isOpen && newGroup.count > 0) { - openGroupCount++; - if (group.__data) { - // bypass the search_read when the group's records have been obtained - // by the call to 'web_read_group' (see @_searchReadUngroupedList) - newGroup.__data = group.__data; - } - options = Object.assign(options, {enableRelationalFetch: false}); - defs.push(self._load(newGroup, options)); - } - }); - if (options.keepEmptyGroups) { - // Find the groups that were available in a previous - // readGroup but are not there anymore. - // Note that these groups are put after existing groups so - // the order is not conserved. A sort *might* be useful. - var emptyGroupsIDs = previousGroups.map((group) => group.id).filter((x) => list.data.indexOf(x)<0); - emptyGroupsIDs.forEach((groupID) => { - list.data.push(groupID); - var emptyGroup = self.localData[groupID]; - // this attribute hasn't been updated in the previous - // loop for empty groups - emptyGroup.aggregateValues = {}; - }); - } - - return Promise.all(defs).then(function (groups) { - if (!options.onlyGroups) { - // generate the res_ids of the main list, being the concatenation - // of the fetched res_ids in each group - list.res_ids = groups.map((group) => { - return group ? group.res_ids : []; - }).flat(); - } - return list; - }).then(function () { - return Promise.all([ - self._fetchX2ManysSingleBatch(list), - self._fetchReferencesSingleBatch(list) - ]).then(function () { - return list; - }); - }); - }); - }, - /** - * For 'static' list, such as one2manys in a form view, we can do a /read - * instead of a /search_read. - * - * @param {Object} list a valid resource object - * @returns {Promise} resolves to the fetched list object - */ - _readUngroupedList: function (list, options) { - options = options || {}; - var self = this; - var def = Promise.resolve(); - - // generate the current count and res_ids list by applying the changes - list = this._applyX2ManyOperations(list); - - // for multi-pages list datapoints, we might need to read the - // order field first to apply the order on all pages - if (list.res_ids.length > list.limit && list.orderedBy.length) { - if (!list.orderedResIDs) { - var fieldNames = list.orderedBy.map((list) => list.name); - def = this._readMissingFields(list, list.res_ids.filter(id => Number.isFinite(id)), fieldNames, options.withoutRecordData); - } - def.then(function () { - self._sortList(list); - }); - } - return def.then(function () { - var resIDs = []; - var currentResIDs = list.res_ids; - // if new records have been added to the list, their virtual ids have - // been pushed at the end of res_ids (or at the beginning, depending - // on the editable property), ignoring completely the current page - // where the records have actually been created ; for that reason, - // we use orderedResIDs which is a freezed order with the virtual ids - // at the correct position where they were actually inserted ; however, - // when we use orderedResIDs, we must filter out ids that are not in - // res_ids, which correspond to records that have been removed from - // the relation (this information being taken into account in res_ids - // but not in orderedResIDs) - if (list.orderedResIDs) { - currentResIDs = list.orderedResIDs.filter(function (resID) { - return list.res_ids.indexOf(resID) >= 0; - }); - } - var currentCount = currentResIDs.length; - var upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount; - var fieldNames = list.getFieldNames(); - for (var i = list.offset; i < upperBound; i++) { - var resId = currentResIDs[i]; - if (Number.isFinite(resId)) { - resIDs.push(resId); - } - } - return self._readMissingFields(list, resIDs, fieldNames, options.withoutRecordData).then(function () { - if (list.res_ids.length <= list.limit) { - self._sortList(list); - } else { - // sortList has already been applied after first the read - self._setDataInRange(list); - } - return list; - }); - }); - }, - /** - * Reload all data for a given resource - * - * @private - * @param {string} id local id for a resource - * @param {Object} [options] - * @param {boolean} [options.keepChanges=false] if true, doesn't discard the - * changes on the record before reloading it - * @returns {Promise} resolves to the id of the resource - */ - _reload: function (id, options) { - options = options || {}; - var element = this.localData[id]; - - if (element.type === 'record') { - if (!options.currentId && (('currentId' in options) || this.isNew(id))) { - var params = { - context: element.context, - fieldsInfo: element.fieldsInfo, - fields: element.fields, - viewType: element.viewType, - allowWarning: true, - }; - return this._makeDefaultRecord(element.model, params); - } - if (!options.keepChanges) { - this.discardChanges(id, {rollback: false}); - } - } else if (element._changes) { - delete element.tempLimitIncrement; - element._changes.forEach((change) => { - delete change.isNew; - }); - } - - if (options.context !== undefined) { - element.context = options.context; - } - if (options.orderedBy !== undefined) { - element.orderedBy = (options.orderedBy.length && options.orderedBy) || element.orderedBy; - } - if (options.domain !== undefined) { - element.domain = options.domain; - } - if (options.groupBy !== undefined) { - element.groupedBy = options.groupBy; - element.isM2MGrouped = element.groupedBy.some((group) => { - const [fieldName] = group.split(':'); - return element.fields[fieldName].type === "many2many"; - }); - } - if (options.limit !== undefined) { - element.limit = options.limit; - } - if (options.offset !== undefined) { - this._setOffset(element.id, options.offset); - } - if (options.groupsLimit !== undefined) { - element.groupsLimit = options.groupsLimit; - } - if (options.groupsOffset !== undefined) { - element.groupsOffset = options.groupsOffset; - } - if (options.loadMoreOffset !== undefined) { - element.loadMoreOffset = options.loadMoreOffset; - } else { - // reset if not specified - element.loadMoreOffset = 0; - } - if (options.currentId !== undefined) { - element.res_id = options.currentId; - } - if (options.ids !== undefined) { - element.res_ids = options.ids; - element.count = element.res_ids.length; - } - if (element.type === 'record') { - element.offset = element.res_ids.indexOf(element.res_id); - } - var loadOptions = pick(options, 'fieldNames', 'viewType'); - return this._load(element, loadOptions).then(function (result) { - return result.id; - }); - }, - /** - * Override to handle the case where we want sample data, and we are in a - * grouped kanban or list view with real groups, but all groups are empty. - * In this case, we use the result of the web_read_group rpc to tweak the - * data in the SampleServer instance of the sampleModel (so that calls to - * that server will return the same groups). - * - * @override - */ - async _rpc(params) { - const result = await this._super(...arguments); - if (this.sampleModel && params.method === 'web_read_group' && result.length) { - const sampleServer = this.sampleModel.sampleServer; - sampleServer.setExistingGroups(result.groups); - } - return result; - }, - /** - * Allows to save a value in the specialData cache associated to a given - * record and fieldName. If the value in the cache was already the given - * one, nothing is done and the method indicates it by returning false - * instead of true. - * - * @private - * @param {Object} record - an element from the localData - * @param {string} fieldName - the name of the field - * @param {*} value - the cache value to save - * @returns {boolean} false if the value was already the given one - */ - _saveSpecialDataCache: function (record, fieldName, value) { - if (JSON.stringify(record._specialDataCache[fieldName]) === JSON.stringify(value)) { - return false; - } - record._specialDataCache[fieldName] = value; - return true; - }, - /** - * Do a /search_read to get data for a list resource. This does a - * /search_read because the data may not be static (for ex, a list view). - * - * @param {Object} list - * @returns {Promise} - */ - _searchReadUngroupedList: function (list) { - var self = this; - var fieldNames = list.getFieldNames(); - var prom; - if (list.__data) { - // the data have already been fetched (alongside the groups by the - // call to 'web_read_group'), so we can bypass the search_read - // But the web_read_group returns the rawGroupBy field's value, which may not be present - // in the view. So we filter it out. - const fieldNameSet = new Set(fieldNames); - fieldNameSet.add("id"); // don't filter out the id - list.__data.records.forEach(record => - Object.keys(record) - .filter(fieldName => !fieldNameSet.has(fieldName)) - .forEach(fieldName => delete record[fieldName])); - prom = Promise.resolve(list.__data); - } else { - prom = this._rpc({ - route: '/web/dataset/search_read', - model: list.model, - fields: fieldNames, - context: Object.assign({}, list.getContext(), {bin_size: true}), - domain: this._getUngroupedListDomain(list), - limit: list.limit, - offset: list.loadMoreOffset + list.offset, - orderBy: list.orderedBy, - }); - } - return prom.then(function (result) { - delete list.__data; - list.count = result.length; - var ids = result.records.map((r) => r.id); - var data = result.records.map((record) => { - var dataPoint = self._makeDataPoint({ - context: list.context, - data: record, - fields: list.fields, - fieldsInfo: list.fieldsInfo, - modelName: list.model, - parentID: list.id, - viewType: list.viewType, - }); - - // add many2one records - self._parseServerData(fieldNames, dataPoint, dataPoint.data); - return dataPoint.id; - }); - if (list.loadMoreOffset) { - list.data = list.data.concat(data); - list.res_ids = list.res_ids.concat(ids); - } else { - list.data = data; - list.res_ids = ids; - } - self._updateParentResIDs(list); - return list; - }); - }, - /** - * Set data in range, i.e. according to the list offset and limit. - * - * @param {Object} list - */ - _setDataInRange: function (list) { - var idsInRange; - if (list.limit) { - idsInRange = list.res_ids.slice(list.offset, list.offset + list.limit); - } else { - idsInRange = list.res_ids; - } - list.data = []; - idsInRange.forEach((id) => { - if (list._cache[id]) { - list.data.push(list._cache[id]); - } - }); - - // display newly created record in addition to the displayed records - if (list.limit) { - for (var i = list.offset + list.limit; i < list.res_ids.length; i++) { - var id = list.res_ids[i]; - var dataPointID = list._cache[id]; - if (Object.values(list._changes || {}).find((change) => change.isNew === true && change.id === dataPointID)) { - list.data.push(dataPointID); - } else { - break; - } - } - } - }, - /** - * Change the offset of a record. Note that this does not reload the data. - * The offset is used to load a different record in a list of record (for - * example, a form view with a pager. Clicking on next/previous actually - * changes the offset through this method). - * - * @param {string} elementId local id for the resource - * @param {number} offset - */ - _setOffset: function (elementId, offset) { - var element = this.localData[elementId]; - element.offset = offset; - if (element.type === 'record' && element.res_ids.length) { - element.res_id = element.res_ids[offset]; - } - }, - /** - * Do a in-memory sort of a list resource data points. This method assumes - * that the list data has already been fetched, and that the changes that - * need to be sorted have already been applied. Its intended use is for - * static datasets, such as a one2many in a form view. - * - * @param {Object} list list dataPoint on which (some) changes might have - * been applied; it is a copy of an internal dataPoint, not the result of - * get - */ - _sortList: function (list) { - if (!list.static) { - // only sort x2many lists - return; - } - var self = this; - - if (list.orderedResIDs) { - var orderedResIDs = {}; - for (var k = 0; k < list.orderedResIDs.length; k++) { - orderedResIDs[list.orderedResIDs[k]] = k; - } - utils.stableSort(list.res_ids, function compareResIdIndexes (resId1, resId2) { - if (!(resId1 in orderedResIDs) && !(resId2 in orderedResIDs)) { - return 0; - } - if (!(resId1 in orderedResIDs)) { - return Infinity; - } - if (!(resId2 in orderedResIDs)) { - return -Infinity; - } - return orderedResIDs[resId1] - orderedResIDs[resId2]; - }); - } else if (list.orderedBy.length) { - // sort records according to ordered_by[0] - var compareRecords = function (resId1, resId2, level) { - if(!level) { - level = 0; - } - if(list.orderedBy.length < level + 1) { - return 0; - } - var order = list.orderedBy[level]; - var record1ID = list._cache[resId1]; - var record2ID = list._cache[resId2]; - if (!record1ID && !record2ID) { - return 0; - } - if (!record1ID) { - return Infinity; - } - if (!record2ID) { - return -Infinity; - } - var r1 = self.localData[record1ID]; - var r2 = self.localData[record2ID]; - var data1 = Object.assign({}, r1.data, r1._changes); - var data2 = Object.assign({}, r2.data, r2._changes); - - // Default value to sort against: the value of the field - var orderData1 = data1[order.name]; - var orderData2 = data2[order.name]; - - // If the field is a relation, sort on the display_name of those records - if (list.fields[order.name].type === 'many2one') { - orderData1 = orderData1 ? self.localData[orderData1].data.display_name : ""; - orderData2 = orderData2 ? self.localData[orderData2].data.display_name : ""; - } - if (orderData1 < orderData2) { - return order.asc ? -1 : 1; - } - if (orderData1 > orderData2) { - return order.asc ? 1 : -1; - } - return compareRecords(resId1, resId2, level + 1); - }; - utils.stableSort(list.res_ids, compareRecords); - } - this._setDataInRange(list); - }, - /** - * Updates the res_ids of the parent of a given element of type list. - * - * After some operations (e.g. loading more records, folding/unfolding a - * group), the res_ids list of an element may be updated. When this happens, - * the res_ids of its ancestors need to be updated as well. This is the - * purpose of this function. - * - * @param {Object} element - */ - _updateParentResIDs: function (element) { - var self = this; - if (element.parentID) { - var parent = this.localData[element.parentID]; - parent.res_ids = parent.data.map((dataPointID) => self.localData[dataPointID].res_ids).flat(); - this._updateParentResIDs(parent); - } - }, - /** - * Helper method to create datapoints and assign them values, then link - * those datapoints into records' data. - * - * @param {Object[]} records a list of record where datapoints will be - * assigned, it assumes _applyX2ManyOperations and _sort have been - * already called on this list - * @param {string} fieldName concerned field in records - * @param {Object[]} values typically a list of values got from a rpc - */ - _updateRecordsData: function (records, fieldName, values) { - if (!records.length || !values) { - return; - } - var self = this; - var field = records[0].fields[fieldName]; - var fieldInfo = records[0].fieldsInfo[records[0].viewType][fieldName]; - var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; - var fields = view ? view.fields : fieldInfo.relatedFields; - var viewType = view ? view.type : fieldInfo.viewType; - var id2Values = new Map(values.map((value) => [value.id, value])) - - records.forEach((record) => { - var x2mList = self.localData[record.data[fieldName]]; - x2mList.data = []; - x2mList.res_ids.forEach((res_id) => { - var dataPoint = self._makeDataPoint({ - modelName: field.relation, - data: id2Values.get(res_id), - fields: fields, - fieldsInfo: fieldsInfo, - parentID: x2mList.id, - viewType: viewType, - }); - x2mList.data.push(dataPoint.id); - x2mList._cache[res_id] = dataPoint.id; - }); - }); - }, - /** - * Used to apply a updateFn to other records with same res_id, that are - * under the same topmost list groupedby many2many. - * - * When a list datapoint is grouped by many2many, same res_id record could - * be in multiple groups, so use this method to apply the same callback to - * each one of them. - * - * @protected - * @param {string} recordID local resource - * @param {(recordID: string)=>void} updateFn called for each record datapoint - * representing the same res_id record - */ - _updateDuplicateRecords(recordID, updateFn) { - const { res_id } = this.localData[recordID]; - // Get the topmost groupedby m2m list - const getTopmostID = () => { - let element = this.localData[recordID]; - while (element.parentID && this.localData[element.parentID].type === "list") { - element = this.localData[element.parentID]; - } - return element.type === "list" && element.isM2MGrouped ? element.id : false; - }; - const topmostID = getTopmostID(); - if (!topmostID) { - return; - } - const topmostList = this.get(topmostID); - utils.traverse_records(topmostList, (r) => { - if (r.res_id === res_id && r.id !== recordID) { - updateFn(r.id); - } - }); - }, - /** - * Helper method. Recursively traverses the data, starting from the element - * record (or list), then following all relations. This is useful when one - * want to determine a property for the current record. - * - * For example, isDirty need to check all relations to find out if something - * has been modified, or not. - * - * Note that this method follows all the changes, so if a record has - * relational sub data, it will visit the new sub records and not the old - * ones. - * - * @param {Object} element a valid local resource - * @param {callback} fn a function to be called on each visited element - */ - _visitChildren: function (element, fn) { - var self = this; - fn(element); - if (element.type === 'record') { - for (var fieldName in element.data) { - var field = element.fields[fieldName]; - if (!field) { - continue; - } - if (['one2many', 'many2one', 'many2many'].includes(field.type)) { - var hasChange = element._changes && fieldName in element._changes; - var value = hasChange ? element._changes[fieldName] : element.data[fieldName]; - var relationalElement = this.localData[value]; - // relationalElement could be empty in the case of a many2one - if (relationalElement) { - self._visitChildren(relationalElement, fn); - } - } - } - } - if (element.type === 'list') { - element = this._applyX2ManyOperations(element); - element.data.forEach((elemId) => { - var elem = self.localData[elemId]; - self._visitChildren(elem, fn); - }); - } - }, -}); - -export default BasicModel; diff --git a/addons/web/static/src/legacy/js/views/field_manager_mixin.js b/addons/web/static/src/legacy/js/views/field_manager_mixin.js deleted file mode 100644 index f4113de6d38..00000000000 --- a/addons/web/static/src/legacy/js/views/field_manager_mixin.js +++ /dev/null @@ -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; diff --git a/addons/web/static/src/legacy/js/views/standalone_field_manager_mixin.js b/addons/web/static/src/legacy/js/views/standalone_field_manager_mixin.js deleted file mode 100644 index cef759851c4..00000000000 --- a/addons/web/static/src/legacy/js/views/standalone_field_manager_mixin.js +++ /dev/null @@ -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; diff --git a/addons/web/static/src/legacy/js/views/view_utils.js b/addons/web/static/src/legacy/js/views/view_utils.js deleted file mode 100644 index dcc56412de2..00000000000 --- a/addons/web/static/src/legacy/js/views/view_utils.js +++ /dev/null @@ -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; diff --git a/addons/web/static/src/views/model.js b/addons/web/static/src/model/model.js similarity index 69% rename from addons/web/static/src/views/model.js rename to addons/web/static/src/model/model.js index 40d95d2c25d..c1945e98423 100644 --- a/addons/web/static/src/views/model.js +++ b/addons/web/static/src/model/model.js @@ -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} */ 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} + */ +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 }; }, }); diff --git a/addons/web/static/src/model/relational_model/datapoint.js b/addons/web/static/src/model/relational_model/datapoint.js new file mode 100644 index 00000000000..9c845695f6f --- /dev/null +++ b/addons/web/static/src/model/relational_model/datapoint.js @@ -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); + } +} diff --git a/addons/web/static/src/model/relational_model/dynamic_group_list.js b/addons/web/static/src/model/relational_model/dynamic_group_list.js new file mode 100644 index 00000000000..34ba5cda159 --- /dev/null +++ b/addons/web/static/src/model/relational_model/dynamic_group_list.js @@ -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, + }); + } +} diff --git a/addons/web/static/src/model/relational_model/dynamic_list.js b/addons/web/static/src/model/relational_model/dynamic_list.js new file mode 100644 index 00000000000..e8e2815a8c9 --- /dev/null +++ b/addons/web/static/src/model/relational_model/dynamic_list.js @@ -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} + */ + 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(); + } + } +} diff --git a/addons/web/static/src/model/relational_model/dynamic_record_list.js b/addons/web/static/src/model/relational_model/dynamic_record_list.js new file mode 100644 index 00000000000..a23111bdbb0 --- /dev/null +++ b/addons/web/static/src/model/relational_model/dynamic_record_list.js @@ -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} 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} + */ + 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; + } + } +} diff --git a/addons/web/static/src/model/relational_model/group.js b/addons/web/static/src/model/relational_model/group.js new file mode 100644 index 00000000000..b1afe73d5f7 --- /dev/null +++ b/addons/web/static/src/model/relational_model/group.js @@ -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; + } +} diff --git a/addons/web/static/src/model/relational_model/record.js b/addons/web/static/src/model/relational_model/record.js new file mode 100644 index 00000000000..f7ae6685cc9 --- /dev/null +++ b/addons/web/static/src/model/relational_model/record.js @@ -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 `
  • ${escape(this.fields[fieldName].string || fieldName)}
  • `; + }, this); + this._closeInvalidFieldsNotification = this.model.notification.add( + markup(`
      ${items.join("")}
    `), + { + 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()); + } + } +} diff --git a/addons/web/static/src/model/relational_model/relational_model.js b/addons/web/static/src/model/relational_model/relational_model.js new file mode 100644 index 00000000000..9acc0fecaa2 --- /dev/null +++ b/addons/web/static/src/model/relational_model/relational_model.js @@ -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} + */ + 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 + */ + _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 + */ + 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} 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} + */ + 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, + } + ); + } +} diff --git a/addons/web/static/src/model/relational_model/static_list.js b/addons/web/static/src/model/relational_model/static_list.js new file mode 100644 index 00000000000..63a01dc7dd5 --- /dev/null +++ b/addons/web/static/src/model/relational_model/static_list.js @@ -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); + } +} diff --git a/addons/web/static/src/model/relational_model/utils.js b/addons/web/static/src/model/relational_model/utils.js new file mode 100644 index 00000000000..26f6a6e832f --- /dev/null +++ b/addons/web/static/src/model/relational_model/utils.js @@ -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); + } + }); +} diff --git a/addons/web/static/src/views/sample_server.js b/addons/web/static/src/model/sample_server.js similarity index 90% rename from addons/web/static/src/views/sample_server.js rename to addons/web/static/src/model/sample_server.js index 847bc37bc64..4f1b0110385 100644 --- a/addons/web/static/src/views/sample_server.js +++ b/addons/web/static/src/model/sample_server.js @@ -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 }; } } } diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js index 3d631511688..70312c53549 100644 --- a/addons/web/static/src/search/search_model.js +++ b/addons/web/static/src/search/search_model.js @@ -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 diff --git a/addons/web/static/src/search/utils/order_by.js b/addons/web/static/src/search/utils/order_by.js new file mode 100644 index 00000000000..d908dadf2fc --- /dev/null +++ b/addons/web/static/src/search/utils/order_by.js @@ -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, + }; + } + }); +} diff --git a/addons/web/static/src/views/basic_relational_model.js b/addons/web/static/src/views/basic_relational_model.js deleted file mode 100644 index f577c35111c..00000000000 --- a/addons/web/static/src/views/basic_relational_model.js +++ /dev/null @@ -1,1445 +0,0 @@ -/* @odoo-module */ - -import { makeContext } from "@web/core/context"; -import { Domain } from "@web/core/domain"; -import { WarningDialog } from "@web/core/errors/error_dialogs"; -import { deserializeDate, deserializeDateTime } from "@web/core/l10n/dates"; -import { KeepLast } from "@web/core/utils/concurrency"; -import { escape } from "@web/core/utils/strings"; -import { mapDoActionOptionAPI } from "@web/legacy/backend_utils"; -import { - mapActiveFieldsToFieldsInfo, - mapViews, - mapWowlValueToLegacy, -} from "@web/views/legacy_utils"; -import { Model } from "@web/views/model"; -import { evalDomain } from "@web/views/utils"; -import BasicModel from "web.BasicModel"; -import Context from "web.Context"; - -import { markup, toRaw } from "@odoo/owl"; - -const DEFAULT_HANDLE_FIELD = "sequence"; - -let nextId = 0; -class DataPoint { - /** - * @param {RelationalModel} model - * @param {Object} [params={}] - * @param {Object} [state={}] - */ - constructor(model, params, state = {}) { - this.id = `datapoint_${nextId++}`; - this.model = model; - - let info; - if (params.__bm_load_params__) { - this.__bm_load_params__ = params.__bm_load_params__; - info = this.__bm_load_params__; - this.context = info.context; - } else if (params.handle) { - this.__bm_handle__ = params.handle; - info = this.model.__bm__.get(this.__bm_handle__); - this.context = this.model.__bm__.localData[this.__bm_handle__].getContext(); - } else { - throw new Error("Datapoint needs load params or handle"); - } - this.resModel = info.model || info.modelName; - this.fields = info.fields; - this.activeFields = {}; - - this.__syncParent = params.__syncParent || (() => {}); - this.__viewType = params.viewType || info.viewType; - const fieldsInfo = (info.fieldsInfo && info.fieldsInfo[this.__viewType]) || {}; - for (const [name, descr] of Object.entries(fieldsInfo)) { - this.activeFields[name] = descr.__WOWL_FIELD_DESCR__ || {}; - } - - this.setup(params, state); - } - - /** - * @abstract - * @param {Object} params - * @param {Object} state - */ - setup() {} - - get evalContext() { - const datapoint = this.model.__bm__.localData[this.__bm_handle__]; - return this.model.__bm__._getLazyEvalContext(datapoint); - } - - getEvalContext(forDomain) { - const datapoint = this.model.__bm__.localData[this.__bm_handle__]; - return this.model.__bm__._getEvalContext(datapoint, forDomain, false, true); - } - - get fieldNames() { - return Object.keys(this.activeFields); - } - - exportState() { - return {}; - } - - async load() { - throw new Error("load must be implemented"); - } -} - -export class Record extends DataPoint { - setup(params, state) { - this.data = {}; - this._invalidFields = new Set(); - this.preloadedData = {}; - this.selected = false; - this.isInQuickCreation = params.isInQuickCreation || false; - this._onChangePromise = Promise.resolve({}); - this._savePromise = Promise.resolve(); - this._domains = {}; - this._closeInvalidFieldsNotification = () => {}; - - this.onWillSaveRecord = params.onWillSaveRecord || (() => {}); - this.onRecordSaved = params.onRecordSaved || (() => {}); - - this._requiredFields = {}; - for (const [fieldName, activeField] of Object.entries(this.activeFields)) { - const { modifiers } = activeField; - if (modifiers && modifiers.required) { - this._requiredFields[fieldName] = modifiers.required; - } - } - - if (!this.resId && this.__viewType === "form") { - this.mode = "edit"; // always edit a new record in form view. - } else { - this.mode = params.mode || state.mode || "readonly"; - } - - this._onWillSwitchMode = params.onRecordWillSwitchMode || (() => {}); - - if (this.__bm_handle__) { - this.__syncData(); - } - } - - // ------------------------------------------------------------------------- - // Getters - // ------------------------------------------------------------------------- - - get canBeAbandoned() { - return this.model.__bm__.canBeAbandoned(this.__bm_handle__); - } - - /** - * Since the ORM can support both `active` and `x_active` fields for - * the archiving mechanism, check if any such field exists and prioritize - * them. The `active` field should always take priority over its custom - * version. - * - * @returns {boolean} true iff the field is active or there is no `active` - * or `x_active` field on the model - */ - 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 isDirty() { - return this.model.__bm__.isDirty(this.__bm_handle__); - } - - get isInEdition() { - return this.mode === "edit"; - } - - get isNew() { - return !this.resId; - } - - get isValid() { - return !this._invalidFields.size; - } - - get resId() { - if (this.__bm_handle__) { - const resId = this.model.__bm__.localData[this.__bm_handle__].res_id; - if (typeof resId === "string") { - return false; - } - return resId; - } else { - // record not loaded yet - return this.__bm_load_params__.res_id; - } - } - - get resIds() { - return this.model.__bm__.localData[this.__bm_handle__].res_ids; - } - - async askChanges() { - const proms = []; - this.model.bus.trigger("NEED_LOCAL_CHANGES", { proms }); - return Promise.all([...proms, this._updatePromise]); - } - - // ------------------------------------------------------------------------- - // Getters - // ------------------------------------------------------------------------- - - async checkValidity(urgent) { - if (!urgent) { - await this.askChanges(); - } - for (const fieldName in this.activeFields) { - const fieldType = this.fields[fieldName].type; - const activeField = this.activeFields[fieldName]; - if (fieldName in this._requiredFields) { - if ( - !evalDomain(this._requiredFields[fieldName], this.evalContext) || - (activeField && activeField.alwaysInvisible) - ) { - this._removeInvalidFields([fieldName]); - continue; - } - } - - const isSet = activeField && activeField.field && activeField.field.isSet; - - if (this._isRequired(fieldName) && isSet && !isSet(this.data[fieldName])) { - this.setInvalidField(fieldName); - continue; - } - - switch (fieldType) { - case "boolean": - case "float": - case "integer": - case "monetary": - continue; - case "properties": - if (!this.checkPropertiesValidity(fieldName)) { - this._setInvalidField(fieldName); - } - break; - case "one2many": - case "many2many": - if (!(await this.checkX2ManyValidity(fieldName, urgent))) { - this._setInvalidField(fieldName); - } - break; - default: - if (!isSet && this._isRequired(fieldName) && !this.data[fieldName]) { - this._setInvalidField(fieldName); - } - } - } - return !this._invalidFields.size; - } - - openInvalidFieldsNotification() { - if (this._invalidFields.size) { - const invalidFields = [...this._invalidFields].map((fieldName) => { - return `
  • ${escape(this.fields[fieldName].string || fieldName)}
  • `; - }, this); - this._closeInvalidFieldsNotification = this.model.notificationService.add( - markup(`
      ${invalidFields.join("")}
    `), - { - title: this.model.env._t("Invalid fields: "), - type: "danger", - } - ); - } - } - - async switchMode(mode, options) { - if (this.mode === mode) { - return true; - } - const canSwitch = await this._onWillSwitchMode(this, mode, options); - if (canSwitch === false) { - return false; - } - - if (mode === "edit") { - // wait for potential pending changes to be saved (done with widgets - // allowing to edit in readonly) - await this.model.__bm__.mutex.getUnlockedDef(); - } - - if (mode === "readonly") { - for (const fieldName in this.activeFields) { - if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { - const editedRecord = this.data[fieldName] && this.data[fieldName].editedRecord; - if (editedRecord) { - editedRecord.switchMode("readonly"); - } - } - } - } - this.mode = mode; - this.model.notify(); - return true; - } - - isFieldDirty(fieldName) { - const changes = this.model.__bm__.localData[this.__bm_handle__]._changes; - return changes && changes[fieldName]; - } - - /** - * FIXME: memoize this at some point? - * @param {string} fieldName - * @returns {boolean} - */ - _isReadonly(fieldName) { - const { readonly } = this.activeFields[fieldName].modifiers || {}; - return evalDomain(readonly, this.evalContext); - } - - /** - * FIXME: memoize this at some point? - * @param {string} fieldName - * @returns {boolean} - */ - _isRequired(fieldName) { - const { required } = this.activeFields[fieldName].modifiers || {}; - return evalDomain(required, this.evalContext); - } - - async checkX2ManyValidity(fieldName, urgent = false) { - const list = this.data[fieldName]; - const record = list.editedRecord; - if (record && !(await record.checkValidity(urgent))) { - if (record.canBeAbandoned && !record.isDirty) { - list.abandonRecord(record.id); - } else { - return false; - } - } - return true; - } - - /** - * The label and the id of the properties are always required. - * - * @param {string} fieldName - * @returns {boolean} - */ - checkPropertiesValidity(fieldName) { - const value = this.data[fieldName]; - if (!value) { - return true; - } - return value.every( - (propertyDefinition) => - !propertyDefinition.id || - (propertyDefinition.string && propertyDefinition.string.length) - ); - } - - _setInvalidField(fieldName) { - this._invalidFields.add(fieldName); - this.model.notify(); - } - - setInvalidField(fieldName) { - const bm = this.model.__bm__; - bm.setDirty(this.__bm_handle__); - this._setInvalidField(fieldName); - } - - isInvalid(fieldName) { - return this._invalidFields.has(fieldName); - } - - async load(params = {}, options = {}) { - if (!this.__bm_handle__) { - this.__bm_handle__ = await this.model.__bm__.load({ - ...this.__bm_load_params__, - viewType: this.__viewType, - }); - } else { - this.__bm_handle__ = await this.model.__bm__.reload(this.__bm_handle__, { - viewType: this.__viewType, - keepChanges: !!options.keepChanges, - }); - } - this.__syncData(); - this._invalidFields = new Set(); - } - - exportState() { - return { - mode: this.mode, - resId: this.resId, - resIds: this.resIds, - }; - } - - __syncData(force) { - const bm = this.model.__bm__; - const legDP = bm.get(this.__bm_handle__); - const data = Object.assign({}, legDP.data); - for (const fieldName of this.fieldNames) { - const fieldType = legDP.fields[fieldName].type; - switch (fieldType) { - case "date": - case "datetime": { - // from moment to luxon - if (data[fieldName]) { - const deserialize = - fieldType === "date" ? deserializeDate : deserializeDateTime; - data[fieldName] = deserialize(data[fieldName].toJSON()); - } - break; - } - case "one2many": - case "many2many": { - const currentVal = this.data[fieldName]; - if ( - !data[fieldName] || - (currentVal && currentVal.__bm_handle__ === data[fieldName].id && !force) - ) { - data[fieldName] = this.data[fieldName]; - data[fieldName].__syncData(); - } else { - const { viewMode, views } = this.activeFields[fieldName]; - const handleField = - (views && views[viewMode] && views[viewMode].handleField) || null; - data[fieldName] = new StaticList(this.model, { - handle: data[fieldName].id, - handleField, - viewType: viewMode, - __syncParent: async (value) => { - await this.model.__bm__.save(this.__bm_handle__, { - savePoint: true, - }); - await this.update({ [fieldName]: value }); - }, - }); - data[fieldName].__fieldName__ = fieldName; - } - break; - } - case "many2one": { - if (data[fieldName]) { - data[fieldName] = [ - data[fieldName].data.id, - data[fieldName].data.display_name || "", - ]; - } else { - data[fieldName] = false; - } - break; - } - case "reference": { - data[fieldName] = data[fieldName] - ? { - resModel: data[fieldName].model, - resId: data[fieldName].data.id, - displayName: data[fieldName].data.display_name, - } - : false; - break; - } - case "text": - case "char": { - data[fieldName] = data[fieldName] || ""; - break; - } - case "html": { - data[fieldName] = markup(data[fieldName] || ""); - break; - } - } - if (legDP.specialData[fieldName]) { - this.preloadedData[fieldName] = legDP.specialData[fieldName]; - } - } - this.data = data; - } - - getFieldDomain(fieldName) { - const { domain } = this.fields[fieldName]; - return domain ? new Domain(domain).toList(this.getEvalContext(true)) : []; - } - - _getFieldContext(fieldName) { - return this.model.__bm__.localData[this.__bm_handle__].getContext({ - fieldName, - viewType: this.__viewType, - }); - } - - async update(changes) { - if (this.batchingUpdateProm) { - // Assign changes in the current batch - Object.assign(this.batchChanges, changes); - return this._updatePromise; - } - - this.batchingUpdateProm = Promise.resolve(); - this.batchChanges = Object.assign({}, changes); - - let resolveUpdatePromise; - this._updatePromise = new Promise((r) => { - resolveUpdatePromise = r; - }); - - await this.batchingUpdateProm; - changes = this.batchChanges; - this.batchingUpdateProm = null; - this.batchChanges = null; - - const data = {}; - for (const [fieldName, value] of Object.entries(changes)) { - const fieldType = this.fields[fieldName].type; - data[fieldName] = mapWowlValueToLegacy(value, fieldType); - // special case for many2ones: they can be updated with a new name (e.g. if edited from - // the dialog), but in the basic_model it worked differently, we had a datapoint for the - // many2one value and we reloaded it directly. In the new model, we directly update the - // value [id, display_name], so we reload beforehand, in the many2one field itself. In - // the next few lines, we thus manually apply the renaming on the legacy datapoint. - if (this.fields[fieldName].type === "many2one" && Array.isArray(changes[fieldName])) { - const newName = changes[fieldName][1]; - if (newName || newName === "") { - const bm = this.model.__bm__; - const m2oDatapointId = bm.get(this.__bm_handle__).data[fieldName].id; - const m2oDatapoint = bm.localData[m2oDatapointId]; - if (m2oDatapoint && m2oDatapoint.data.id === changes[fieldName][0]) { - m2oDatapoint.data.display_name = newName; - } - } - } - // same for reference fields - if (this.fields[fieldName].type === "reference" && changes[fieldName].displayName) { - const bm = this.model.__bm__; - const m2oDatapointId = bm.get(this.__bm_handle__).data[fieldName].id; - const m2oDatapoint = bm.localData[m2oDatapointId]; - if (m2oDatapoint) { - m2oDatapoint.data.display_name = changes[fieldName].displayName; - } - } - } - if (this._urgentSave) { - const fieldNames = await this.model.__bm__.notifyChanges(this.__bm_handle__, data, { - viewType: this.__viewType, - notifyChange: false, - }); - resolveUpdatePromise(); - this._removeInvalidFields(fieldNames); - this.__syncData(); - return; - } - - const parentID = this.model.__bm__.localData[this.__bm_handle__].parentID; - if (parentID && this.__viewType === "list") { - // inside an x2many (parentID is the id of the static list datapoint) - const mainRecordId = this.model.__bm__.localData[parentID].parentID; - const mainRecordDP = this.model.__bm__.localData[mainRecordId]; - const mainRecordValues = { ...mainRecordDP.data, ...mainRecordDP._changes }; - const x2manyFieldName = Object.keys(mainRecordValues).find( - (name) => mainRecordValues[name] === parentID - ); - if (!x2manyFieldName) { - throw new Error("couldn't find x2many field name"); - } - const operation = { operation: "UPDATE", id: this.__bm_handle__, data }; - const prom = this.__syncParent(operation); - prom.catch(resolveUpdatePromise); - await prom; - } else { - const prom = this.model.__bm__.notifyChanges(this.__bm_handle__, data, { - viewType: this.__viewType, - }); - prom.catch(resolveUpdatePromise); // onchange rpc may return an error - const fieldNames = await prom; - this._removeInvalidFields(fieldNames); - for (const fieldName of fieldNames) { - if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { - const { editedRecord } = this.data[fieldName]; - if (editedRecord) { - editedRecord._removeAllInvalidFields(); - } - } - } - this.__syncData(); - } - this._removeInvalidFields(Object.keys(changes)); - this.model.notify(); - resolveUpdatePromise(); - } - - /** - * - * @param {Object} options - * @param {boolean} [options.stayInEdition=false] - * @param {boolean} [options.noReload=false] prevents the record from - * reloading after changes are applied, typically used to defer the load. - * @param {boolean} [options.useSaveErrorDialog=false] displays a custom - * dialog and await the response from this dialog when an error is - * returned by the server. - * @param {boolean} [options.throwOnError=false] throws the saving error if - * applicable, allowing to catch it. - * @returns {Promise} - */ - async save(options = {}) { - options = Object.assign( - { - stayInEdition: true, - noReload: false, - savePoint: false, - useSaveErrorDialog: false, - throwOnError: false, - }, - options - ); - const shouldSwitchToReadonly = !options.stayInEdition && this.isInEdition; - let resolveSavePromise; - this._savePromise = new Promise((r) => { - resolveSavePromise = r; - }); - this._closeInvalidFieldsNotification(); - if (!(await this.checkValidity())) { - this.openInvalidFieldsNotification(); - resolveSavePromise(); - return false; - } - if ((await this.onWillSaveRecord(this)) === false) { - return false; - } - const saveOptions = { - reload: !options.noReload, - savePoint: options.savePoint, - }; - try { - await this.model.__bm__.save(this.__bm_handle__, saveOptions); - } catch (_e) { - resolveSavePromise(); - let canProceed = false; - if (options.useSaveErrorDialog) { - _e.__raisedOnFormSave = true; - canProceed = await new Promise((resolve) => { - _e.onDiscard = async () => { - await this.discard(); - resolve(true); - }; - _e.onStayHere = () => resolve(false); - }); - } - - if (!this.isInEdition) { - await this.load(); - this.model.notify(); - } - if (options.throwOnError) { - throw _e; - } - return canProceed; - } - this.__syncData(true); - if (shouldSwitchToReadonly) { - this.switchMode("readonly"); - } - this.model.notify(); - resolveSavePromise(); - - await this.onRecordSaved(this); - return true; - } - - /** - * To be called **only** when Odoo is about to be closed, and we want to - * save potential changes on a given record. - * - * We can't follow the normal flow (onchange(s) + save, mutexified), - * because the 'beforeunload' handler must be *almost* sync (< 10 ms - * setTimeout seems fine, but an rpc roundtrip is definitely too long), - * so here we bypass the standard mechanism of notifying changes and - * saving them: - * - we ask the model to bypass its mutex for upcoming 'notifyChanges' and - * 'save' requests - * - we ask all fields to commit their changes (in case there would - * be a focused field with a fresh value) - * - we take all changes that have been reported to the - * controller, but not yet sent to the model because of the mutex, - * and directly notify the model about them, see update. - * - we reset the widgets with all those changes, s.t. a further call - * to 'canBeRemoved' uses the correct data (it asks the widgets if - * they are set/valid, based on their internal state) - * - if the record is dirty, we save directly - * - * @param {string} recordID - * @private - */ - async urgentSave() { - this.model.__bm__.bypassMutex = true; - this._urgentSave = true; - this.model.bus.trigger("WILL_SAVE_URGENTLY"); - await Promise.resolve(); - this.__syncData(); - let isValid = true; - if (this.isDirty) { - isValid = await this.checkValidity(true); - if (isValid) { - this.model.__bm__.save(this.__bm_handle__, { reload: false }); - } - } - this.model.__bm__.bypassMutex = false; - return isValid; - } - - async archive() { - await this.model.__bm__.actionArchive([this.resId], this.__bm_handle__); - this.__syncData(); - this.model.notify(); - } - - async unarchive() { - await this.model.__bm__.actionUnarchive([this.resId], this.__bm_handle__); - this.__syncData(); - this.model.notify(); - } - - // FIXME AAB: to discuss: not sure we want to keep resIds in the model (this concerns - // "duplicate" and "delete"). Alternative: handle this in form_view (but then what's the - // point of calling a Record method to do the operation?) - async duplicate() { - this.__bm_handle__ = await this.model.__bm__.duplicateRecord(this.__bm_handle__); - this.__syncData(); - this.switchMode("edit"); - this.model.notify(); - } - - async delete() { - await this.model.__bm__.deleteRecords([this.__bm_handle__], this.resModel); - if (this.resIds.length) { - await this.load(); - } - this.model.notify(); - } - - toggleSelection(selected) { - if (typeof selected === "boolean") { - this.selected = selected; - } else { - this.selected = !this.selected; - } - this.model.notify(); - } - - async discard() { - await this._savePromise; - this._closeInvalidFieldsNotification(); - this.model.__bm__.discardChanges(this.__bm_handle__); - this._invalidFields = new Set(); - this.__syncData(); - this.model.notify(); - } - - _removeInvalidFields(fieldNames) { - for (const fieldName of fieldNames) { - this._invalidFields.delete(fieldName); - } - } - - _removeAllInvalidFields() { - this._removeInvalidFields(Object.keys(this.activeFields)); - } -} - -export class StaticList extends DataPoint { - setup(params) { - /** @type {Record[]} */ - this.records = []; - - this.handleField = params.handleField; - - this.editedRecord = null; - this.onRecordWillSwitchMode = async (record, mode, options = {}) => { - if (mode === "edit") { - await this.model.__bm__.save(this.__bm_handle__, { savePoint: true }); - this.model.__bm__.freezeOrder(this.__bm_handle__); - } - - const editedRecord = this.editedRecord; - this.editedRecord = null; - if (editedRecord) { - // Validity is checked if one of the following is true: - // - "switchMode" has been called with explicit "checkValidity" - // - the record is dirty - // - the record is new and can be abandonned - const shouldCheckValidity = - options.checkValidity || editedRecord.isDirty || editedRecord.canBeAbandoned; - const isValid = !shouldCheckValidity || (await editedRecord.checkValidity()); - if (isValid) { - await editedRecord.switchMode("readonly"); - } else if (editedRecord.id !== record.id && editedRecord.canBeAbandoned) { - this.abandonRecord(editedRecord.id); - } else { - this.editedRecord = editedRecord; - return false; - } - } - if (mode === "edit") { - this.editedRecord = record; - } - }; - - this.__syncData(); - } - - __syncData() { - const legacyListDP = this.model.__bm__.get(this.__bm_handle__); - this.records = legacyListDP.data.map((dp) => { - let record = this.records.find((r) => r.__bm_handle__ === dp.id); - if (record) { - record.__syncData(); - } else { - record = new this.model.constructor.Record(this.model, { - handle: dp.id, - onRecordWillSwitchMode: this.onRecordWillSwitchMode, - mode: "readonly", - viewType: this.__viewType, - __syncParent: async (value) => { - await this.__syncParent(value); - }, - }); - if (record.mode === "edit" && this.editedRecord) { - this.editedRecord.mode = "readonly"; - } - } - return record; - }); - this.editedRecord = this.records.find((r) => r.mode === "edit") || null; - } - - // FIXME: remove? - get resIds() { - return this.model.__bm__.get(this.__bm_handle__).res_ids; - } - get currentIds() { - return this.model.__bm__.get(this.__bm_handle__).res_ids; - } - - get orderBy() { - return this.model.__bm__.localData[this.__bm_handle__].orderedBy; - } - get limit() { - return this.model.__bm__.localData[this.__bm_handle__].limit; - } - get offset() { - return this.model.__bm__.localData[this.__bm_handle__].offset; - } - get count() { - return this.currentIds.length; - } - - abandonRecord(recordId) { - const record = this.records.find((r) => r.id === recordId); - this.model.__bm__.removeLine(record.__bm_handle__); - this.__syncData(); - this.model.notify(); - } - - canResequence() { - return this.handleField || DEFAULT_HANDLE_FIELD in this.fields; - } - - removeRecord(record) { - // if (true) { see _onRemoveRecord in rel fields - this.delete(record.id); - // } - } - - async delete(recordId, operation = "DELETE") { - const record = this.records.find((r) => r.id === recordId); - await this.__syncParent({ operation, ids: [record.__bm_handle__] }); - } - - async add(object, params = { isM2M: false }) { - let operation; - const bm = this.model.__bm__; - if (object instanceof Record) { - const recHandle = object.__bm_handle__; - await bm.save(recHandle, { savePoint: !params.isM2M, viewType: object.__viewType }); - if (params.isM2M) { - const id = bm.localData[recHandle].res_id; - operation = { operation: "ADD_M2M", ids: [{ id }] }; - } else { - operation = { operation: "ADD", id: recHandle }; - } - } else if (Array.isArray(object) && params.isM2M) { - const oldIds = this.resIds; - const newIds = object.filter((id) => !oldIds.includes(id)).map((id) => ({ id })); - operation = { operation: "ADD_M2M", ids: newIds }; - } - await this.__syncParent(operation); - } - - /** Creates a Draft record from nothing and edits it. Relevant in editable x2m's */ - async addNew(params) { - const position = params.position; - const operation = { context: [params.context], operation: "CREATE", position }; - await this.model.__bm__.save(this.__bm_handle__, { savePoint: true }); - this.model.__bm__.freezeOrder(this.__bm_handle__); - await this.__syncParent(operation); - const newRecord = this.records[position === "bottom" ? this.records.length - 1 : 0]; - if (params.mode === "edit") { - await newRecord.switchMode("edit"); - } - return newRecord; - } - - // x2many dialog edition - // addRecord(record) { - // this.onChanges(); - // this._cache[record.virtualId] = record; - // this._cache[record.virtualId] = record; - // this.records.push(record); - // this.resIds.push(record.virtualId); - // this.limit = this.limit + 1; // might be not good - // this.validated[record.virtualId] = false; - // this.model.notify(); - // } - - exportState() { - return { - limit: this.limit, - }; - } - - async load(params) { - await this.model.__bm__._reload(this.__bm_handle__, params); - this.__syncData(); - } - - async sortBy(fieldName) { - await this.model.__bm__.setSort(this.__bm_handle__, fieldName); - this.__syncData(); - this.model.notify(); - } - - async replaceWith(resIds) { - const addedIds = resIds.filter((id) => !this.currentIds.includes(id)); - const deletedIds = this.currentIds.filter((id) => !resIds.includes(id)); - let operation; - if (addedIds.length && deletedIds.length) { - operation = { - ids: resIds, - operation: "REPLACE_WITH", - }; - } else if (addedIds.length) { - operation = { - ids: addedIds.map((id) => ({ id })), - operation: "ADD_M2M", - }; - } else if (deletedIds.length) { - operation = { - ids: deletedIds, - operation: "FORGET", - }; - } else { - // no change? - return; - } - await this.__syncParent(operation); - } - - /** - * @param {Array[]} commands array of commands - */ - async applyCommands(fieldName, commands) { - const commandsWithId = commands.map((command) => { - return { - operation: command.operation, - id: command.record.__bm_handle__, - data: command.data, - }; - }); - - const parentID = this.model.__bm__.localData[this.__bm_handle__].parentID; - await this.model.__bm__.notifyChanges(parentID, { - [fieldName]: { - operation: "MULTI", - commands: commandsWithId, - }, - }); - - this.model.root.__syncData(); - this.model.notify(); - } - - /** - * @param {string} dataRecordId - * @param {string} dataGroupId - * @param {string} refId - * @param {string} targetGroupId - * @returns {Promise} - */ - async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) { - await this.resequence(dataRecordId, refId); - } - - /** - * @param {RecordId} movedId // id of the moved record - * @param {RecordId | null} targetId // id of the record (if any) that must be before moved record after operation is done - */ - async resequence(movedId, targetId) { - if (!this.canResequence()) { - // There is no handle field on the current model - return; - } - - if (this.__viewType === "list") { - await this.model.__bm__.save(this.__bm_handle__, { savePoint: true }); - this.model.__bm__.freezeOrder(this.__bm_handle__); - } - - const handleField = this.handleField || DEFAULT_HANDLE_FIELD; - const records = [...this.records]; - const order = this.orderBy.find((o) => o.name === 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[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 operations = toReorder.map((record, i) => ({ - operation: "UPDATE", - id: record.__bm_handle__, - data: { [handleField]: offset + i }, - })); - const lastOperation = operations.pop(); - - const parentID = this.model.__bm__.localData[this.__bm_handle__].parentID; - - await Promise.all( - operations.map((op) => { - this.model.__bm__.notifyChanges( - parentID, - { [this.__fieldName__]: op }, - { notifyChange: false, viewType: "form" } - ); - }) - ); - - try { - await this.model.__bm__.notifyChanges( - parentID, - { - [this.__fieldName__]: lastOperation, - }, - { viewType: "form" } - ); - } finally { - if (this.__viewType === "list") { - await this.model.__bm__.setSort(this.__bm_handle__, handleField); - } - } - - this.records = records; - - this.__syncData(); - this.model.notify(); - } - - async unselectRecord(canDiscard = false) { - // something seems wrong with switchMode --> review system? - const editedRecord = this.editedRecord; - if (!editedRecord) { - return true; - } - - const isValid = await editedRecord.checkValidity(); - const handle = editedRecord.__bm_handle__; - if (!editedRecord.isDirty && ((canDiscard && editedRecord.isNew) || !isValid)) { - this.model.__bm__.discardChanges(handle); - if (editedRecord.canBeAbandoned) { - this.model.__bm__.removeLine(handle); - } - this.__syncData(); - this.editedRecord = null; - return await editedRecord.switchMode("readonly"); - } else if (isValid) { - return await editedRecord.switchMode("readonly"); - } - return false; - } -} - -export class RelationalModel extends Model { - setup(params, { action, dialog, notification }) { - this.actionService = action; - this.dialogService = dialog; - this.notificationService = notification; - this.keepLast = new KeepLast(); - - if (params.rootType !== "record") { - throw "only record root type is supported"; - } - - this.__component = params.component; - - this.root = null; - - this.__bm__ = new this.constructor.LegacyModel(this, { - fields: params.fields || {}, - modelName: params.resModel, - useSampleModel: false, // FIXME AAB - }); - this.__activeFields = params.activeFields; - this.__fields = params.fields; - - const res_id = params.resId || undefined; - const res_ids = (params.resIds ? toRaw(params.resIds) : null) || (res_id ? [res_id] : []); - - this.__bm_load_params__ = { - type: "record", - modelName: params.resModel, - res_id, - res_ids, - fields: params.fields || {}, - viewType: "form", - }; - - this.initialMode = params.mode; - - this.onWillSaveRecord = params.onWillSaveRecord || (() => {}); - this.onRecordSaved = params.onRecordSaved || (() => {}); - } - - async duplicateDatapoint(record, params) { - const bm = this.__bm__; - const fieldsInfo = mapViews(params.views, this.env); - const handle = record.__bm_handle__; - - // Sync with the mutex to wait for potential onchanges - await bm.mutex.getUnlockedDef(); - - await bm.addFieldsInfo(handle, { - fields: params.fields, - viewType: params.viewMode, - fieldInfo: fieldsInfo[params.viewMode].fieldsInfo[params.viewMode], - }); - - // determine fieldNames to load (comes from basic_view.js) - const legRec = this.__bm__.get(record.__bm_handle__); - const viewType = params.viewMode; - const viewFields = Object.keys(legRec.fieldsInfo[viewType]); - const fieldNames = viewFields.filter((x) => Object.keys(legRec.data).indexOf(x) < 0); - const legFieldsInfo = legRec.fieldsInfo[viewType]; - - // Suppose that in a form view, there is an x2many list view with - // a field F, and that F is also displayed in the x2many form view. - // In this case, F is represented in legRec.data (as it is known by - // the x2many list view), but the loaded information may not suffice - // in the form view (e.g. if field is a many2many list in the form - // view, or if it is displayed by a widget requiring specialData). - // So when this happens, F is added to the list of fieldNames to fetch. - for (const name of viewFields) { - if (!fieldNames.includes(name)) { - const fieldType = legRec.fields[name].type; - const fieldInfo = legFieldsInfo[name]; - // SpecialData case: field requires specialData that haven't - // been fetched yet. - if (fieldInfo.specialData && !(name in legRec.specialData)) { - fieldNames.push(name); - continue; - } - - // X2Many case: field is an x2many displayed as a list or - // kanban view, but the related fields haven't been loaded yet. - if (fieldType === "one2many" || fieldType === "many2many") { - if (!("fieldsInfo" in legRec.data[name])) { - fieldNames.push(name); - } else { - const x2mFieldInfo = legRec.fieldsInfo[params.viewMode][name]; - const viewType = x2mFieldInfo.viewType || x2mFieldInfo.mode; - const knownFields = Object.keys( - legRec.data[name].fieldsInfo[legRec.data[name].viewType] || {} - ); - const newFields = Object.keys(legRec.data[name].fieldsInfo[viewType] || {}); - if (newFields.filter((f) => !knownFields.includes(f)).length) { - fieldNames.push(name); - } - - if (legRec.data[name].viewType === "default") { - // Use case: x2many (tags) in x2many list views - // When opening the x2many legRec form view, the - // x2many will be reloaded but it may not have - // the same fields (ex: tags in list and list in - // form) so we need to merge the fieldsInfo to - // avoid losing the initial fields (display_name) - const fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {}; - const defaultFieldInfo = legRec.data[name].fieldsInfo.default; - Object.values(fieldViews).forEach((fieldView) => { - Object.assign(defaultFieldInfo, fieldView.fields); - Object.values(fieldView.fieldsInfo).forEach((x2mFieldInfo) => { - Object.assign(defaultFieldInfo, x2mFieldInfo); - }); - }); - } - } - } - // Many2one: context is not the same between the different views - // this means the result of a name_get could differ - if (fieldType === "many2one") { - if ( - JSON.stringify(legRec.data[name].context) !== - JSON.stringify(fieldInfo.context) - ) { - fieldNames.push(name); - } - } - } - } - - if (fieldNames.length) { - if (this.__bm__.isNew(record.__bm_handle__)) { - await this.__bm__.generateDefaultValues(record.__bm_handle__, { - fieldNames: fieldNames, - viewType: viewType, - }); - } else { - await this.__bm__.reload(record.__bm_handle__, { - fieldNames: fieldNames, - keepChanges: true, - viewType: viewType, - }); - } - } - - const Record = this.constructor.Record; - const newRecord = new Record(this, { - handle, - viewType: params.viewMode, - mode: params.mode, - }); - - const recordSave = Record.prototype.save; - record.save = recordSave; - record.save = async (...args) => { - record.__syncData(); - const res = await recordSave.call(record, ...args); - record.save = recordSave; - return res; - }; - - return newRecord; - } - async addNewRecord(list, params, withParentId = true) { - const parentId = this.__bm__.localData[list.__bm_handle__].parentID; - const fieldName = list.__fieldName__; - const context = this.__bm__._getContext(this.__bm__.localData[parentId], { fieldName }); - params.context = makeContext([context, params.context]); - params.__syncParent = () => list.__syncData(); - const newRecord = this.createDataPoint("record", params); - if (withParentId) { - newRecord.__bm_load_params__.parentID = list.__bm_handle__; - } - await newRecord.load(); - return newRecord; - } - async updateRecord(list, record, params = { isM2M: false }) { - let operation; - const isM2M = params.isM2M; - if (!isM2M) { - operation = { operation: "UPDATE", id: record.__bm_handle__ }; - } else { - await record.save({ noReload: true }); - operation = { operation: "TRIGGER_ONCHANGE" }; - } - await list.__syncParent(operation); - if (isM2M) { - await record.load(); - } - } - - /** - * @param {Object} [params={}] - * @param {Comparison | null} [params.comparison] - * @param {Context} [params.context] - * @param {DomainListRepr} [params.domain] - * @param {string[]} [params.groupBy] - * @param {Object[]} [params.orderBy] - * @param {number} [params.resId] should not be there - * @returns {Promise} - */ - async load(params = {}) { - if (!this.__bm_load_params__.fieldsInfo) { - // only the first time (must wait for the subviews to be loaded) - this.__bm_load_params__.fieldsInfo = mapActiveFieldsToFieldsInfo( - this.__activeFields || {}, - this.__fields || {}, - "form", - this.env - ); - } - const loadParams = { ...this.__bm_load_params__ }; - if ("resId" in params) { - loadParams.res_id = params.resId || undefined; - } - if ("context" in params) { - loadParams.context = params.context; - } - const state = this.root ? this.root.exportState() : {}; - const nextRoot = new this.constructor.Record( - this, - { - __bm_load_params__: loadParams, - mode: this.initialMode, - onWillSaveRecord: this.onWillSaveRecord, - onRecordSaved: this.onRecordSaved, - }, - state - ); - await this.keepLast.add(nextRoot.load()); - this.root = nextRoot; - this.__bm_load_params__ = loadParams; - this.notify(); - } - _trigger_up(ev) { - const evType = ev.name; - const payload = ev.data; - if (evType === "call_service") { - let args = payload.args || []; - if (payload.service === "ajax" && payload.method === "rpc") { - // ajax service uses an extra 'target' argument for rpc - args = args.concat(ev.target); - if (owl.status(this.__component) === "destroyed") { - console.warn("Component is destroyed"); - return payload.callback(new Promise(() => {})); - } - const prom = new Promise((resolve, reject) => { - owl.Component.env.session - .rpc(...args) - .then((value) => { - if (owl.status(this.__component) !== "destroyed") { - resolve(value); - } - }) - .guardedCatch((reason) => { - if (owl.status(this.__component) !== "destroyed") { - reject(reason); - } - }); - }); - return payload.callback(prom); - } else if (payload.service === "notification") { - return this.notificationService.add(payload.message, { - className: payload.className, - sticky: payload.sticky, - title: payload.title, - type: payload.type, - }); - } - throw new Error(`call service ${payload.service} not handled in relational model`); - } else if (evType === "warning") { - if (payload.type === "dialog") { - return this.dialogService.add(WarningDialog, { - title: payload.title, - message: payload.message, - }); - } else { - return this.notificationService.add(payload.message, { - className: payload.className, - sticky: payload.sticky, - title: payload.title, - type: "warning", - }); - } - } else if (evType === "do_action") { - // SAME CODE AS legacy_service_provider - if (payload.action.context) { - payload.action.context = new Context(payload.action.context).eval(); - } - const legacyOptions = mapDoActionOptionAPI(payload.options); - return this.actionService.doAction(payload.action, legacyOptions); - } else if (evType === "reload") { - return this.load().then(() => { - if (ev.data.onSuccess) { - ev.data.onSuccess(); - } - }); - } - throw new Error(`trigger_up(${evType}) not handled in relational model`); - } - createDataPoint(type, params, state = {}) { - if (type !== "record") { - throw new Error("LPE CRASH"); - } - const fieldsInfo = mapActiveFieldsToFieldsInfo( - params.activeFields, - params.fields, - params.viewType, - this.env - ); - params = Object.assign({}, params, { - __bm_load_params__: { - type: "record", - modelName: params.resModel, - fields: params.fields || {}, - viewType: "form", - fieldsInfo, - parentID: params.parentID, - context: params.context, - }, - }); - return new this.constructor.Record(this, params, state); - } -} -RelationalModel.services = ["action", "dialog", "notification"]; -RelationalModel.LegacyModel = BasicModel; -RelationalModel.Record = Record; diff --git a/addons/web/static/src/views/calendar/calendar_arch_parser.js b/addons/web/static/src/views/calendar/calendar_arch_parser.js index 161751994aa..01d87ce3fa7 100644 --- a/addons/web/static/src/views/calendar/calendar_arch_parser.js +++ b/addons/web/static/src/views/calendar/calendar_arch_parser.js @@ -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, diff --git a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js index e50adc111e0..1bb8e840374 100644 --- a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js +++ b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js @@ -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() { diff --git a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml index 48a558a4cfd..ddfdf0d888c 100644 --- a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml +++ b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml @@ -44,10 +44,10 @@
      - - - - + + + +
    • @@ -60,7 +60,7 @@
      - +
    • diff --git a/addons/web/static/src/views/calendar/calendar_controller.js b/addons/web/static/src/views/calendar/calendar_controller.js index d71a9fcebdb..563f3b82c8b 100644 --- a/addons/web/static/src/views/calendar/calendar_controller.js +++ b/addons/web/static/src/views/calendar/calendar_controller.js @@ -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"; diff --git a/addons/web/static/src/views/calendar/calendar_model.js b/addons/web/static/src/views/calendar/calendar_model.js index 4db550bf8ac..016e6f68fa9 100644 --- a/addons/web/static/src/views/calendar/calendar_model.js +++ b/addons/web/static/src/views/calendar/calendar_model.js @@ -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; } diff --git a/addons/web/static/src/views/debug_items.js b/addons/web/static/src/views/debug_items.js index 39a40dc0ccd..0557b9c9593 100644 --- a/addons/web/static/src/views/debug_items.js +++ b/addons/web/static/src/views/debug_items.js @@ -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, diff --git a/addons/web/static/src/views/fields/ace/ace_field.js b/addons/web/static/src/views/fields/ace/ace_field.js index c4f4a3e9bc3..02f90112ef5 100644 --- a/addons/web/static/src/views/fields/ace/ace_field.js +++ b/addons/web/static/src/views/fields/ace/ace_field.js @@ -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 }); } } } diff --git a/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js b/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js index fab3376e2c9..5a13d772810 100644 --- a/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js +++ b/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js @@ -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, -}); diff --git a/addons/web/static/src/views/fields/binary/binary_field.js b/addons/web/static/src/views/fields/binary/binary_field.js index 44adb9064c9..6de9d6c31f9 100644 --- a/addons/web/static/src/views/fields/binary/binary_field.js +++ b/addons/web/static/src/views/fields/binary/binary_field.js @@ -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) { diff --git a/addons/web/static/src/views/fields/binary/binary_field.xml b/addons/web/static/src/views/fields/binary/binary_field.xml index 92b9adeaee3..2e90c452f61 100644 --- a/addons/web/static/src/views/fields/binary/binary_field.xml +++ b/addons/web/static/src/views/fields/binary/binary_field.xml @@ -56,7 +56,7 @@ - +
      diff --git a/addons/web/static/src/views/fields/color/color_field.js b/addons/web/static/src/views/fields/color/color_field.js index b258402870d..631e98b7e01 100644 --- a/addons/web/static/src/views/fields/color/color_field.js +++ b/addons/web/static/src/views/fields/color/color_field.js @@ -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] || ""; } } diff --git a/addons/web/static/src/views/fields/color/color_field.xml b/addons/web/static/src/views/fields/color/color_field.xml index dddf2f5af6c..098102da6d9 100644 --- a/addons/web/static/src/views/fields/color/color_field.xml +++ b/addons/web/static/src/views/fields/color/color_field.xml @@ -2,8 +2,8 @@ -
      - +
      +
      diff --git a/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js b/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js index a8f1aa5583c..1f3096f52ba 100644 --- a/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js +++ b/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js @@ -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"; diff --git a/addons/web/static/src/views/fields/field.js b/addons/web/static/src/views/fields/field.js index 9e3ce5d3932..b48aac5020d 100644 --- a/addons/web/static/src/views/fields/field.js +++ b/addons/web/static/src/views/fields/field.js @@ -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; } } diff --git a/addons/web/static/src/views/fields/font_selection/font_selection_field.js b/addons/web/static/src/views/fields/font_selection/font_selection_field.js index 0ac405a6997..f9a01d6b21d 100644 --- a/addons/web/static/src/views/fields/font_selection/font_selection_field.js +++ b/addons/web/static/src/views/fields/font_selection/font_selection_field.js @@ -49,7 +49,6 @@ export const fontSelectionField = { required: dynamicInfo.required, }; }, - legacySpecialData: "_fetchSpecialRelation", }; registry.category("fields").add("font", fontSelectionField); diff --git a/addons/web/static/src/views/fields/image/image_field.js b/addons/web/static/src/views/fields/image/image_field.js index 16a42a37e55..0bd447934d7 100644 --- a/addons/web/static/src/views/fields/image/image_field.js +++ b/addons/web/static/src/views/fields/image/image_field.js @@ -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'); diff --git a/addons/web/static/src/views/fields/input_field_hook.js b/addons/web/static/src/views/fields/input_field_hook.js index 0660f24a3d8..b44054d60e7 100644 --- a/addons/web/static/src/views/fields/input_field_hook.js +++ b/addons/web/static/src/views/fields/input_field_hook.js @@ -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; } } } diff --git a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js index 9c834abe4e8..51732d65a84 100644 --- a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js +++ b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js @@ -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); } diff --git a/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js b/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js index e65bedfed48..84b6d3eaa2f 100644 --- a/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js +++ b/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js @@ -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, -}); diff --git a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js index 349fb4cb0e9..045fa4ebffd 100644 --- a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js +++ b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js @@ -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) { diff --git a/addons/web/static/src/views/fields/many2one/many2one_field.js b/addons/web/static/src/views/fields/many2one/many2one_field.js index d737d8a9464..c720ac13c04 100644 --- a/addons/web/static/src/views/fields/many2one/many2one_field.js +++ b/addons/web/static/src/views/fields/many2one/many2one_field.js @@ -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, diff --git a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js index ca7c78aba27..711896b19d2 100644 --- a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js +++ b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js @@ -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 = { diff --git a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml index a3ae7201445..31f11ac64b6 100644 --- a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml +++ b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml @@ -4,8 +4,8 @@
      -
      -
      +
      +
      @@ -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" /> diff --git a/addons/web/static/src/views/fields/properties/properties_field.js b/addons/web/static/src/views/fields/properties/properties_field.js index bc695f6aa11..86dc201596c 100644 --- a/addons/web/static/src/views/fields/properties/properties_field.js +++ b/addons/web/static/src/views/fields/properties/properties_field.js @@ -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); } diff --git a/addons/web/static/src/views/fields/radio/radio_field.js b/addons/web/static/src/views/fields/radio/radio_field.js index aa4acb2b2df..e1cb207e787 100644 --- a/addons/web/static/src/views/fields/radio/radio_field.js +++ b/addons/web/static/src/views/fields/radio/radio_field.js @@ -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, -}); diff --git a/addons/web/static/src/views/fields/reference/reference_field.js b/addons/web/static/src/views/fields/reference/reference_field.js index dbd09ed21c9..890b7e00ea6 100644 --- a/addons/web/static/src/views/fields/reference/reference_field.js +++ b/addons/web/static/src/views/fields/reference/reference_field.js @@ -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} + */ + 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 - @@ -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; }, }; diff --git a/addons/web/static/src/views/fields/reference/reference_field.xml b/addons/web/static/src/views/fields/reference/reference_field.xml index 82604929b20..5e444140504 100644 --- a/addons/web/static/src/views/fields/reference/reference_field.xml +++ b/addons/web/static/src/views/fields/reference/reference_field.xml @@ -3,7 +3,7 @@
      - +