From b03c227e885efa4ccdcad43ccb56ee10371b9284 Mon Sep 17 00:00:00 2001 From: Denis Ledoux Date: Wed, 23 Feb 2022 15:15:39 +0000 Subject: [PATCH 1/6] [REF] models: refactor fields_view_get, load_views Refactor the `load_views` API so it no longer sends multiple times the same fields description. e.g. When `load_views` is called to get the kanban, tree and form views, the list of fields of the model was sent 4 times: - Once for each view, with only the fields used in the view, in `['fields_views']['kanban']['fields']` for instance - Once globally, with all the fields of the model, in `['fields']` The goal of this revision is to change that so it sends the list of all fields only once. In addition, if a view contains x2many fields, the fields description of the comodel is also sent. It was sent in the `views` key of the view fields dict. e.g. When calling `load_views` of `res.partner` to get the kanban, tree and form views, the `res.partner` fields description was actually sent 6 times: - Once for each view - Once globally - Once for each view of the many2many field `child_ids` of the form view, in - `['fields_views']['form']['fields']['child_ids']['views']['kanban']['fields']` - `['fields_views']['form']['fields']['child_ids']['views']['form']['fields']` The change suggested in this revision is to: - Remove the fields description for each view in `['fields_views']`. As it no longer contains the fields, the key becomes `['views']` instead of `['fields_views']`. - Replace the dict key `['fields']` by `['models']`, which is a dict with as key the model name and as values the model fields description. It contains the fields description for all models implied in the view: the model of the main view and the model of all one2many and many2many fields. With this change, the fields description will only be sent once by model implied in the view. In addition, the web client was getting the information about the fields sometimes in the global fields description list (e.g. `['fields']`), sometimes in the fields description list of the view type (e.g. `['fields_views']['form']['fields']`), making it a pain to try to make changes / performance gain in these field description dictionaries, because you never knew in which dict the web client was getting its info. Now, as there is only one place to get the fields description from, it's clearer and cleaner. - one2many and many2many fields views are passed directly in the main view architecture rather than being put in the `views` key of the field description. This is actually easier to treat by the web client, and this will allow in a future work to cache an entire view in one block of text rather than having to combine multiple cached blocks of text to return one view. - one2many and many2many fields which do not have directly embedded views have their views directly injected in the architecture, so the web client doesn't have to do RPC calls to `load_views` for each one2many and many2many fields not having embedded views. For instance, this allow to reduce the number of RPC calls to `load_views` from 8 to 1 when loading the form of `product.product`. Currently, this behavior is limited to 1 level deep but we consider making it go all the way down in future works. We did not do it for the moment because in certain cases it rises the processing time and the size (bytes) too much. e.g. the sale.order view can be 5 levels deep, meaning you can reach 4 dialogs on top the main view. ``` sale.order form > order_line > sale.order.line form > invoice_lines > account.move.line form > asset_ids > account.asset form > depreciation_move_ids > account.move form. ``` This will also benefit in future works to cache an entire view in one block of text rather to having to combine multiple cached block of text to get one view. - `fields_view_get` becomes `get_view`. As it no longer returns the fields description, keeping the `fields` in the name `fields_view_get` no longer makes sense. Hence removing `fields` from the method name, it becomes `view_get`. As it gets renamed anyway, we take the opportunity to rename it `get_view`, which is more in line with the general getter/setter guidelines in the model object world. - `_fields_view_get` becomes `_get_view`. For the same reasons than above. - `load_views` becomes `get_views`. This is not mandatory, there is no technical reason to rename `load_views` as it practically sends the same info as before, the view architectures and their fields description. Just in another way. We just take the opportunity of this pull request to suggest a cleaner API: `_get_view`, `get_view` and `get_views`. - Arguments `toolbar=False, submenu=False` fo the methods `_fields_view_get` and `fields_view_get` are converted to a kwargs `**options` in `_get_view` and `get_view`. The rationale is that submenu was already no longer used (deprecated) and the mobile options is introduced. The mobile options is necessary to tell the server to send the mobile views for x2many fields (kanban instead of tree). Instead of adding a new argument each time we add a new option to `fields_view_get`, it seems wiser to have a kwargs `**options` to avoid to re-write all overrides each time a new option is introduced. - `_fields_view_get` returned a dict containing the arch in text and some of the view information. Now, `get_view` returns a tuple with the view architecture as an `etree` node, and the view as a browse record. The rationale is that all overrides of `_fields_view_get` were about modifying the arch only (e.g. changing the address format/re-organizing the address related field nodes of the partner according to the company country). To do so, all these overrides were doing `etree.fromstring` to parse the arch which was sent in text to convert it to an `etree`, then operations were done on the `etree`, and then `etree.tostring` was called to convert back the arch to string. With this change of signature to send the arch as an `etree`, all these back and forth `etree.fromstring` -> `etree.tostring` are avoided, allowing some performance gain and less code in the end. - A cleanup of the keys returned in the dict of `fields_view_get` has been performed in `get_view`: - `fields` is removed, as explained above, - `view_id` is renamed `id`, - `name` is removed, it was unused by the web client, - `type` is removed, it was unused by the web client, - `field_parent` is removed, it was unused by the web client, - `base_model` is removed, it was unused by the web client. - `filters` is moved from the global dict returned by `load_views` (now `get_views`) to the dict returned by `fields_view_get` (now `get_view`) as it applies only to the `search` view type. - Retro-compatible methods for the 3 methods `fields_view_get`, `_fields_view_get` and `load_views` are provided, with deprecation warnings in them. - The web client could cache the model fields description (as it already caches the views), so it doesn't need to fetch them again if it asks for another view of a model for which he already has the fields description. If we do so, `get_views` could return only the list of models used by the views, without the fields description as of now, and the web client would then call `fields_get` independently only for the models for which it doesn't have yet the fields description. This would avoid the server to return the fields description and to call `fields_get`, which is costly, for each `get_views`, therefore gaining performances. - Inject the views of the one2many and many2many fields all the way down, unlimited depth level, as explained above. - Cache with `ormcache` the architecture of back-end views. This is already done for qweb views, it's not done for back-end views. Therefore the postprocessing of the views is performed for each `get_views`, which is costly, while the view architecture doesn't change for users belonging to the same groups, according to the groups implied by the view. This pull request is co-authored by Aaron Bohy (aab) for the web client part and Denis Ledoux (dle) for the server part. Part-of: odoo/odoo#87522 --- addons/board/controllers/main.py | 9 +- addons/board/models/board.py | 4 +- addons/crm/models/crm_lead.py | 8 +- .../google_spreadsheet/models/google_drive.py | 4 +- addons/hr/models/hr_employee.py | 6 +- addons/hr/models/res_users.py | 6 +- addons/hr/tests/test_self_user_access.py | 29 ++-- .../hr_recruitment/models/hr_recruitment.py | 12 +- addons/hr_timesheet/models/hr_timesheet.py | 20 +-- addons/hr_timesheet/models/project.py | 18 +- addons/hr_timesheet/report/project_report.py | 8 +- addons/mail/models/ir_ui_view.py | 7 + addons/membership/models/product.py | 4 +- .../test_project_sharing_portal_access.py | 6 +- addons/stock/models/product.py | 52 +++--- addons/web/models/models.py | 12 +- .../src/legacy/js/services/data_manager.js | 3 + addons/web/static/src/views/view_service.js | 15 +- addons/web_editor/models/ir_qweb_fields.py | 2 +- addons/website/models/ir_ui_view.py | 2 +- addons/website/models/website.py | 2 +- odoo/addons/base/models/ir_actions.py | 2 +- odoo/addons/base/models/ir_module.py | 4 +- odoo/addons/base/models/ir_qweb.py | 2 +- odoo/addons/base/models/ir_ui_view.py | 123 ++++++++++---- odoo/addons/base/models/res_config.py | 27 ++- odoo/addons/base/models/res_currency.py | 20 +-- odoo/addons/base/models/res_partner.py | 22 +-- odoo/addons/base/tests/test_acl.py | 14 +- odoo/addons/base/tests/test_translate.py | 10 +- odoo/addons/base/tests/test_views.py | 28 ++-- .../addons/base/wizard/base_module_upgrade.py | 4 +- .../tests/test_bindings.py | 12 +- .../test_new_api/tests/test_onchange.py | 4 +- .../test_new_api/views/test_new_api_views.xml | 2 +- .../tests/test_form_impl.py | 16 +- odoo/models.py | 156 +++++++++++++----- odoo/tests/common.py | 80 ++++----- odoo/tools/test_reports.py | 14 +- 39 files changed, 437 insertions(+), 332 deletions(-) diff --git a/addons/board/controllers/main.py b/addons/board/controllers/main.py index 303f5b0908e..e3871b6f822 100644 --- a/addons/board/controllers/main.py +++ b/addons/board/controllers/main.py @@ -16,10 +16,9 @@ class Board(Controller): if action and action['res_model'] == 'board.board' and action['views'][0][1] == 'form' and action_id: # Maybe should check the content instead of model board.board ? view_id = action['views'][0][0] - board = request.env['board.board'].fields_view_get(view_id, 'form') - if board and 'arch' in board: - xml = ElementTree.fromstring(board['arch']) - column = xml.find('./board/column') + board_arch, _view = request.env['board.board']._get_view(view_id, 'form') + if board_arch: + column = board_arch.find('./board/column') if column is not None: new_action = ElementTree.Element('action', { 'name': str(action_id), @@ -29,7 +28,7 @@ class Board(Controller): 'domain': str(domain) }) column.insert(0, new_action) - arch = ElementTree.tostring(xml, encoding='unicode') + arch = ElementTree.tostring(board_arch, encoding='unicode') request.env['ir.ui.view.custom'].create({ 'user_id': request.session.uid, 'ref_id': view_id, diff --git a/addons/board/models/board.py b/addons/board/models/board.py index 3d7a47f7574..9dc2978fd8e 100644 --- a/addons/board/models/board.py +++ b/addons/board/models/board.py @@ -20,13 +20,13 @@ class Board(models.AbstractModel): return self @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def get_view(self, view_id=None, view_type='form', **options): """ Overrides orm field_view_get. @return: Dictionary of Fields, arch and toolbar. """ - res = super(Board, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + res = super().get_view(view_id, view_type, **options) custom_view = self.env['ir.ui.view.custom'].search([('user_id', '=', self.env.uid), ('ref_id', '=', view_id)], limit=1) if custom_view: diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 2a4f460a4b5..d8a0802fad2 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -862,16 +862,16 @@ class Lead(models.Model): return super(Lead, self.with_context(context)).copy(default=default) @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def _get_view(self, view_id=None, view_type='form', **options): if self._context.get('opportunity_id'): opportunity = self.browse(self._context['opportunity_id']) action = opportunity.get_formview_action() if action.get('views') and any(view_id for view_id in action['views'] if view_id[1] == view_type): view_id = next(view_id[0] for view_id in action['views'] if view_id[1] == view_type) - res = super(Lead, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + arch, view = super()._get_view(view_id, view_type, **options) if view_type == 'form': - res['arch'] = self._fields_view_get_address(res['arch']) - return res + arch = self._view_get_address(arch) + return arch, view @api.model def _read_group_stage_ids(self, stages, domain, order): diff --git a/addons/google_spreadsheet/models/google_drive.py b/addons/google_spreadsheet/models/google_drive.py index 10e04bbb050..c64e8e6b5f2 100644 --- a/addons/google_spreadsheet/models/google_drive.py +++ b/addons/google_spreadsheet/models/google_drive.py @@ -26,8 +26,8 @@ class GoogleDrive(models.Model): def write_config_formula(self, attachment_id, spreadsheet_key, model, domain, groupbys, view_id): access_token = self.get_access_token(scope='https://www.googleapis.com/auth/spreadsheets') - fields = self.env[model].fields_view_get(view_id=view_id, view_type='tree') - doc = etree.XML(fields.get('arch')) + arch, _view = self.env[model]._get_view(view_id, 'tree') + doc = arch display_fields = [] for node in doc.xpath("//field"): if node.get('modifiers'): diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index fe14ec9b5db..cb58ab6f98c 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -217,10 +217,10 @@ class HrEmployeePrivate(models.Model): return self.env['hr.employee.public'].browse(self.ids).read(fields, load=load) @api.model - def load_views(self, views, options=None): + def get_view(self, view_id=None, view_type='form', **options): if self.check_access_rights('read', raise_exception=False): - return super(HrEmployeePrivate, self).load_views(views, options=options) - return self.env['hr.employee.public'].load_views(views, options=options) + return super().get_view(view_id, view_type, **options) + return self.env['hr.employee.public'].get_view(view_id, view_type, **options) @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): diff --git a/addons/hr/models/res_users.py b/addons/hr/models/res_users.py index 8123aa54bec..9a51fbed6ce 100644 --- a/addons/hr/models/res_users.py +++ b/addons/hr/models/res_users.py @@ -169,7 +169,7 @@ class User(models.Model): return super().SELF_WRITEABLE_FIELDS + HR_WRITABLE_FIELDS @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def get_view(self, view_id=None, view_type='form', **options): # When the front-end loads the views it gets the list of available fields # for the user (according to its access rights). Later, when the front-end wants to # populate the view with data, it only asks to read those available fields. @@ -182,11 +182,11 @@ class User(models.Model): original_user = self.env.user if profile_view and view_id == profile_view.id: self = self.with_user(SUPERUSER_ID) - result = super(User, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + result = super(User, self).get_view(view_id, view_type, **options) # Due to using the SUPERUSER the result will contain action that the user may not have access too # here we filter out actions that requires special implicit rights to avoid having unusable actions # in the dropdown menu. - if toolbar and self.env.user != original_user: + if options.get('toolbar') and self.env.user != original_user: self = self.with_user(original_user.id) if not self.user_has_groups("base.group_erp_manager"): change_password_action = self.env.ref("base.change_password_wizard_action") diff --git a/addons/hr/tests/test_self_user_access.py b/addons/hr/tests/test_self_user_access.py index 539476e48b0..36213ff912a 100644 --- a/addons/hr/tests/test_self_user_access.py +++ b/addons/hr/tests/test_self_user_access.py @@ -3,6 +3,7 @@ from collections import OrderedDict from itertools import chain +from lxml import etree from odoo.addons.hr.tests.common import TestHrCommon from odoo.tests import new_test_user, tagged, Form @@ -20,8 +21,8 @@ class TestSelfAccessProfile(TestHrCommon): 'user_id': james.id, }) view = self.env.ref('hr.res_users_view_form_profile') - view_infos = james.fields_view_get(view_id=view.id) - fields = view_infos['fields'].keys() + view_infos = james.get_view(view.id) + fields = [el.get('name') for el in etree.fromstring(view_infos['arch']).xpath('//field[not(ancestor::field)]')] james.read(fields) def test_readonly_fields(self): @@ -35,12 +36,12 @@ class TestSelfAccessProfile(TestHrCommon): }) view = self.env.ref('hr.res_users_view_form_profile') - view_infos = james.fields_view_get(view_id=view.id) - + fields = james._fields + view_infos = james.get_view(view.id) employee_related_fields = { - field_name - for field_name, field_attrs in view_infos['fields'].items() - if field_attrs.get('related', (None,))[0] == 'employee_id' + el.get('name') + for el in etree.fromstring(view_infos['arch']).xpath('//field[not(ancestor::field)]') + if fields[el.get('name')].related and fields[el.get('name')].related.split('.')[0] == 'employee_id' } form = Form(james, view=view) @@ -65,16 +66,16 @@ class TestSelfAccessProfile(TestHrCommon): all_groups |= self.env.ref(xml_id.strip()) user_all_groups = new_test_user(self.env, groups='base.group_user', login='hel', name='God') user_all_groups.write({'groups_id': [(4, group.id, False) for group in all_groups]}) - view_infos = self.env['res.users'].with_user(user_all_groups).fields_view_get(view_id=view.id) - full_fields = view_infos['fields'] + view_infos = self.env['res.users'].with_user(user_all_groups).get_view(view.id) + full_fields = [el.get('name') for el in etree.fromstring(view_infos['arch']).xpath('//field[not(ancestor::field)]')] # Now check the view for a simple user user = new_test_user(self.env, login='gro', name='Grouillot') - view_infos = self.env['res.users'].with_user(user).fields_view_get(view_id=view.id) - fields = view_infos['fields'] + view_infos = self.env['res.users'].with_user(user).get_view(view.id) + fields = [el.get('name') for el in etree.fromstring(view_infos['arch']).xpath('//field[not(ancestor::field)]')] # Compare both - self.assertEqual(full_fields.keys(), fields.keys(), "View fields should not depend on user's groups") + self.assertEqual(full_fields, fields, "View fields should not depend on user's groups") def test_access_my_profile_toolbar(self): """ A simple user shouldn't have the possibilities to see the 'Change Password' action""" @@ -85,7 +86,7 @@ class TestSelfAccessProfile(TestHrCommon): 'user_id': james.id, }) view = self.env.ref('hr.res_users_view_form_profile') - available_actions = james.fields_view_get(view_id=view.id, toolbar=True)['toolbar']['action'] + available_actions = james.get_view(view.id, toolbar=True)['toolbar']['action'] change_password_action = self.env.ref("base.change_password_wizard_action") self.assertFalse(any(x['id'] == change_password_action.id for x in available_actions)) @@ -98,7 +99,7 @@ class TestSelfAccessProfile(TestHrCommon): 'user_id': john.id, }) view = self.env.ref('hr.res_users_view_form_profile') - available_actions = john.fields_view_get(view_id=view.id, toolbar=True)['toolbar']['action'] + available_actions = john.get_view(view.id, toolbar=True)['toolbar']['action'] self.assertTrue(any(x['id'] == change_password_action.id for x in available_actions)) diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py index de37dbbd150..0b2c0379a4a 100644 --- a/addons/hr_recruitment/models/hr_recruitment.py +++ b/addons/hr_recruitment/models/hr_recruitment.py @@ -52,14 +52,12 @@ class RecruitmentSource(models.Model): source.alias_id = self.env['mail.alias'].create(vals) @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - res = super().fields_view_get(view_id, view_type, toolbar, submenu) + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) if view_type == 'tree' and not bool(self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")): - arch = etree.fromstring(res['arch']) email = arch.xpath("//field[@name='email']")[0] email.getparent().remove(email) - res['arch'] = etree.tostring(arch, encoding='unicode') - return res + return arch, view class RecruitmentStage(models.Model): _name = "hr.recruitment.stage" @@ -437,10 +435,10 @@ class Applicant(models.Model): return nocontent_body % nocontent_values @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def get_view(self, view_id=None, view_type='form', **options): if view_type == 'form' and self.user_has_groups('hr_recruitment.group_hr_recruitment_interviewer'): view_id = self.env.ref('hr_recruitment.hr_applicant_view_form_interviewer').id - return super().fields_view_get(view_id, view_type, toolbar, submenu) + return super().get_view(view_id, view_type, **options) def _notify_compute_recipients(self, message, msg_vals): """ diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 8986c7f245f..197f4bd39b7 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -157,26 +157,26 @@ class AccountAnalyticLine(models.Model): return result @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def _get_view(self, view_id=None, view_type='form', **options): """ Set the correct label for `unit_amount`, depending on company UoM """ - result = super(AccountAnalyticLine, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) - result['arch'] = self._apply_timesheet_label(result['arch'], view_type=view_type) - return result + arch, view = super()._get_view(view_id, view_type, **options) + arch = self._apply_timesheet_label(arch, view_type=view_type) + return arch, view @api.model - def _apply_timesheet_label(self, view_arch, view_type='form'): - doc = etree.XML(view_arch) + def _apply_timesheet_label(self, view_node, view_type='form'): + doc = view_node encoding_uom = self.env.company.timesheet_encode_uom_id # Here, we select only the unit_amount field having no string set to give priority to # custom inheretied view stored in database. Even if normally, no xpath can be done on # 'string' attribute. for node in doc.xpath("//field[@name='unit_amount'][@widget='timesheet_uom'][not(@string)]"): node.set('string', _('%s Spent') % (re.sub(r'[\(\)]', '', encoding_uom.name or ''))) - return etree.tostring(doc, encoding='unicode') + return doc @api.model - def _apply_time_label(self, view_arch, related_model): - doc = etree.XML(view_arch) + def _apply_time_label(self, view_node, related_model): + doc = view_node Model = self.env[related_model] # Just fetch the name of the uom in `timesheet_encode_uom_id` of the current company encoding_uom_name = self.env.company.timesheet_encode_uom_id.with_context(prefetch_fields=False).sudo().name @@ -184,7 +184,7 @@ class AccountAnalyticLine(models.Model): name_with_uom = re.sub(_('Hours') + "|Hours", encoding_uom_name or '', Model._fields[node.get('name')]._description_string(self.env), flags=re.IGNORECASE) node.set('string', name_with_uom) - return etree.tostring(doc, encoding='unicode') + return doc def _timesheet_get_portal_domain(self): if self.env.user.has_group('hr_timesheet.group_hr_timesheet_user'): diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index edab491a5d1..ebb9037f2ee 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -81,11 +81,11 @@ class Project(models.Model): return [('id', operator_new, (query, ()))] @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - result = super()._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) if view_type in ['tree', 'form'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'): - result['arch'] = self.env['account.analytic.line']._apply_time_label(result['arch'], related_model=self._name) - return result + arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name) + return arch, view @api.depends('allow_timesheets', 'timesheet_ids') def _compute_remaining_hours(self): @@ -368,16 +368,16 @@ class Task(models.Model): return super().name_get() @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def _get_view(self, view_id=None, view_type='form', **options): """ Set the correct label for `unit_amount`, depending on company UoM """ - result = super(Task, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + arch, view = super()._get_view(view_id, view_type, **options) # Use of sudo as the portal user doesn't have access to uom - result['arch'] = self.env['account.analytic.line'].sudo()._apply_timesheet_label(result['arch']) + arch = self.env['account.analytic.line'].sudo()._apply_timesheet_label(arch) if view_type in ['tree', 'pivot', 'graph'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'): - result['arch'] = self.env['account.analytic.line']._apply_time_label(result['arch'], related_model=self._name) + arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name) - return result + return arch, view @api.ondelete(at_uninstall=False) def _unlink_except_contains_entries(self): diff --git a/addons/hr_timesheet/report/project_report.py b/addons/hr_timesheet/report/project_report.py index be4238b46db..fc40365952a 100644 --- a/addons/hr_timesheet/report/project_report.py +++ b/addons/hr_timesheet/report/project_report.py @@ -33,8 +33,8 @@ class ReportProjectTaskUser(models.Model): return super(ReportProjectTaskUser, self)._group_by() + group_by_append @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - result = super(ReportProjectTaskUser, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) if view_type in ['pivot', 'graph'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'): - result['arch'] = self.env['account.analytic.line']._apply_time_label(result['arch'], related_model=self._name) - return result + arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name) + return arch, view diff --git a/addons/mail/models/ir_ui_view.py b/addons/mail/models/ir_ui_view.py index 90fdea597b9..fb2a5552eaf 100644 --- a/addons/mail/models/ir_ui_view.py +++ b/addons/mail/models/ir_ui_view.py @@ -6,3 +6,10 @@ class View(models.Model): _inherit = 'ir.ui.view' type = fields.Selection(selection_add=[('activity', 'Activity')]) + + def _postprocess_tag_field(self, node, name_manager, node_info): + if node.xpath("ancestor::div[hasclass('oe_chatter')]"): + # Pass the postprocessing of the mail thread fields + # The web client makes it completely custom, and this is therefore pointless. + return + return super()._postprocess_tag_field(node, name_manager, node_info) diff --git a/addons/membership/models/product.py b/addons/membership/models/product.py index 6107ad3b3a0..b766df1240f 100644 --- a/addons/membership/models/product.py +++ b/addons/membership/models/product.py @@ -18,10 +18,10 @@ class Product(models.Model): ] @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def get_view(self, view_id=None, view_type='form', **options): if self._context.get('product') == 'membership_product': if view_type == 'form': view_id = self.env.ref('membership.membership_products_form').id else: view_id = self.env.ref('membership.membership_products_tree').id - return super(Product, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + return super().get_view(view_id, view_type, **options) diff --git a/addons/project/tests/test_project_sharing_portal_access.py b/addons/project/tests/test_project_sharing_portal_access.py index f7cfed90e29..e3cf97fc017 100644 --- a/addons/project/tests/test_project_sharing_portal_access.py +++ b/addons/project/tests/test_project_sharing_portal_access.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import OrderedDict +from lxml import etree from odoo import Command from odoo.exceptions import AccessError from odoo.tests import tagged @@ -49,10 +50,11 @@ class TestProjectSharingPortalAccess(TestProjectSharingCommon): def test_readonly_fields(self): """ The fields are not writeable should not be editable by the portal user. """ - view_infos = self.task_portal.fields_view_get(view_id=self.env.ref(self.project_sharing_form_view_xml_id).id) + view_infos = self.task_portal.get_view(self.env.ref(self.project_sharing_form_view_xml_id).id) + fields = [el.get('name') for el in etree.fromstring(view_infos['arch']).xpath('//field[not(ancestor::field)]')] project_task_fields = { field_name - for field_name, field_attrs in view_infos['fields'].items() + for field_name in fields if field_name not in self.write_protected_fields_task } with self.get_project_sharing_form_view(self.task_portal, self.user_portal) as form: diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index ee051576aba..bc78ba3efe5 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -428,35 +428,33 @@ class Product(models.Model): return res @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - res = super(Product, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + def fields_get(self, allfields=None, attributes=None): + res = super().fields_get(allfields, attributes) if self._context.get('location') and isinstance(self._context['location'], int): location = self.env['stock.location'].browse(self._context['location']) - fields = res.get('fields') - if fields: - if location.usage == 'supplier': - if fields.get('virtual_available'): - res['fields']['virtual_available']['string'] = _('Future Receipts') - if fields.get('qty_available'): - res['fields']['qty_available']['string'] = _('Received Qty') - elif location.usage == 'internal': - if fields.get('virtual_available'): - res['fields']['virtual_available']['string'] = _('Forecasted Quantity') - elif location.usage == 'customer': - if fields.get('virtual_available'): - res['fields']['virtual_available']['string'] = _('Future Deliveries') - if fields.get('qty_available'): - res['fields']['qty_available']['string'] = _('Delivered Qty') - elif location.usage == 'inventory': - if fields.get('virtual_available'): - res['fields']['virtual_available']['string'] = _('Future P&L') - if fields.get('qty_available'): - res['fields']['qty_available']['string'] = _('P&L Qty') - elif location.usage == 'production': - if fields.get('virtual_available'): - res['fields']['virtual_available']['string'] = _('Future Productions') - if fields.get('qty_available'): - res['fields']['qty_available']['string'] = _('Produced Qty') + if location.usage == 'supplier': + if res.get('virtual_available'): + res['virtual_available']['string'] = _('Future Receipts') + if res.get('qty_available'): + res['qty_available']['string'] = _('Received Qty') + elif location.usage == 'internal': + if res.get('virtual_available'): + res['virtual_available']['string'] = _('Forecasted Quantity') + elif location.usage == 'customer': + if res.get('virtual_available'): + res['virtual_available']['string'] = _('Future Deliveries') + if res.get('qty_available'): + res['qty_available']['string'] = _('Delivered Qty') + elif location.usage == 'inventory': + if res.get('virtual_available'): + res['virtual_available']['string'] = _('Future P&L') + if res.get('qty_available'): + res['qty_available']['string'] = _('P&L Qty') + elif location.usage == 'production': + if res.get('virtual_available'): + res['virtual_available']['string'] = _('Future Productions') + if res.get('qty_available'): + res['qty_available']['string'] = _('Produced Qty') return res def action_view_orderpoints(self): diff --git a/addons/web/models/models.py b/addons/web/models/models.py index d42e0f4c452..318768a1d91 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -237,15 +237,15 @@ class Base(models.AbstractModel): }) @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - r = super().fields_view_get(view_id, view_type, toolbar, submenu) + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) # avoid leaking the raw (un-rendered) template, also avoids bloating # the response payload for no reason. Only send the root node, # to send attributes such as `js_class`. - if r['type'] == 'qweb': - root = etree.fromstring(r['arch']) - r['arch'] = etree.tostring(etree.Element('qweb', root.attrib)) - return r + if view_type == 'qweb': + root = arch + arch = etree.Element('qweb', root.attrib) + return arch, view @api.model def _search_panel_field_image(self, field_name, **kwargs): diff --git a/addons/web/static/src/legacy/js/services/data_manager.js b/addons/web/static/src/legacy/js/services/data_manager.js index 0efcc2ecf2d..2a8fc5b425b 100644 --- a/addons/web/static/src/legacy/js/services/data_manager.js +++ b/addons/web/static/src/legacy/js/services/data_manager.js @@ -89,6 +89,9 @@ return core.Class.extend({ if (shouldLoadViews) { // Views info should be loaded options.load_filters = shouldLoadFilters; + if (config.device.isMobile) { + options.mobile = config.device.isMobile; + } this._cache.views[viewsKey] = rpc.query({ args: [], kwargs: { context, options, views: views_descr }, diff --git a/addons/web/static/src/views/view_service.js b/addons/web/static/src/views/view_service.js index cf8a0668551..423bc8b317c 100644 --- a/addons/web/static/src/views/view_service.js +++ b/addons/web/static/src/views/view_service.js @@ -1,6 +1,7 @@ /** @odoo-module **/ import { registry } from "@web/core/registry"; +import { device } from 'web.config'; /** * @typedef {Object} IrFilter @@ -65,14 +66,18 @@ export const viewService = { async function loadViews(params, options) { const key = JSON.stringify([params.resModel, params.views, params.context, options]); if (!cache[key]) { + var load_views_options = { + action_id: options.actionId || false, + load_filters: options.loadIrFilters || false, + toolbar: options.loadActionMenus || false, + } + if (device.isMobile){ + load_views_options.mobile = device.isMobile; + } cache[key] = orm .call(params.resModel, "load_views", [], { views: params.views, - options: { - action_id: options.actionId || false, - load_filters: options.loadIrFilters || false, - toolbar: options.loadActionMenus || false, - }, + options: load_views_options, context: params.context, }) .then((result) => { diff --git a/addons/web_editor/models/ir_qweb_fields.py b/addons/web_editor/models/ir_qweb_fields.py index d5a6b295c76..58e4d0e49a6 100644 --- a/addons/web_editor/models/ir_qweb_fields.py +++ b/addons/web_editor/models/ir_qweb_fields.py @@ -76,7 +76,7 @@ class IrQWeb(models.AbstractModel): key = el.attrib.pop('t-snippet') el.set('t-call', key) el.set('t-options', f"{{'snippet-key': {key!r}}}") - view = self.env['ir.ui.view']._get_view(key).sudo() + view = self.env['ir.ui.view']._get(key).sudo() name = view.name thumbnail = el.attrib.pop('t-thumbnail', "oe-thumbnail") div = '
' % ( diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index 00d61296f64..01aba7e14b9 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -409,7 +409,7 @@ class View(models.Model): def _render_template(self, template, values=None): """ Render the template. If website is enabled on request, then extend rendering context with website values. """ - view = self._get_view(template).sudo() + view = self._get(template).sudo() view._handle_visibility(do_raise=True) if values is None: values = {} diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 28b41bad262..db6c8c632d0 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -1090,7 +1090,7 @@ class Website(models.Model): def get_template(self, template): if isinstance(template, str) and '.' not in template: template = 'website.%s' % template - view = self.env['ir.ui.view']._get_view(template).sudo() + view = self.env['ir.ui.view']._get(template).sudo() if not view: raise NotFound return view diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index 2828595d525..15e1d4a67ed 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -218,7 +218,7 @@ class IrActionsActWindow(models.Model): @api.depends('res_model', 'search_view_id') def _compute_search_view(self): for act in self: - fvg = self.env[act.res_model].fields_view_get(act.search_view_id.id, 'search') + fvg = self.env[act.res_model].get_view(act.search_view_id.id, 'search') act.search_view = str(fvg) name = fields.Char(string='Action Name', translate=True) diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index 58af110837e..0e4a5e1cf75 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -157,8 +157,8 @@ class Module(models.Model): _order = 'application desc,sequence,name' @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - res = super(Module, self).fields_view_get(view_id, view_type, toolbar=toolbar, submenu=False) + def get_view(self, view_id=None, view_type='form', **options): + res = super().get_view(view_id, view_type, **options) if view_type == 'form' and res.get('toolbar',False): install_id = self.env.ref('base.action_server_module_immediate_install').id action = [rec for rec in res['toolbar']['action'] if rec.get('id', False) != install_id] diff --git a/odoo/addons/base/models/ir_qweb.py b/odoo/addons/base/models/ir_qweb.py index 76089bc01fb..bcb50b1888d 100644 --- a/odoo/addons/base/models/ir_qweb.py +++ b/odoo/addons/base/models/ir_qweb.py @@ -772,7 +772,7 @@ class IrQWeb(models.AbstractModel): :rtype: Tuple[Union[etree, str], Optional[str, int]] """ IrUIView = self.env['ir.ui.view'].sudo() - view = IrUIView._get_view(ref) + view = IrUIView._get(ref) template = IrUIView._read_template(view.id) etree_view = etree.fromstring(template) diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index 75a24ef78a6..385fbeb6f23 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -34,6 +34,23 @@ _logger = logging.getLogger(__name__) MOVABLE_BRANDING = ['data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-xpath', 'data-oe-source-id'] +ref_re = re.compile(r""" +# first match 'form_view_ref' key, backrefs are used to handle single or +# double quoting of the value +(['"])(?P\w+_view_ref)\1 +# colon separator (with optional spaces around) +\s*:\s* +# open quote for value +(['"]) +(?P + # we'll just match stuff which is normally part of an xid: + # word and "." characters + [.\w]+ +) +# close with same quote as opening +\3 +""", re.VERBOSE) + def quick_eval(expr, globals_dict): """ Functionally identical to safe_eval(), but optimized with special-casing. """ @@ -967,12 +984,26 @@ actual arch. del node.attrib['attrs'] # avoid making field visible later del node.attrib['groups'] + def _get_view_refs(self, node): + """ Extract the `[view_type]_view_ref` keys and values from the node context attribute, + giving the views to use for a field node. + + :param node: the field node as an etree + :return: a dictonary mapping the `[view_type]_view_ref` key to the xmlid of the view to use for that view type. + """ + if not node.get('context'): + return {} + return { + m.group('view_type'): m.group('view_id') + for m in ref_re.finditer(node.get('context')) + } + #------------------------------------------------------ # Postprocessing: translation, groups and modifiers #------------------------------------------------------ # TODO: remove group processing from ir_qweb #------------------------------------------------------ - def postprocess_and_fields(self, node, model=None): + def postprocess_and_fields(self, node, model=None, **options): """ Return an architecture and a description of all the fields. The field description combines the result of fields_get() and @@ -987,12 +1018,18 @@ actual arch. """ self and self.ensure_one() # self is at most one view - name_manager = self._postprocess_view(node, model or self.model) + name_manager = self._postprocess_view(node, model or self.model, **options) arch = etree.tostring(node, encoding="unicode").replace('\t', '') - return arch, dict(name_manager.available_fields) - def _postprocess_view(self, node, model_name, editable=True): + models = set() + name_managers = [name_manager] + for name_manager in name_managers: + models.add(name_manager.model._name) + name_managers.extend(name_manager.children) + return arch, models + + def _postprocess_view(self, node, model_name, editable=True, parent_name_manager=None, **options): """ Process the given architecture, modifying it in-place to add and remove stuff. @@ -1010,7 +1047,12 @@ actual arch. self._postprocess_on_change(root, model) - name_manager = NameManager(model) + name_manager = NameManager(model, parent=parent_name_manager) + + root_info = { + 'view_type': root.tag, + 'mobile': options.get('mobile'), + } # use a stack to recursively traverse the tree stack = [(root, editable)] @@ -1020,10 +1062,7 @@ actual arch. # compute default tag = node.tag parent = node.getparent() - node_info = { - 'modifiers': {}, - 'editable': editable and self._editable_node(node, name_manager), - } + node_info = dict(root_info, modifiers={}, editable=editable and self._editable_node(node, name_manager)) # tag-specific postprocessing postprocessor = getattr(self, f"_postprocess_tag_{tag}", None) @@ -1120,21 +1159,41 @@ actual arch. node.getparent().remove(node) # no point processing view-level ``groups`` anymore, return return - views = {} + if ( + node_info.get('view_type') == 'form' + and field.type in ('one2many', 'many2many') + and not node.get('widget') + and not node.get('invisible') + and not name_manager.parent + ): + # Embed kanban/tree/form views for visible x2many fields in form views + # if no widget or the widget requires it. + # So the web client doesn't have to call `get_views` for x2many fields not embedding their view + # in the main form view. + current_view_types = [el.tag for el in node.xpath("./*[descendant::field]")] + missing_view_types = [] + if 'form' not in current_view_types: + missing_view_types.append('form') + if not any(view_type in current_view_types for view_type in node.get('mode', 'kanban,tree').split(',')): + missing_view_types.append( + node.get('mode', 'kanban' if node_info.get('mobile') else 'tree').split(',')[0] + ) + if missing_view_types: + comodel = self.env[field.comodel_name].sudo(False) + refs = self._get_view_refs(node) + if refs: + comodel = comodel.with_context(**refs) + for view_type in missing_view_types: + subarch, _subview = comodel._get_view(view_type=view_type) + node.append(subarch) for child in node: if child.tag in ('form', 'tree', 'graph', 'kanban', 'calendar'): - node.remove(child) - sub_name_manager = self.with_context( + node_info['children'] = [] + self.with_context( base_model_name=name_manager.model._name, )._postprocess_view( - child, field.comodel_name, editable=node_info['editable'], + child, field.comodel_name, editable=node_info['editable'], parent_name_manager=name_manager, ) - xarch = etree.tostring(child, encoding="unicode").replace('\t', '') - views[child.tag] = { - 'arch': xarch, - 'fields': dict(sub_name_manager.available_fields), - } - attrs['views'] = views if field.type in ('many2one', 'many2many'): comodel = self.env[field.comodel_name].sudo(False) can_create = comodel.check_access_rights('create', raise_exception=False) @@ -1160,19 +1219,11 @@ actual arch. field = name_manager.model._fields.get(name) if not field or not field.comodel_name: return - # move all children nodes into a new node - groupby_node = E.groupby(*node) # post-process the node as a nested view, and associate it to the field - sub_name_manager = self.with_context( + self.with_context( base_model_name=name_manager.model._name, - )._postprocess_view(groupby_node, field.comodel_name, editable=False) - xarch = etree.tostring(groupby_node, encoding="unicode").replace('\t', '') - name_manager.has_field(name, {'views': { - 'groupby': { - 'arch': xarch, - 'fields': dict(sub_name_manager.available_fields), - } - }}) + )._postprocess_view(node, field.comodel_name, editable=False, parent_name_manager=name_manager) + name_manager.has_field(name) def _postprocess_tag_label(self, node, name_manager, node_info): if node.get('for'): @@ -1186,7 +1237,7 @@ actual arch. self.with_context( base_model_name=name_manager.model._name, )._postprocess_view( - searchpanel[0], name_manager.model._name, editable=False, + searchpanel[0], name_manager.model._name, editable=False, parent_name_manager=name_manager ) node_info['children'] = [child for child in node if child.tag != 'searchpanel'] @@ -1839,7 +1890,7 @@ actual arch. return res_id @api.model - def _get_view(self, view_ref): + def _get(self, view_ref): """ Return the view corresponding to ``view_ref``, which may be a view ID or an XML ID. """ @@ -1933,7 +1984,7 @@ actual arch. @api.model def render_public_asset(self, template, values=None): - template_sudo = self._get_view(template).sudo() + template_sudo = self._get(template).sudo() template_sudo._check_view_access() return self.env['ir.qweb'].sudo()._render(template, values) @@ -2129,7 +2180,7 @@ class ResetViewArchWizard(models.TransientModel): class NameManager: """ An object that manages all the named elements in a view. """ - def __init__(self, model): + def __init__(self, model, parent=None): self.model = model self.available_fields = collections.defaultdict(dict) # {field_name: field_info} self.available_actions = set() @@ -2137,6 +2188,10 @@ class NameManager: self.mandatory_fields = dict() # {field_name: use} self.mandatory_parent_fields = dict() # {field_name: use} self.mandatory_names = dict() # {name: use} + self.parent = parent + self.children = [] + if self.parent: + self.parent.children.append(self) @lazy_property def field_info(self): diff --git a/odoo/addons/base/models/res_config.py b/odoo/addons/base/models/res_config.py index f6c05de588d..6e848ce8b4e 100644 --- a/odoo/addons/base/models/res_config.py +++ b/odoo/addons/base/models/res_config.py @@ -384,29 +384,22 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin) raise UserError(_("Cannot duplicate configuration!")) @api.model - def fields_view_get(self, view_id=None, view_type='form', - toolbar=False, submenu=False): - ret_val = super(ResConfigSettings, self).fields_view_get( - view_id=view_id, view_type=view_type, - toolbar=toolbar, submenu=submenu) + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) can_install_modules = self.env['ir.module.module'].check_access_rights( 'write', raise_exception=False) - doc = etree.XML(ret_val['arch']) - - for field in ret_val['fields']: - if not field.startswith("module_"): + for node in arch.xpath("//field[@name]"): + if not node.get('name').startswith("module_"): continue - for node in doc.xpath("//field[@name='%s']" % field): - if not can_install_modules: - node.set("readonly", "1") - modifiers = json.loads(node.get("modifiers")) - modifiers['readonly'] = True - node.set("modifiers", json.dumps(modifiers)) + if not can_install_modules: + node.set("readonly", "1") + modifiers = json.loads(node.get("modifiers")) + modifiers['readonly'] = True + node.set("modifiers", json.dumps(modifiers)) - ret_val['arch'] = etree.tostring(doc, encoding='unicode') - return ret_val + return arch, view def onchange_module(self, field_value, module_name): module_sudo = self.env['ir.module.module']._get(module_name[7:]) diff --git a/odoo/addons/base/models/res_currency.py b/odoo/addons/base/models/res_currency.py index 3b4e8766b5b..bd39b6f51b5 100644 --- a/odoo/addons/base/models/res_currency.py +++ b/odoo/addons/base/models/res_currency.py @@ -285,18 +285,16 @@ class Currency(models.Model): """ @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - result = super(Currency, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) if view_type in ('tree', 'form'): currency_name = (self.env['res.company'].browse(self._context.get('company_id')) or self.env.company).currency_id.name - doc = etree.XML(result['arch']) for field in [['company_rate', _('Unit per %s', currency_name)], ['inverse_company_rate', _('%s per Unit', currency_name)]]: - node = doc.xpath("//tree//field[@name='%s']" % field[0]) + node = arch.xpath("//tree//field[@name='%s']" % field[0]) if node: node[0].set('string', field[1]) - result['arch'] = etree.tostring(doc, encoding='unicode') - return result + return arch, view class CurrencyRate(models.Model): @@ -417,18 +415,16 @@ class CurrencyRate(models.Model): return super()._name_search(parse_date(self.env, name), args, operator, limit, name_get_uid) @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - result = super(CurrencyRate, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) if view_type in ('tree'): names = { 'company_currency_name': (self.env['res.company'].browse(self._context.get('company_id')) or self.env.company).currency_id.name, 'rate_currency_name': self.env['res.currency'].browse(self._context.get('active_id')).name or 'Unit', } - doc = etree.XML(result['arch']) for field in [['company_rate', _('%(rate_currency_name)s per %(company_currency_name)s', **names)], ['inverse_company_rate', _('%(company_currency_name)s per %(rate_currency_name)s', **names)]]: - node = doc.xpath("//tree//field[@name='%s']" % field[0]) + node = arch.xpath("//tree//field[@name='%s']" % field[0]) if node: node[0].set('string', field[1]) - result['arch'] = etree.tostring(doc, encoding='unicode') - return result + return arch, view diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index bb8df0697fb..54bb10d51ac 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -44,26 +44,22 @@ class FormatAddressMixin(models.AbstractModel): _name = "format.address.mixin" _description = 'Address Format' - def _fields_view_get_address(self, arch): + def _view_get_address(self, arch): # consider the country of the user, not the country of the partner we want to display address_view_id = self.env.company.country_id.address_view_id.sudo() if address_view_id and not self._context.get('no_address_format') and (not address_view_id.model or address_view_id.model == self._name): #render the partner address accordingly to address_view_id - doc = etree.fromstring(arch) - for address_node in doc.xpath("//div[hasclass('o_address_format')]"): + for address_node in arch.xpath("//div[hasclass('o_address_format')]"): Partner = self.env['res.partner'].with_context(no_address_format=True) - sub_view = Partner.fields_view_get( - view_id=address_view_id.id, view_type='form', toolbar=False, submenu=False) - sub_view_node = etree.fromstring(sub_view['arch']) + sub_arch, _sub_view = Partner._get_view(address_view_id.id, 'form') #if the model is different than res.partner, there are chances that the view won't work #(e.g fields not present on the model). In that case we just return arch if self._name != 'res.partner': try: - self.env['ir.ui.view'].postprocess_and_fields(sub_view_node, model=self._name) + self.env['ir.ui.view'].postprocess_and_fields(sub_arch, model=self._name) except ValueError: return arch - address_node.getparent().replace(address_node, sub_view_node) - arch = etree.tostring(doc, encoding='unicode') + address_node.getparent().replace(address_node, sub_arch) return arch class PartnerCategory(models.Model): @@ -345,13 +341,13 @@ class Partner(models.Model): partner.commercial_company_name = p.is_company and p.name or partner.company_name @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def _get_view(self, view_id=None, view_type='form', **options): if (not view_id) and (view_type == 'form') and self._context.get('force_email'): view_id = self.env.ref('base.view_partner_simple_form').id - res = super(Partner, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + arch, view = super()._get_view(view_id, view_type, **options) if view_type == 'form': - res['arch'] = self._fields_view_get_address(res['arch']) - return res + arch = self._view_get_address(arch) + return arch, view @api.constrains('parent_id') def _check_parent_id(self): diff --git a/odoo/addons/base/tests/test_acl.py b/odoo/addons/base/tests/test_acl.py index 0f7f38f1c5e..9d2b8c120eb 100644 --- a/odoo/addons/base/tests/test_acl.py +++ b/odoo/addons/base/tests/test_acl.py @@ -48,7 +48,7 @@ class TestACL(TransactionCaseWithUserDemo): # Verify the test environment first original_fields = currency.fields_get([]) - form_view = currency.fields_view_get(False, 'form') + form_view = currency.get_view(False, 'form') view_arch = etree.fromstring(form_view.get('arch')) has_group_system = self.user_demo.has_group(GROUP_SYSTEM) self.assertFalse(has_group_system, "`demo` user should not belong to the restricted group before the test") @@ -62,7 +62,7 @@ class TestACL(TransactionCaseWithUserDemo): self._set_field_groups(currency, 'decimal_places', GROUP_SYSTEM) fields = currency.fields_get([]) - form_view = currency.fields_view_get(False, 'form') + form_view = currency.get_view(False, 'form') view_arch = etree.fromstring(form_view.get('arch')) self.assertNotIn('decimal_places', fields, "'decimal_places' field should be gone") self.assertEqual(view_arch.xpath("//field[@name='decimal_places']"), [], @@ -74,7 +74,7 @@ class TestACL(TransactionCaseWithUserDemo): self.erp_system_group.users += self.user_demo has_group_system = self.user_demo.has_group(GROUP_SYSTEM) fields = currency.fields_get([]) - form_view = currency.fields_view_get(False, 'form') + form_view = currency.get_view(False, 'form') view_arch = etree.fromstring(form_view.get('arch')) self.assertTrue(has_group_system, "`demo` user should now belong to the restricted group") self.assertIn('decimal_places', fields, "'decimal_places' field must be properly visible again") @@ -130,7 +130,7 @@ class TestACL(TransactionCaseWithUserDemo): """ Test form view Create, Edit, Delete button visibility based on access right of model""" methods = ['create', 'edit', 'delete'] company = self.env['res.company'].with_user(self.user_demo) - company_view = company.fields_view_get(False, 'form') + company_view = company.get_view(False, 'form') view_arch = etree.fromstring(company_view['arch']) for method in methods: self.assertEqual(view_arch.get(method), 'false') @@ -140,7 +140,7 @@ class TestACL(TransactionCaseWithUserDemo): self.erp_system_group.users += self.user_demo methods = ['create', 'edit', 'delete'] company = self.env['res.company'].with_user(self.user_demo) - company_view = company.fields_view_get(False, 'form') + company_view = company.get_view(False, 'form') view_arch = etree.fromstring(company_view['arch']) for method in methods: self.assertIsNone(view_arch.get(method)) @@ -149,7 +149,7 @@ class TestACL(TransactionCaseWithUserDemo): """ Test many2one field Create and Edit option visibility based on access rights of relation field""" methods = ['create', 'write'] company = self.env['res.company'].with_user(self.user_demo) - company_view = company.fields_view_get(False, 'form') + company_view = company.get_view(False, 'form') view_arch = etree.fromstring(company_view['arch']) field_node = view_arch.xpath("//field[@name='currency_id']") self.assertTrue(len(field_node), "currency_id field should be in company from view") @@ -161,7 +161,7 @@ class TestACL(TransactionCaseWithUserDemo): self.erp_system_group.users += self.user_demo methods = ['create', 'write'] company = self.env['res.company'].with_user(self.user_demo) - company_view = company.fields_view_get(False, 'form') + company_view = company.get_view(False, 'form') view_arch = etree.fromstring(company_view['arch']) field_node = view_arch.xpath("//field[@name='currency_id']") self.assertTrue(len(field_node), "currency_id field should be in company from view") diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py index a8586eeaaa1..2f50c1faac6 100644 --- a/odoo/addons/base/tests/test_translate.py +++ b/odoo/addons/base/tests/test_translate.py @@ -638,8 +638,8 @@ class TestTranslationWrite(TransactionCase): self.assertEqual(fg['state']['selection'], [('manual', 'Custo'), ('base', 'Pas touche!')]) - def test_fields_view_get(self): - """ Test translations of field descriptions in fields_view_get(). """ + def test_load_views(self): + """ Test translations of field descriptions in get_view(). """ self.env['res.lang']._activate_lang('fr_FR') # add translation for the string of field ir.model.name @@ -659,9 +659,9 @@ class TestTranslationWrite(TransactionCase): info = model.fields_get(['name']) self.assertEqual(info['name']['string'], LABEL) - # check that fields_view_get() also returns the expected label - info = model.fields_view_get()['fields'] - self.assertEqual(info['name']['string'], LABEL) + # check that get_views() also returns the expected label + info = model.get_views([(False, 'form')]) + self.assertEqual(info['models'][model._name]['name']['string'], LABEL) class TestXMLTranslation(TransactionCase): diff --git a/odoo/addons/base/tests/test_views.py b/odoo/addons/base/tests/test_views.py index b0d6f2ce91a..74bd758eafa 100644 --- a/odoo/addons/base/tests/test_views.py +++ b/odoo/addons/base/tests/test_views.py @@ -1256,9 +1256,7 @@ class TestViews(ViewCase): """ }) - view = self.View.with_context(check_view_ids=[view2.id, view3.id]) \ - .fields_view_get(view2.id, view_type='form') - self.assertEqual(view['type'], 'form') + view = self.View.with_context(check_view_ids=[view2.id, view3.id]).get_view(view2.id, 'form') self.assertEqual( etree.fromstring( view['arch'], @@ -1285,8 +1283,7 @@ class TestViews(ViewCase): 'inherit_id': view1.id, 'arch': '
a

b

c

', }) - view = self.View.with_context(check_view_ids=view2.ids).fields_view_get(view1.id) - self.assertEqual(view['type'], 'form') + view = self.View.with_context(check_view_ids=view2.ids).get_view(view1.id) self.assertEqual( view['arch'], '
(
a

b

c

)
', @@ -1305,8 +1302,7 @@ class TestViews(ViewCase): 'inherit_id': view1.id, 'arch': '
a

b

c

', }) - view = self.View.with_context(check_view_ids=view2.ids).fields_view_get(view1.id) - self.assertEqual(view['type'], 'form') + view = self.View.with_context(check_view_ids=view2.ids).get_view(view1.id) self.assertEqual( view['arch'], '
(
a

b

c)

', @@ -1325,8 +1321,7 @@ class TestViews(ViewCase): 'inherit_id': view1.id, 'arch': '
a

b

c

', }) - view = self.View.with_context(check_view_ids=view2.ids).fields_view_get(view1.id) - self.assertEqual(view['type'], 'form') + view = self.View.with_context(check_view_ids=view2.ids).get_view(view1.id) self.assertEqual( view['arch'], '
(a

b

c

)', @@ -1379,8 +1374,7 @@ class TestViews(ViewCase): }) view = self.View.with_context(check_view_ids=[view2.id, view3.id]) \ - .fields_view_get(view2.id, view_type='form') - self.assertEqual(view['type'], 'form') + .get_view(view2.id, view_type='form') self.assertEqual( etree.fromstring( view['arch'], @@ -2330,13 +2324,13 @@ class TestViews(ViewCase): }) # default view, no address_view defined - arch = self.env['res.partner'].fields_view_get(view_id=partner_view.id)['arch'] + arch = self.env['res.partner'].get_view(partner_view.id)['arch'] self.assertIn('"street"', arch) self.assertNotIn('"parent_name"', arch) # custom view, address_view defined self.env.company.country_id.address_view_id = address_view - arch = self.env['res.partner'].fields_view_get(view_id=partner_view.id)['arch'] + arch = self.env['res.partner'].get_view(partner_view.id)['arch'] self.assertNotIn('"street"', arch) self.assertIn('"parent_name"', arch) # weird result:
inside a @@ -3110,12 +3104,12 @@ class TestAccessRights(common.TransactionCase): with self.assertRaises(AccessError): self.env['ir.ui.view'].search([("model", '=', "res.partner"), ('type', '=', 'form')]) - # but can call fields_view_get - self.env['res.partner'].fields_view_get(view_type='form') + # but can call view_get + self.env['res.partner'].get_view(view_type='form') # unless he does not have access to the model with self.assertRaises(AccessError): - self.env['ir.ui.view'].fields_view_get(view_type='form') + self.env['ir.ui.view'].get_view(view_type='form') @common.tagged('post_install', '-at_install', '-standard', 'migration') class TestAllViews(common.TransactionCase): @@ -3142,7 +3136,7 @@ class TestRenderAllViews(common.TransactionCase): for _ in range(5): model.invalidate_cache() before = time.perf_counter() - model.fields_view_get() + model.get_view() times.append(time.perf_counter() - before) count += 1 elapsed += min(times) diff --git a/odoo/addons/base/wizard/base_module_upgrade.py b/odoo/addons/base/wizard/base_module_upgrade.py index 5f0552bcdfd..cc8a93c5424 100644 --- a/odoo/addons/base/wizard/base_module_upgrade.py +++ b/odoo/addons/base/wizard/base_module_upgrade.py @@ -23,8 +23,8 @@ class BaseModuleUpgrade(models.TransientModel): module_info = fields.Text('Apps to Update', readonly=True, default=_default_module_info) @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - res = super(BaseModuleUpgrade, self).fields_view_get(view_id, view_type, toolbar=toolbar,submenu=False) + def get_view(self, view_id=None, view_type='form', **options): + res = super().get_view(view_id, view_type, **options) if view_type != 'form': return res diff --git a/odoo/addons/test_action_bindings/tests/test_bindings.py b/odoo/addons/test_action_bindings/tests/test_bindings.py index 21be64c27bf..cb9ba4561ed 100644 --- a/odoo/addons/test_action_bindings/tests/test_bindings.py +++ b/odoo/addons/test_action_bindings/tests/test_bindings.py @@ -53,19 +53,19 @@ class TestBindingViewFilters(common.TransactionCase): def test_act_window(self): A = self.env['tab.a'] - form_act = A.fields_view_get(toolbar=True)['toolbar']['action'] + form_act = A.get_view(toolbar=True)['toolbar']['action'] self.assertEqual( [a['name'] for a in form_act], ['Action 1', 'Action 2', 'Action 3'], "forms should have all actions") - list_act = A.fields_view_get(view_type='tree', toolbar=True)['toolbar']['action'] + list_act = A.get_view(view_type='tree', toolbar=True)['toolbar']['action'] self.assertEqual( [a['name'] for a in list_act], ['Action 1', 'Action 3'], "lists should not have the form-only action") - kanban_act = A.fields_view_get(view_type='kanban', toolbar=True)['toolbar']['action'] + kanban_act = A.get_view(view_type='kanban', toolbar=True)['toolbar']['action'] self.assertEqual( [a['name'] for a in kanban_act], ['Action 1'], @@ -74,19 +74,19 @@ class TestBindingViewFilters(common.TransactionCase): def test_act_record(self): B = self.env['tab.b'] - form_act = B.fields_view_get(toolbar=True)['toolbar']['action'] + form_act = B.get_view(toolbar=True)['toolbar']['action'] self.assertEqual( [a['name'] for a in form_act], ['Record 1', 'Record 2', 'Record 3'], "forms should have all actions") - list_act = B.fields_view_get(view_type='tree', toolbar=True)['toolbar']['action'] + list_act = B.get_view(view_type='tree', toolbar=True)['toolbar']['action'] self.assertEqual( [a['name'] for a in list_act], ['Record 1', 'Record 3'], "lists should not have the form-only action") - kanban_act = B.fields_view_get(view_type='kanban', toolbar=True)['toolbar']['action'] + kanban_act = B.get_view(view_type='kanban', toolbar=True)['toolbar']['action'] self.assertEqual( [a['name'] for a in kanban_act], ['Record 1'], diff --git a/odoo/addons/test_new_api/tests/test_onchange.py b/odoo/addons/test_new_api/tests/test_onchange.py index 472ff6de6d0..51939ddb15e 100644 --- a/odoo/addons/test_new_api/tests/test_onchange.py +++ b/odoo/addons/test_new_api/tests/test_onchange.py @@ -424,9 +424,7 @@ class TestOnChange(SavepointCaseWithUserDemo): # mimic UI behaviour, so we get subfields # (we need at least subfield: 'important_emails.important') - view_info = self.Discussion.fields_view_get( - view_id=self.env.ref('test_new_api.discussion_form').id, - view_type='form') + view_info = self.Discussion.get_view(self.env.ref('test_new_api.discussion_form').id, 'form') field_onchange = self.Discussion._onchange_spec(view_info=view_info) self.assertEqual(field_onchange.get('messages'), '1') diff --git a/odoo/addons/test_new_api/views/test_new_api_views.xml b/odoo/addons/test_new_api/views/test_new_api_views.xml index 56e6a773b28..36ee073f0c3 100644 --- a/odoo/addons/test_new_api/views/test_new_api_views.xml +++ b/odoo/addons/test_new_api/views/test_new_api_views.xml @@ -93,7 +93,7 @@ - + diff --git a/odoo/addons/test_testing_utilities/tests/test_form_impl.py b/odoo/addons/test_testing_utilities/tests/test_form_impl.py index 6251cb1943e..d440e6ee40b 100644 --- a/odoo/addons/test_testing_utilities/tests/test_form_impl.py +++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py @@ -6,6 +6,7 @@ complete) intended for properly validating business "view" flows (onchanges, readonly, required, ...) and make it easier to generate sensible & coherent business objects. """ +from lxml import etree from operator import itemgetter from odoo.tests.common import TransactionCase, Form @@ -324,19 +325,18 @@ class TestO2M(TransactionCase): delegating to a separate form view """ f = Form(self.env['test_testing_utilities.parent'], view='test_testing_utilities.o2m_parent_ed') - custom_tree = self.env.ref('test_testing_utilities.editable_external').id + custom_tree = self.env.ref('test_testing_utilities.editable_external') - subs_field = f._view['fields']['subs'] - tree_view = subs_field['views']['tree'] - self.assertEqual(tree_view['type'], 'tree') self.assertEqual( - tree_view['view_id'], custom_tree, + [el.get('name') for el in f._view['tree'].xpath('//field[@name="subs"]/tree//field')], + [el.get('name') for el in etree.fromstring(custom_tree['arch']).xpath('//field')], 'check that the tree view is the one referenced by tree_view_ref' ) - self.assertIs(subs_field['views']['edition'], tree_view, "check that the edition view is the tree view") + subs_field = f._view['fields']['subs'] + self.assertIs(subs_field['edition_view']['tree'], f._view['tree'].xpath('//field[@name="subs"]/tree')[0], "check that the edition view is the tree view") self.assertEqual( - subs_field['views']['edition']['view_id'], - custom_tree + [el.get('name') for el in subs_field['edition_view']['tree'].xpath('.//field')], + [el.get('name') for el in etree.fromstring(custom_tree['arch']).xpath('//field')], ) with f.subs.new() as s: diff --git a/odoo/models.py b/odoo/models.py index 7f470c29dbc..ab16f1c40f7 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -1428,7 +1428,7 @@ class BaseModel(metaclass=MetaModel): @api.model def fields_get_keys(self): warnings.warn( - 'fields_get_keys() method is deprecated, use `_fields` or `fields_view_get` instead', + 'fields_get_keys() method is deprecated, use `_fields` or `get_views` instead', DeprecationWarning ) return list(self._fields) @@ -1606,7 +1606,7 @@ class BaseModel(metaclass=MetaModel): return view @api.model - def load_views(self, views, options=None): + def get_views(self, views, options=None): """ Returns the fields_views of given views, along with the fields of the current model, and optionally its filters for the given action. @@ -1626,27 +1626,40 @@ class BaseModel(metaclass=MetaModel): options = options or {} result = {} - toolbar = options.get('toolbar') - result['fields_views'] = { - v_type: self.fields_view_get(v_id, v_type if v_type != 'list' else 'tree', - toolbar=toolbar if v_type != 'search' else False) + result['views'] = { + v_type: self.get_view( + v_id, v_type if v_type != 'list' else 'tree', + **options + ) for [v_id, v_type] in views } - result['fields'] = self.fields_get() - - if options.get('load_filters'): - result['filters'] = self.env['ir.filters'].get_filters(self._name, options.get('action_id')) + models = set(model for info in result['views'].values() for model in info.pop('models')) + result['models'] = {model: self.env[model].fields_get() for model in models} return result @api.model - def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + def _get_view(self, view_id=None, view_type='form', **options): + """_get_view([view_id | view_type='form']) + + Get the model view combined architecture (the view along all its inheriting views). + + :param int view_id: id of the view or None + :param str view_type: type of the view to return if view_id is None ('form', 'tree', ...) + :param dict options: bool options to return additional features: + - bool load_filters: returns the model's filters (for search views) + - bool mobile: true if the web client is currently using the responsive mobile view + (to use kanban views instead of list views for x2many fields) + - bool toolbar: true to include contextual actions + :return: architecture of the view as an etree node, and the browse record of the view used + :rtype: tuple + :raise AttributeError: + + * if no view exists for that model, and no method `_get_default_[view_type]_view` exists for the view type + + """ View = self.env['ir.ui.view'].sudo() - result = { - 'model': self._name, - 'field_parent': False, - } # try to find a view_id if none provided if not view_id: @@ -1654,6 +1667,11 @@ class BaseModel(metaclass=MetaModel): view_ref_key = view_type + '_view_ref' view_ref = self._context.get(view_ref_key) if view_ref: + # Do not propagate _view_ref + # so these context keys are not used when fetching the subviews of one2many/many2many fields + context = dict(self._context) + context.pop(view_ref_key) + View = View.with_context(context) if '.' in view_ref: module, view_ref = view_ref.split('.', 1) query = "SELECT res_id FROM ir_model_data WHERE model='ir.ui.view' AND module=%s AND name=%s" @@ -1673,33 +1691,29 @@ class BaseModel(metaclass=MetaModel): if view_id: # read the view with inherited views applied view = View.browse(view_id) - result['arch'] = view.get_combined_arch() - result['name'] = view.name - result['type'] = view.type - result['view_id'] = view.id - result['field_parent'] = view.field_parent - result['base_model'] = view.model + arch = view._get_combined_arch() else: # fallback on default views methods if no ir.ui.view could be found + view = View.browse() try: - arch_etree = getattr(self, '_get_default_%s_view' % view_type)() - result['arch'] = etree.tostring(arch_etree, encoding='unicode') - result['type'] = view_type - result['name'] = 'default' + arch = getattr(self, '_get_default_%s_view' % view_type)() except AttributeError: raise UserError(_("No default view of type '%s' could be found !", view_type)) - return result + return arch, view @api.model - def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): - """ fields_view_get([view_id | view_type='form']) + def get_view(self, view_id=None, view_type='form', **options): + """ get_view([view_id | view_type='form']) - Get the detailed composition of the requested view like fields, model, view architecture + Get the detailed composition of the requested view like model, view architecture :param int view_id: id of the view or None :param str view_type: type of the view to return if view_id is None ('form', 'tree', ...) - :param bool toolbar: true to include contextual actions - :param submenu: deprecated + :param dict options: bool options to return additional features: + - bool load_filters: returns the model's filters (for search views) + - bool mobile: true if the web client is currently using the responsive mobile view + (to use kanban views instead of list views for x2many fields) + - bool toolbar: true to include contextual actions :return: composition of the requested view (including inherited views and extensions) :rtype: dict :raise AttributeError: @@ -1710,22 +1724,28 @@ class BaseModel(metaclass=MetaModel): :raise Invalid ArchitectureError: if there is view type other than form, tree, calendar, search etc... defined on the structure """ self.check_access_rights('read') - view = self.env['ir.ui.view'].sudo().browse(view_id) # Get the view arch and all other attributes describing the composition of the view - result = self._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + arch, view = self._get_view(view_id, view_type, **options) # Override context for postprocessing - if view_id and result.get('base_model', self._name) != self._name: - view = view.with_context(base_model_name=result['base_model']) + if view and (view.model or self._name) != self._name: + view = view.with_context(base_model_name=view.model) # Apply post processing, groups and modifiers etc... - xarch, xfields = view.postprocess_and_fields(etree.fromstring(result['arch']), model=self._name) - result['arch'] = xarch - result['fields'] = xfields + arch, models = view.postprocess_and_fields(arch, model=self._name, **options) + result = { + 'arch': arch, + # TODO: only `web_studio` seems to require this. I guess this is acceptable to keep it. + 'id': view.id, + # TODO: only `web_studio` seems to require this. But this one on the other hand should be eliminated: + # you just called `get_views` for that model, so obviously the web client already knows the model. + 'model': self._name, + 'models': models, + } # Add related action information if asked - if toolbar: + if options.get('toolbar') and view_type != 'search': vt = 'list' if view_type == 'tree' else view_type bindings = self.env['ir.actions.actions'].get_bindings(self._name) resreport = [action @@ -1739,6 +1759,56 @@ class BaseModel(metaclass=MetaModel): 'print': resreport, 'action': resaction, } + + if options.get('load_filters') and view_type == 'search': + result['filters'] = self.env['ir.filters'].get_filters(self._name, options.get('action_id')) + + return result + + @api.model + def load_views(self, views, options=None): + warnings.warn('`load_views` method is deprecated, use `get_views` instead', DeprecationWarning) + return self.get_views(views, options=options) + + @api.model + def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + warnings.warn('Method `_fields_view_get` is deprecated, use `_get_view` instead', DeprecationWarning) + arch, view = self._get_view(view_id, view_type, toolbar=toolbar, submenu=submenu) + result = { + 'arch': etree.tostring(arch, encoding='unicode'), + 'model': self._name, + 'field_parent': False, + } + if view: + result['name'] = view.name + result['type'] = view.type + result['view_id'] = view.id + result['field_parent'] = view.field_parent + result['base_model'] = view.model + else: + result['type'] = view_type + result['name'] = 'default' + return result + + @api.model + def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + warnings.warn('Method `fields_view_get` is deprecated, use `get_view` instead', DeprecationWarning) + result = self.get_view(view_id, view_type, toolbar=toolbar, submenu=submenu) + node = etree.fromstring(result['arch']) + view_fields = set(el.get('name') for el in node.xpath('.//field[not(ancestor::field)]')) + result['fields'] = self.fields_get(view_fields) + result.pop('models', None) + if 'id' in result: + view = self.env['ir.ui.view'].sudo(result.pop('id')) + result['name'] = view.name + result['type'] = view.type + result['view_id'] = view.id + result['field_parent'] = view.field_parent + result['base_model'] = view.model + else: + result['type'] = view_type + result['name'] = 'default' + result['field_parent'] = False return result def get_formview_id(self, access_uid=None): @@ -6223,7 +6293,7 @@ Fields: @api.model def _onchange_spec(self, view_info=None): """ Return the onchange spec from a view description; if not given, the - result of ``self.fields_view_get()`` is used. + result of ``self.get_view()`` is used. """ result = {} @@ -6235,14 +6305,14 @@ Fields: if not result.get(names): result[names] = node.attrib.get('on_change') # traverse the subviews included in relational fields - for subinfo in info['fields'][name].get('views', {}).values(): - process(etree.fromstring(subinfo['arch']), subinfo, names) + for child_view in node.xpath("./*[descendant::field]"): + process(child_view, None, names) else: for child in node: process(child, info, prefix) if view_info is None: - view_info = self.fields_view_get() + view_info = self.get_view() process(etree.fromstring(view_info['arch']), view_info, '') return result diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 83ee1479e05..f2bb653cf1e 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -1766,24 +1766,7 @@ def can_import(module): else: return True -# TODO: sub-views (o2m, m2m) -> sub-form? -# TODO: domains -ref_re = re.compile(r""" -# first match 'form_view_ref' key, backrefs are used to handle single or -# double quoting of the value -(['"])(?P\w+_view_ref)\1 -# colon separator (with optional spaces around) -\s*:\s* -# open quote for value -(['"]) -(?P - # we'll just match stuff which is normally part of an xid: - # word and "." characters - [.\w]+ -) -# close with same quote as opening -\3 -""", re.VERBOSE) + class Form(object): """ Server-side form view implementation (partial) @@ -1871,8 +1854,9 @@ class Form(object): view_id = env.ref(view).id else: view_id = view or False - fvg = recordp.fields_view_get(view_id, 'form') + fvg = recordp.get_view(view_id, 'form') fvg['tree'] = etree.fromstring(fvg['arch']) + fvg['fields'] = self._get_view_fields(fvg['tree'], recordp) object.__setattr__(self, '_view', fvg) @@ -1891,37 +1875,40 @@ class Form(object): else: self._init_from_defaults(self._model) + def _get_view_fields(self, node, model): + level = node.xpath('count(ancestor::field)') + fnames = set(el.get('name') for el in node.xpath('.//field[count(ancestor::field) = %s]' % level)) + fields = {fname: info for fname, info in model.fields_get().items() if fname in fnames} + return fields + def _o2m_set_edition_view(self, descr, node, level): default_view = next( (m for m in node.get('mode', 'tree').split(',') if m != 'form'), 'tree' ) - refs = { - m.group('view_type'): m.group('view_id') - for m in ref_re.finditer(node.get('context', '')) - } + refs = self._env['ir.ui.view']._get_view_refs(node) # always fetch for simplicity, ensure we always have a tree and # a form view submodel = self._env[descr['relation']] - views = submodel.with_context(**refs) \ - .load_views([(False, 'tree'), (False, 'form')])['fields_views'] - # embedded views should take the priority on externals - views.update(descr['views']) - # re-set all resolved views on the descriptor - descr['views'] = views + views = {view.tag: view for view in node.xpath('./*[descendant::field]')} + for view_type in ['tree', 'form']: + # embedded views should take the priority on externals + if view_type not in views: + sub_fvg = submodel.with_context(**refs).get_view(view_type=view_type) + sub_node = etree.fromstring(sub_fvg['arch']) + views[view_type] = sub_node + node.append(sub_node) # if the default view is a kanban or a non-editable list, the # "edition controller" is the form view - edition = views['form'] - edition['tree'] = etree.fromstring(edition['arch']) - if default_view == 'tree': - subarch = etree.fromstring(views['tree']['arch']) - if subarch.get('editable'): - edition = views['tree'] - edition['tree'] = subarch + edition_view = 'tree' if default_view == 'tree' and views['tree'].get('editable') else 'form' + edition = { + 'fields': self._get_view_fields(views[edition_view], submodel), + 'tree': views[edition_view], + } # don't recursively process o2ms in o2ms self._process_fvg(submodel, edition, level=level-1) - descr['views']['edition'] = edition + descr['edition_view'] = edition def __str__(self): return "<%s %s(%s)>" % ( @@ -1931,8 +1918,7 @@ class Form(object): ) def _process_fvg(self, model, fvg, level=2): - """ Post-processes to augment the fields_view_get with: - + """ Post-processes to augment the view_get with: * an id field (may not be present if not in the view but needed) * pre-processed modifiers (map of modifier name to json-loaded domain) * pre-processed onchanges list @@ -1942,7 +1928,7 @@ class Form(object): modifiers = fvg['modifiers'] = {'id': {'required': False, 'readonly': True}} contexts = fvg['contexts'] = {} order = fvg['fields_ordered'] = [] - for f in fvg['tree'].xpath('//field[not(ancestor::field)]'): + for f in fvg['tree'].xpath('.//field[count(ancestor::field) = %s]' % fvg['tree'].xpath('count(ancestor::field)')): fname = f.get('name') order.append(fname) @@ -1962,7 +1948,7 @@ class Form(object): if level and descr['type'] == 'one2many': self._o2m_set_edition_view(descr, f, level) - fvg['onchange'] = model._onchange_spec(fvg) + fvg['onchange'] = model._onchange_spec({'arch': etree.tostring(fvg['tree'])}) def _init_from_defaults(self, model): vals = self._values @@ -2207,7 +2193,7 @@ class Form(object): continue if descr['type'] == 'one2many': - subview = descr['views']['edition'] + subview = descr['edition_view'] fields_ = subview['fields'] oldvals = v v = [] @@ -2293,7 +2279,7 @@ class Form(object): values = {} for k, v in record.items(): if fields[k]['type'] == 'one2many': - subfields = fields[k]['views']['edition']['fields'] + subfields = fields[k]['edition_view']['fields'] it = values[k] = [] for (c, rid, vs) in v: if c == 1 and isinstance(vs, UpdateDict): @@ -2317,7 +2303,7 @@ class Form(object): return value[0] elif descr['type'] == 'one2many': # ignore o2ms nested in o2ms - if not descr['views']: + if not descr['edition_view']: return [] if current is None: @@ -2326,7 +2312,7 @@ class Form(object): c = {t[1] for t in current if t[0] in (1, 2)} current_values = {c[1]: c[2] for c in current if c[0] == 1} # which view should this be??? - subfields = descr['views']['edition']['fields'] + subfields = descr['edition_view']['fields'] # TODO: simplistic, unlikely to work if e.g. there's a 5 inbetween other commands for command in value: if command[0] == 0: @@ -2402,7 +2388,7 @@ class O2MForm(Form): object.__setattr__(self, '_model', m) # copy so we don't risk breaking it too much (?) - fvg = dict(proxy._descr['views']['edition']) + fvg = dict(proxy._descr['edition_view']) object.__setattr__(self, '_view', fvg) self._process_fvg(m, fvg) @@ -2503,7 +2489,7 @@ class O2MProxy(X2MProxy): # reify records to a list so they can be manipulated easily? self._records = [] model = self._model - fields = self._descr['views']['edition']['fields'] + fields = self._descr['edition_view']['fields'] for (command, rid, values) in self._parent._values[self._field]: if command == 0: self._records.append(values) diff --git a/odoo/tools/test_reports.py b/odoo/tools/test_reports.py index 06ceccea33e..0938e666744 100644 --- a/odoo/tools/test_reports.py +++ b/odoo/tools/test_reports.py @@ -10,6 +10,7 @@ import logging import os import tempfile +from lxml import etree from subprocess import Popen, PIPE from .. import api @@ -170,21 +171,24 @@ def try_report_action(cr, uid, action_id, active_model=None, active_ids=None, log_test("will emulate a %s view: %s#%s", view_type, datas['res_model'], view_id or '?') - view_res = env[datas['res_model']].fields_view_get(view_id, view_type=view_type) + model = env[datas['res_model']] + view_res = model.get_view(view_id, view_type) assert view_res and view_res.get('arch'), "Did not return any arch for the view" view_data = {} - if view_res.get('fields'): - view_data = env[datas['res_model']].default_get(list(view_res['fields'])) + arch = etree.fromstring(view_res['arch']) + fields = [el.get('name') for el in arch.xpath('//field[not(ancestor::field)]')] + if fields: + view_data = model.default_get(fields) if datas.get('form'): view_data.update(datas.get('form')) if wiz_data: view_data.update(wiz_data) _logger.debug("View data is: %r", view_data) - for fk, field in view_res.get('fields',{}).items(): + for fk in fields: # Default fields returns list of int, while at create() # we need to send a [(6,0,[int,..])] - if field['type'] in ('one2many', 'many2many') \ + if model._fields[fk].type in ('one2many', 'many2many') \ and view_data.get(fk, False) \ and isinstance(view_data[fk], list) \ and not isinstance(view_data[fk][0], tuple) : From f573e93bb2e75c1b7b69ede1bc587a75d0047802 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Wed, 23 Mar 2022 07:46:46 +0000 Subject: [PATCH 2/6] [REF] web,mail: adapt code to new load_views API Part-of: odoo/odoo#87522 --- addons/board/static/tests/dashboard_tests.js | 8 +- addons/mail/__manifest__.py | 1 + .../test_mail/static/tests/activity_tests.js | 2 +- addons/web/__manifest__.py | 1 + .../src/legacy/js/services/data_manager.js | 8 +- .../static/src/legacy/legacy_load_views.js | 116 +++++ addons/web/static/src/legacy/legacy_views.js | 1 + .../static/src/search/search_arch_parser.js | 4 +- addons/web/static/src/search/search_model.js | 19 +- addons/web/static/src/views/view.js | 15 +- addons/web/static/src/views/view_service.js | 74 +-- .../web/static/tests/helpers/mock_server.js | 453 ++++++++++-------- .../field_many2many_tests.js | 8 +- .../relational_fields/field_many2one_tests.js | 8 +- .../relational_fields/field_one2many_tests.js | 6 +- .../tests/legacy/helpers/mock_server.js | 51 +- .../static/tests/legacy/helpers/test_utils.js | 4 +- .../tests/legacy/helpers/test_utils_create.js | 10 +- .../tests/legacy/helpers/test_utils_mock.js | 31 +- .../legacy/services/data_manager_tests.js | 29 +- .../tests/legacy/views/basic_model_tests.js | 2 +- .../tests/legacy/views/calendar_tests.js | 2 +- .../static/tests/legacy/views/form_tests.js | 16 +- .../static/tests/legacy/views/kanban_tests.js | 12 +- .../static/tests/search/search_panel_tests.js | 6 +- .../static/tests/search/with_search_tests.js | 14 +- addons/web/static/tests/views/helpers.js | 7 +- .../static/tests/views/pivot_view_tests.js | 2 +- .../static/tests/views/view_service_tests.js | 27 +- addons/web/static/tests/views/view_tests.js | 4 +- .../webclient/actions/concurrency_tests.js | 44 +- .../webclient/actions/load_state_tests.js | 28 +- .../tests/webclient/actions/misc_tests.js | 4 +- .../webclient/actions/server_action_tests.js | 4 +- .../tests/webclient/actions/target_tests.js | 8 +- .../webclient/actions/window_action_tests.js | 40 +- addons/web/static/tests/webclient/helpers.js | 7 +- .../base/static/tests/base_settings_tests.js | 4 +- 38 files changed, 636 insertions(+), 444 deletions(-) create mode 100644 addons/web/static/src/legacy/legacy_load_views.js diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 4e62f5adab7..43a0d72e9d8 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -365,7 +365,7 @@ odoo.define("board.dashboard_tests", function (require) { ], }); } - if (args.method === "load_views") { + if (args.method === "get_views") { assert.deepEqual( args.kwargs.context, { a: 1, b: 2 }, @@ -656,8 +656,8 @@ odoo.define("board.dashboard_tests", function (require) { "" + "", intercepts: { - load_views: function () { - throw new Error("load_views should not be called"); + get_views: function () { + throw new Error("get_views should not be called"); }, }, mockRPC: function (route) { @@ -1048,7 +1048,7 @@ odoo.define("board.dashboard_tests", function (require) { "" + "", mockRPC: function (route, args) { - if (args.method === "load_views") { + if (args.method === "get_views") { assert.deepEqual( pyUtils.eval("context", args.kwargs.context), { lang: "fr_FR" }, diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index cab0cf777ef..c3d31734f01 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -99,6 +99,7 @@ 'web/static/src/legacy/js/services/data_manager.js', 'web/static/src/legacy/js/services/session.js', 'web/static/src/legacy/js/widgets/date_picker.js', + 'web/static/src/legacy/legacy_load_views.js', 'web/static/src/legacy/legacy_promise_error_handler.js', 'web/static/src/legacy/legacy_rpc_error_handler.js', 'web/static/src/legacy/utils.js', diff --git a/addons/test_mail/static/tests/activity_tests.js b/addons/test_mail/static/tests/activity_tests.js index 02fb895f1f2..5bad8e284f1 100644 --- a/addons/test_mail/static/tests/activity_tests.js +++ b/addons/test_mail/static/tests/activity_tests.js @@ -562,7 +562,7 @@ QUnit.test("Schedule activity dialog uses the same search view as activity view" }); function mockRPC(route, args) { - if (args.method === "load_views") { + if (args.method === "get_views") { assert.step(JSON.stringify(args.kwargs.views)); } } diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 9e87df8b27b..342045a3f85 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -145,6 +145,7 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/legacy_service_provider.js', 'web/static/src/legacy/legacy_client_actions.js', 'web/static/src/legacy/legacy_dialog.js', + 'web/static/src/legacy/legacy_load_views.js', 'web/static/src/legacy/legacy_views.js', 'web/static/src/legacy/legacy_promise_error_handler.js', 'web/static/src/legacy/legacy_rpc_error_handler.js', diff --git a/addons/web/static/src/legacy/js/services/data_manager.js b/addons/web/static/src/legacy/js/services/data_manager.js index 2a8fc5b425b..bf7c2b82a56 100644 --- a/addons/web/static/src/legacy/js/services/data_manager.js +++ b/addons/web/static/src/legacy/js/services/data_manager.js @@ -5,7 +5,7 @@ var config = require('web.config'); var core = require('web.core'); var rpc = require('web.rpc'); var session = require('web.session'); -var utils = require('web.utils'); +const { generateLegacyLoadViewsResult } = require("@web/legacy/legacy_load_views"); return core.Class.extend({ init: function () { @@ -96,11 +96,13 @@ return core.Class.extend({ args: [], kwargs: { context, options, views: views_descr }, model, - method: 'load_views', + method: 'get_views', }).then(result => { + const { models, views } = result; + result = generateLegacyLoadViewsResult(model, views, models); // Freeze the fields dict as it will be shared between views and // no one should edit it - utils.deepFreeze(result.fields); + // utils.deepFreeze(result.fields); // OWL issue regarding proxies and frozen objects https://github.com/odoo/owl/issues/1158 for (const [ /* viewId */ , viewType] of views_descr) { const fvg = result.fields_views[viewType]; fvg.viewFields = fvg.fields; diff --git a/addons/web/static/src/legacy/legacy_load_views.js b/addons/web/static/src/legacy/legacy_load_views.js new file mode 100644 index 00000000000..fa50c822799 --- /dev/null +++ b/addons/web/static/src/legacy/legacy_load_views.js @@ -0,0 +1,116 @@ +/** @odoo-module **/ + +const xmlSerializer = new XMLSerializer(); +const domParser = new DOMParser(); + +function traverse(tree, f) { + if (f(tree)) { + _.each(tree.children, function (c) { + traverse(c, f); + }); + } +} + +/** + * Given the result of a get_views, produces the equivalent of what was previously + * returned, such that legacy views continue working as before without any adaptations. + * + * @param {string} resModel + * @param {Object} views "views" key of the result of a get_views + * @param {Object} models maps of model name -> fields_get + * @returns {Object} similar to what was previously returned by load_views + */ +export function generateLegacyLoadViewsResult(resModel, views, models) { + const legacyFieldViews = { + fields: models[resModel], + fields_views: {}, + }; + if (views.search && views.search.filters) { + legacyFieldViews.filters = views.search.filters; + } + for (const viewType in views) { + const { arch, toolbar, id } = views[viewType]; + const { arch: processedArch, viewFields } = processArch(arch, viewType, resModel, models); + legacyFieldViews.fields_views[viewType] = { + arch: processedArch, + fields: viewFields, + model: resModel, + toolbar, + view_id: id, + }; + } + return legacyFieldViews; +} + +/** + * Given an arch with inline x2many subviews, generates the object of fields + * appearing in the arch, with a "views" key that contains the descriptions of + * their subviews (whose archs are removed from the main arch). + * + * @param {string} arch + * @param {string} viewType + * @param {string} resModel + * @param {Object} models + * @returns {Object} the processed arch and the (view) fields description + */ +export function processArch(arch, viewType, resModel, models) { + const viewFields = {}; + const archDoc = domParser.parseFromString(arch, "text/xml").documentElement; + traverse(archDoc, function (node) { + if (node.nodeType === 3) { + return false; + } + if (node.tagName === "field") { + const fieldName = node.getAttribute("name"); + viewFields[fieldName] = viewFields[fieldName] || { + ...models[resModel][fieldName], + views: {}, + }; + // extract subviews + // note: this should only be done for form views, because x2many subviews doesn't make + // any sense in any other view type, but sometimes in the codebase, people inline + // subviews in list views (e.g. sale.order.form.inherit.sale.product.configurator) so + // we remove them from the main arch anyway, to mock the previous behavior + if (viewType === "form" || viewType === "list") { + const coModel = models[resModel][fieldName].relation; + const views = {}; + for (const childNode of [...node.children]) { + const viewType = childNode.tagName === "tree" ? "list" : childNode.tagName; + const { arch: subViewArch, viewFields: subViewFields } = processArch( + xmlSerializer.serializeToString(childNode), + viewType, + coModel, + models + ); + views[viewType] = { arch: subViewArch, fields: subViewFields }; + node.removeChild(childNode); + } + viewFields[fieldName].views = views; + } + return false; + } + // list: extract groupby informations + if (viewType === "list" && node.tagName === "groupby" && node !== archDoc) { + const fieldName = node.getAttribute("name"); + const coModel = models[resModel][fieldName].relation; + const { arch: subViewArch, viewFields: subViewFields } = processArch( + xmlSerializer.serializeToString(node), + "groupby", + coModel, + models + ); + const groupbyView = { arch: subViewArch, fields: subViewFields }; + viewFields[fieldName] = viewFields[fieldName] || { + ...models[resModel][fieldName], + views: {}, + }; + viewFields[fieldName].views.groupby = groupbyView; + for (const child of [...node.children]) { + node.removeChild(child); + } + return false; + } + return true; + }); + return { arch: xmlSerializer.serializeToString(archDoc), viewFields }; +} diff --git a/addons/web/static/src/legacy/legacy_views.js b/addons/web/static/src/legacy/legacy_views.js index e2f0e4d5994..b8c167c87e6 100644 --- a/addons/web/static/src/legacy/legacy_views.js +++ b/addons/web/static/src/legacy/legacy_views.js @@ -116,6 +116,7 @@ function registerView(name, LegacyView) { this.viewInfo = Object.assign({}, fieldsInfo, { fields: result.fields, viewFields: fieldsInfo.fields, + type: this.props.type, }); let controlPanelFieldsView; if (result.fields_views.search) { diff --git a/addons/web/static/src/search/search_arch_parser.js b/addons/web/static/src/search/search_arch_parser.js index 9e71ba747f4..09bdb370079 100644 --- a/addons/web/static/src/search/search_arch_parser.js +++ b/addons/web/static/src/search/search_arch_parser.js @@ -36,10 +36,10 @@ function reduceType(type) { } export class SearchArchParser extends XMLParser { - constructor(searchViewDescription, searchDefaults = {}, searchPanelDefaults = {}) { + constructor(searchViewDescription, fields, searchDefaults = {}, searchPanelDefaults = {}) { super(); - const { fields, irFilters, arch } = searchViewDescription; + const { irFilters, arch } = searchViewDescription; this.fields = fields || {}; this.irFilters = irFilters || []; diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js index f03724152f4..b8995f199c2 100644 --- a/addons/web/static/src/search/search_model.js +++ b/addons/web/static/src/search/search_model.js @@ -229,7 +229,7 @@ export class SearchModel extends EventBus { const searchViewDescription = {}; if (loadSearchView) { - const viewDescriptions = await this.viewService.loadViews( + const result = await this.viewService.loadViews( { context: this.globalContext, resModel, @@ -240,23 +240,20 @@ export class SearchModel extends EventBus { loadIrFilters: loadIrFilters || false, } ); - Object.assign(searchViewDescription, viewDescriptions.search); + Object.assign(searchViewDescription, result.views.search); + searchViewFields = searchViewFields || result.fields; } if (searchViewArch) { searchViewDescription.arch = searchViewArch; } - if (searchViewFields) { - searchViewDescription.fields = searchViewFields; - } if (irFilters) { searchViewDescription.irFilters = irFilters; } if (searchViewId !== undefined) { searchViewDescription.viewId = searchViewId; } - this.searchViewArch = searchViewDescription.arch || ""; - this.searchViewFields = searchViewDescription.fields || {}; + this.searchViewFields = searchViewFields || {}; if (searchViewDescription.irFilters) { this.irFilters = searchViewDescription.irFilters; } @@ -266,7 +263,7 @@ export class SearchModel extends EventBus { if (config.state) { this._importState(config.state); - this.__legacyParseSearchPanelArchAnyway(searchViewDescription); + this.__legacyParseSearchPanelArchAnyway(searchViewDescription, searchViewFields); this.domainParts = {}; this.display = this._getDisplay(config.display); @@ -307,6 +304,7 @@ export class SearchModel extends EventBus { const parser = new SearchArchParser( searchViewDescription, + searchViewFields, searchDefaults, searchPanelDefaults ); @@ -2084,13 +2082,14 @@ export class SearchModel extends EventBus { * extension doesn't include the arch information, i.e. the class name and * view types. We have to extract those if they are not given. * @param {Object} searchViewDescription + * @param {Object} searchViewFields */ - __legacyParseSearchPanelArchAnyway(searchViewDescription) { + __legacyParseSearchPanelArchAnyway(searchViewDescription, searchViewFields) { if (this.searchPanelInfo) { return; } - const parser = new SearchArchParser(searchViewDescription); + const parser = new SearchArchParser(searchViewDescription, searchViewFields); const { searchPanelInfo } = parser.parse(); this.searchPanelInfo = { ...searchPanelInfo, shouldReload: false }; diff --git a/addons/web/static/src/views/view.js b/addons/web/static/src/views/view.js index 453e75385a2..f860389e43f 100644 --- a/addons/web/static/src/views/view.js +++ b/addons/web/static/src/views/view.js @@ -199,32 +199,31 @@ export class View extends Component { if (loadView || loadSearchView) { // view description (or search view description if required) is incomplete // a loadViews is done to complete the missing information - const viewDescriptions = await this.viewService.loadViews( + const result = await this.viewService.loadViews( { context, resModel, views }, { actionId: this.env.config.actionId, loadActionMenus, loadIrFilters } ); // Note: if this.props.views is different from views, the cached descriptions // will certainly not be reused! (but for the standard flow this will work as // before) - viewDescription = viewDescriptions[type]; - view[0] = viewDescription.viewId; - searchViewDescription = viewDescriptions.search; + viewDescription = result.views[type]; + searchViewDescription = result.views.search; if (loadSearchView) { - searchViewId = searchViewId || searchViewDescription.viewId; + searchViewId = searchViewId || searchViewDescription.id; if (!searchViewArch) { searchViewArch = searchViewDescription.arch; - searchViewFields = searchViewDescription.fields; + searchViewFields = result.fields; } if (!irFilters) { irFilters = searchViewDescription.irFilters; } } this.env.config.views = views; + fields = fields || result.fields; } if (!arch) { arch = viewDescription.arch; - fields = viewDescription.fields; } if (!actionMenus) { actionMenus = viewDescription.actionMenus; @@ -244,7 +243,7 @@ export class View extends Component { } Object.assign(this.env.config, { - viewId: viewDescription.viewId, + viewId: viewDescription.id, viewType: type, viewSubType: subType, bannerRoute, diff --git a/addons/web/static/src/views/view_service.js b/addons/web/static/src/views/view_service.js index 423bc8b317c..b25e26bb7f5 100644 --- a/addons/web/static/src/views/view_service.js +++ b/addons/web/static/src/views/view_service.js @@ -1,7 +1,8 @@ /** @odoo-module **/ +import { deepCopy } from "@web/core/utils/objects"; import { registry } from "@web/core/registry"; -import { device } from 'web.config'; +import { generateLegacyLoadViewsResult } from "@web/legacy/legacy_load_views"; /** * @typedef {Object} IrFilter @@ -64,47 +65,48 @@ export const viewService = { * @returns {Promise} */ async function loadViews(params, options) { - const key = JSON.stringify([params.resModel, params.views, params.context, options]); + const loadViewsOptions = { + action_id: options.actionId || false, + load_filters: options.loadIrFilters || false, + toolbar: options.loadActionMenus || false, + }; + if (env.isSmall) { + loadViewsOptions.mobile = true; + } + const { context, resModel, views } = params; + const key = JSON.stringify([resModel, views, context, loadViewsOptions]); if (!cache[key]) { - var load_views_options = { - action_id: options.actionId || false, - load_filters: options.loadIrFilters || false, - toolbar: options.loadActionMenus || false, - } - if (device.isMobile){ - load_views_options.mobile = device.isMobile; - } cache[key] = orm - .call(params.resModel, "load_views", [], { - views: params.views, - options: load_views_options, - context: params.context, - }) + .call(resModel, "get_views", [], { context, views, options: loadViewsOptions }) .then((result) => { + const { models, views } = result; + const modelsCopy = deepCopy(models); // for legacy views + const fields = models[resModel]; + // add relatedFields for relational fields in view + function setRelatedFields(fields, models) { + for (const field of Object.values(fields)) { + if (field.relation && !field.relatedFields) { + field.relatedFields = models[field.relation] || {}; + setRelatedFields(field.relatedFields, models); + } + } + } + setRelatedFields(models[resModel], models); const viewDescriptions = { - __legacy__: result, - }; // for legacy purpose, keys in result are left in viewDescriptions - for (const [, viewType] of params.views) { - const viewDescription = JSON.parse( - JSON.stringify(result.fields_views[viewType]) - ); - viewDescription.viewId = viewDescription.view_id; - delete viewDescription.view_id; - if (viewDescription.toolbar) { - viewDescription.actionMenus = viewDescription.toolbar; - delete viewDescription.toolbar; + __legacy__: generateLegacyLoadViewsResult(resModel, views, modelsCopy), + fields, + views: {}, + }; + for (const viewType in views) { + const { arch, toolbar, id, filters } = views[viewType]; + const viewDescription = { arch, id }; + if (toolbar) { + viewDescription.actionMenus = toolbar; } - viewDescription.fields = Object.assign( - {}, - result.fields, - viewDescription.fields - ); // before a deep freeze was done. - delete viewDescription.base_model; // unused - delete viewDescription.field_parent; // unused - if (viewType === "search" && options.loadIrFilters) { - viewDescription.irFilters = result.filters; + if (filters) { + viewDescription.irFilters = filters; } - viewDescriptions[viewType] = viewDescription; + viewDescriptions.views[viewType] = viewDescription; } return viewDescriptions; }) diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index f3689d437e9..15be09d7c87 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -3,6 +3,8 @@ import { browser } from "@web/core/browser/browser"; import { Domain } from "@web/core/domain"; import { evaluateExpr } from "@web/core/py_js/py"; +import { deepCopy } from "@web/core/utils/objects"; +import { intersection } from "@web/core/utils/arrays"; import { registry } from "@web/core/registry"; import { makeFakeRPCService, makeMockFetch } from "./mock_services"; import { patchWithCleanup } from "./utils"; @@ -11,6 +13,9 @@ import { parseDateTime } from "@web/core/l10n/dates"; const { DateTime } = luxon; const serviceRegistry = registry.category("services"); +const domParser = new DOMParser(); +const xmlSerializer = new XMLSerializer(); + // ----------------------------------------------------------------------------- // Utils // ----------------------------------------------------------------------------- @@ -21,197 +26,6 @@ function traverseElementTree(tree, cb) { } } -export function _fieldsViewGet(params) { - let processedNodes = params.processedNodes || []; - const { arch, context, fields, modelName } = params; - function isNodeProcessed(node) { - return processedNodes.findIndex((n) => n.isSameNode(node)) > -1; - } - const modifiersNames = ["invisible", "readonly", "required"]; - const onchanges = params.models[modelName].onchanges || {}; - const fieldNodes = {}; - const groupbyNodes = {}; - let doc; - if (typeof arch === "string") { - const domParser = new DOMParser(); - doc = domParser.parseFromString(arch, "text/xml").documentElement; - } else { - doc = arch; - } - const inTreeView = doc.tagName === "tree"; - // mock _postprocess_access_rights - const isBaseModel = !context.base_model_name || modelName === context.base_model_name; - const views = ["kanban", "tree", "form", "gantt", "activity"]; - if (isBaseModel && views.indexOf(doc.tagName) !== -1) { - for (const action of ["create", "delete", "edit", "write"]) { - if (!doc.getAttribute(action) && action in context && !context[action]) { - doc.setAttribute(action, "false"); - } - } - } - - traverseElementTree(doc, (node) => { - if (node.nodeType === Node.TEXT_NODE) { - return false; - } - const modifiers = {}; - const isField = node.tagName === "field"; - const isGroupby = node.tagName === "groupby"; - if (isField) { - const fieldName = node.getAttribute("name"); - fieldNodes[fieldName] = node; - // 'transfer_field_to_modifiers' simulation - const field = fields[fieldName]; - if (!field) { - throw new Error("Field " + fieldName + " does not exist"); - } - const defaultValues = {}; - const stateExceptions = {}; // what is this ? - modifiersNames.forEach((attr) => { - stateExceptions[attr] = []; - defaultValues[attr] = !!field[attr]; - }); - // LPE: what is this ? - /* _.each(field.states || {}, function (modifs, state) { - _.each(modifs, function (modif) { - if (defaultValues[modif[0]] !== modif[1]) { - stateExceptions[modif[0]].append(state); - } - }); - });*/ - Object.entries(defaultValues).forEach(([attr, defaultValue]) => { - if (stateExceptions[attr].length) { - modifiers[attr] = [ - ["state", defaultValue ? "not in" : "in", stateExceptions[attr]], - ]; - } else { - modifiers[attr] = defaultValue; - } - }); - } else if (isGroupby && !isNodeProcessed(node)) { - const groupbyName = node.getAttribute("name"); - fieldNodes[groupbyName] = node; - groupbyNodes[groupbyName] = node; - } - // 'transfer_node_to_modifiers' simulation - let attrs = node.getAttribute("attrs"); - if (attrs) { - attrs = evaluateExpr(attrs); - Object.assign(modifiers, attrs); - } - const states = node.getAttribute("states"); - if (states) { - if (!modifiers.invisible) { - modifiers.invisible = []; - } - modifiers.invisible.push(["state", "not in", states.split(",")]); - } - const inListHeader = inTreeView && node.closest("header"); - modifiersNames.forEach((attr) => { - const mod = node.getAttribute(attr); - if (mod) { - // TODO - // const pyevalContext = window.py.dict.fromJSON(context || {}); - // var v = pyUtils.py_eval(mod, {context: pyevalContext}) ? true : false; - console.info( - "MockServer: naive parse of modifier value in", - QUnit.config.current.testName - ); - const v = JSON.parse(mod); - if (inTreeView && !inListHeader && attr === "invisible") { - modifiers.column_invisible = v; - } else if (v || !(attr in modifiers) || !Array.isArray(modifiers[attr])) { - modifiers[attr] = v; - } - } - }); - modifiersNames.forEach((attr) => { - if ( - attr in modifiers && - (!!modifiers[attr] === false || - (Array.isArray(modifiers[attr]) && !modifiers[attr].length)) - ) { - delete modifiers[attr]; - } - }); - if (Object.keys(modifiers).length) { - node.setAttribute("modifiers", JSON.stringify(modifiers)); - } - if (isGroupby && !isNodeProcessed(node)) { - return false; - } - return !isField; - }); - let relModel, relFields; - Object.entries(fieldNodes).forEach(([name, node]) => { - const field = fields[name]; - if (field.type === "many2one" || field.type === "many2many") { - const canCreate = node.getAttribute("can_create"); - node.setAttribute("can_create", canCreate || "true"); - const canWrite = node.getAttribute("can_write"); - node.setAttribute("can_write", canWrite || "true"); - } - if (field.type === "one2many" || field.type === "many2many") { - field.views = {}; - Array.from(node.children).forEach((children) => { - if (children.tagName) { - // skip text nodes - relModel = field.relation; - relFields = Object.assign({}, params.models[relModel].fields); - field.views[children.tagName] = _fieldsViewGet({ - models: params.models, - arch: children, - modelName: relModel, - fields: relFields, - context: Object.assign({}, context, { base_model_name: modelName }), - processedNodes, - }); - } - }); - } - // add onchanges - if (name in onchanges) { - node.setAttribute("on_change", "1"); - } - }); - Object.entries(groupbyNodes).forEach(([name, node]) => { - const field = fields[name]; - if (field.type !== "many2one") { - throw new Error("groupby can only target many2one"); - } - field.views = {}; - relModel = field.relation; - relFields = Object.assign({}, params.models[relModel].fields); - processedNodes.push(node); - // postprocess simulation - field.views.groupby = _fieldsViewGet({ - models: params.models, - arch: node, - modelName: relModel, - fields: relFields, - context, - processedNodes, - }); - while (node.firstChild) { - node.removeChild(node.firstChild); - } - }); - const xmlSerializer = new XMLSerializer(); - const processedArch = xmlSerializer.serializeToString(doc); - const fieldsInView = {}; - Object.entries(fields).forEach(([fname, field]) => { - if (fname in fieldNodes) { - fieldsInView[fname] = field; - } - }); - return { - arch: processedArch, - fields: fieldsInView, - model: modelName, - type: doc.tagName === "tree" ? "list" : doc.tagName, - }; -} - // ----------------------------------------------------------------------------- // MockServer // ----------------------------------------------------------------------------- @@ -298,7 +112,7 @@ export class MockServer { // def.abort = abort; } - fieldsViewGet(modelName, args, kwargs) { + getView(modelName, args, kwargs) { if (!(modelName in this.models)) { throw new Error(`Model ${modelName} was not defined in mock server data`); } @@ -331,7 +145,7 @@ export class MockServer { fields[fieldName].name = fieldName; } // var viewOptions = params.viewOptions || {}; - const fvg = _fieldsViewGet({ + const view = this._getView({ arch, modelName, fields, @@ -339,13 +153,231 @@ export class MockServer { models: this.models, }); if (kwargs.options.toolbar) { - fvg.toolbar = this.models[modelName].toolbar || {}; + view.toolbar = this.models[modelName].toolbar || {}; } if (viewId !== undefined) { - fvg.view_id = viewId; - fvg.name = key; + view.id = viewId; } - return fvg; + return view; + } + + _getView(params) { + let processedNodes = params.processedNodes || []; + const { arch, context, modelName } = params; + const level = params.level || 0; + const fields = deepCopy(params.fields); + function isNodeProcessed(node) { + return processedNodes.findIndex((n) => n.isSameNode(node)) > -1; + } + const modifiersNames = ["invisible", "readonly", "required"]; + const onchanges = params.models[modelName].onchanges || {}; + const fieldNodes = {}; + const groupbyNodes = {}; + const relatedModels = new Set([modelName]); + let doc; + if (typeof arch === "string") { + doc = domParser.parseFromString(arch, "text/xml").documentElement; + } else { + doc = arch; + } + const inTreeView = doc.tagName === "tree"; + const inFormView = doc.tagName === "form"; + // mock _postprocess_access_rights + const isBaseModel = !context.base_model_name || modelName === context.base_model_name; + const views = ["kanban", "tree", "form", "gantt", "activity"]; + if (isBaseModel && views.indexOf(doc.tagName) !== -1) { + for (const action of ["create", "delete", "edit", "write"]) { + if (!doc.getAttribute(action) && action in context && !context[action]) { + doc.setAttribute(action, "false"); + } + } + } + + traverseElementTree(doc, (node) => { + if (node.nodeType === Node.TEXT_NODE) { + return false; + } + const modifiers = {}; + const isField = node.tagName === "field"; + const isGroupby = node.tagName === "groupby"; + if (isField) { + const fieldName = node.getAttribute("name"); + fieldNodes[fieldName] = node; + // 'transfer_field_to_modifiers' simulation + const field = fields[fieldName]; + if (!field) { + throw new Error("Field " + fieldName + " does not exist"); + } + const defaultValues = {}; + const stateExceptions = {}; // what is this ? + modifiersNames.forEach((attr) => { + stateExceptions[attr] = []; + defaultValues[attr] = !!field[attr]; + }); + // LPE: what is this ? + /* _.each(field.states || {}, function (modifs, state) { + _.each(modifs, function (modif) { + if (defaultValues[modif[0]] !== modif[1]) { + stateExceptions[modif[0]].append(state); + } + }); + });*/ + Object.entries(defaultValues).forEach(([attr, defaultValue]) => { + if (stateExceptions[attr].length) { + modifiers[attr] = [ + ["state", defaultValue ? "not in" : "in", stateExceptions[attr]], + ]; + } else { + modifiers[attr] = defaultValue; + } + }); + } else if (isGroupby && !isNodeProcessed(node)) { + const groupbyName = node.getAttribute("name"); + fieldNodes[groupbyName] = node; + groupbyNodes[groupbyName] = node; + } + // 'transfer_node_to_modifiers' simulation + let attrs = node.getAttribute("attrs"); + if (attrs) { + attrs = evaluateExpr(attrs); + Object.assign(modifiers, attrs); + } + const states = node.getAttribute("states"); + if (states) { + if (!modifiers.invisible) { + modifiers.invisible = []; + } + modifiers.invisible.push(["state", "not in", states.split(",")]); + } + const inListHeader = inTreeView && node.closest("header"); + modifiersNames.forEach((attr) => { + const mod = node.getAttribute(attr); + if (mod) { + const v = evaluateExpr(mod, { context }) ? true : false; + if (inTreeView && !inListHeader && attr === "invisible") { + modifiers.column_invisible = v; + } else if (v || !(attr in modifiers) || !Array.isArray(modifiers[attr])) { + modifiers[attr] = v; + } + } + }); + modifiersNames.forEach((attr) => { + if ( + attr in modifiers && + (!!modifiers[attr] === false || + (Array.isArray(modifiers[attr]) && !modifiers[attr].length)) + ) { + delete modifiers[attr]; + } + }); + if (Object.keys(modifiers).length) { + node.setAttribute("modifiers", JSON.stringify(modifiers)); + } + if (isGroupby && !isNodeProcessed(node)) { + return false; + } + return !isField; + }); + let relModel, relFields; + Object.entries(fieldNodes).forEach(([name, node]) => { + const field = fields[name]; + if (field.type === "many2one" || field.type === "many2many") { + const canCreate = node.getAttribute("can_create"); + node.setAttribute("can_create", canCreate || "true"); + const canWrite = node.getAttribute("can_write"); + node.setAttribute("can_write", canWrite || "true"); + } + if (field.type === "one2many" || field.type === "many2many") { + relModel = field.relation; + relatedModels.add(relModel); + // inline subviews: in forms if field is visible and has no widget (1st level only) + if ( + inFormView && + level === 0 && + !node.getAttribute("widget") && + !node.getAttribute("invisible") + ) { + const inlineViewTypes = Array.from(node.children).map((c) => c.tagName); + const missingViewtypes = inlineViewTypes.includes("form") ? [] : ["form"]; + const mode = node.getAttribute("mode") || "kanban,tree"; + if (!intersection(inlineViewTypes, mode.split(",")).length) { + // TODO: use a kanban view by default in mobile + missingViewtypes.push((node.getAttribute("mode") || "list").split(",")[0]); + } + for (let type of missingViewtypes) { + type = type === "tree" ? "list" : type; + let key = `${field.relation},false,${type}`; + if (!this.archs[key]) { + const regexp = new RegExp(`${field.relation},[a-z._0-9]+,${type}`); + key = Object.keys(this.archs).find((k) => regexp.test(k)); + } + // in a lot of tests, we don't need the form view, so it doesn't even exist + const arch = this.archs[key] || (type === "form" ? "
" : null); + if (!arch) { + throw new Error(`Can't find ${type} view to inline`); + } + node.appendChild( + domParser.parseFromString(arch, "text/xml").documentElement + ); + } + } + Array.from(node.children).forEach((childNode) => { + if (childNode.tagName) { + relFields = Object.assign({}, params.models[relModel].fields); + // this is hackhish, but _getView modifies the subview document in place, + // especially to generate the "modifiers" attribute + const { models } = this._getView({ + models: params.models, + arch: childNode, + modelName: relModel, + fields: relFields, + context, + processedNodes, + level: level + 1, + }); + [...models].forEach((modelName) => relatedModels.add(modelName)); + } + }); + } + // add onchanges + if (name in onchanges) { + node.setAttribute("on_change", "1"); + } + }); + Object.entries(groupbyNodes).forEach(([name, node]) => { + const field = fields[name]; + if (field.type !== "many2one") { + throw new Error("groupby can only target many2one"); + } + field.views = {}; + relModel = field.relation; + relatedModels.add(relModel); + relFields = Object.assign({}, params.models[relModel].fields); + processedNodes.push(node); + // postprocess simulation + const { models } = this._getView({ + models: params.models, + arch: node, + modelName: relModel, + fields: relFields, + context, + processedNodes, + }); + [...models].forEach((modelName) => relatedModels.add(modelName)); + }); + const processedArch = xmlSerializer.serializeToString(doc); + const fieldsInView = {}; + Object.entries(fields).forEach(([fname, field]) => { + if (fname in fieldNodes) { + fieldsInView[fname] = field; + } + }); + return { + arch: processedArch, + model: modelName, + type: doc.tagName === "tree" ? "list" : doc.tagName, + models: relatedModels, + }; } /** @@ -419,8 +451,8 @@ export class MockServer { return this.mockCreate(args.model, args.args[0]); case "fields_get": return this.mockFieldsGet(args.model); - case "load_views": - return this.mockLoadViews(args.model, args.kwargs); + case "get_views": + return this.mockGetViews(args.model, args.kwargs); case "name_create": return this.mockNameCreate(args.model, args.args[0]); case "name_get": @@ -536,19 +568,20 @@ export class MockServer { return menus; } - mockLoadViews(modelName, kwargs) { - const fieldsViews = {}; + mockGetViews(modelName, kwargs) { + const views = {}; + const models = {}; + models[modelName] = this.mockFieldsGet(modelName); kwargs.views.forEach(([viewId, viewType]) => { - fieldsViews[viewType] = this.fieldsViewGet(modelName, [viewId, viewType], kwargs); + views[viewType] = this.getView(modelName, [viewId, viewType], kwargs); + if (kwargs.options.load_filters && viewType === "search") { + views[viewType].filters = this.models[modelName].filters || []; + } + for (const modelName of views[viewType].models) { + models[modelName] = models[modelName] || this.mockFieldsGet(modelName); + } }); - const result = { - fields: this.mockFieldsGet(modelName), - fields_views: fieldsViews, - }; - if (kwargs.options.load_filters) { - result.filters = this.models[modelName].filters || []; - } - return result; + return { models, views }; } /** diff --git a/addons/web/static/tests/legacy/fields/relational_fields/field_many2many_tests.js b/addons/web/static/tests/legacy/fields/relational_fields/field_many2many_tests.js index ff6ce352664..4dbb411b0d4 100644 --- a/addons/web/static/tests/legacy/fields/relational_fields/field_many2many_tests.js +++ b/addons/web/static/tests/legacy/fields/relational_fields/field_many2many_tests.js @@ -446,7 +446,7 @@ QUnit.module('fields', {}, function () { }, res_id: 1, mockRPC: function (route, args) { - if (args.method !== 'load_views') { + if (args.method !== 'get_views') { assert.step(_.last(route.split('/'))); } if (args.method === 'write' && args.model === 'partner') { @@ -550,7 +550,7 @@ QUnit.module('fields', {}, function () { 'partner_type,false,search': '', }, mockRPC: function (route, args) { - if (args.method !== 'load_views') { + if (args.method !== 'get_views') { assert.step(_.last(route.split('/'))); } if (args.method === 'write') { @@ -1139,7 +1139,7 @@ QUnit.module('fields', {}, function () { '', }, mockRPC: function (route, args) { - if (args.method !== 'load_views') { + if (args.method !== 'get_views') { assert.step(_.last(route.split('/')) + ' on ' + args.model); } if (args.model === 'turtle') { @@ -1264,7 +1264,7 @@ QUnit.module('fields', {}, function () { assert.verifySteps([ 'read', // read initial record (on partner) 'read', // read many2many turtles - 'load_views', // load arch of turtles form view + 'get_views', // load arch of turtles form view 'read', // read missing field when opening record in modal form view 'write', // when saving the modal 'onchange', // onchange should be triggered on partner diff --git a/addons/web/static/tests/legacy/fields/relational_fields/field_many2one_tests.js b/addons/web/static/tests/legacy/fields/relational_fields/field_many2one_tests.js index 03bfa5ee421..7f99381c812 100644 --- a/addons/web/static/tests/legacy/fields/relational_fields/field_many2one_tests.js +++ b/addons/web/static/tests/legacy/fields/relational_fields/field_many2one_tests.js @@ -629,7 +629,7 @@ QUnit.module('fields', {}, function () { // save the modal and make sure an onchange is triggered await testUtils.dom.click($('.modal .modal-footer .btn-primary').first()); - assert.verifySteps(['read', 'get_formview_id', 'load_views', 'read', 'write', 'read', 'onchange']); + assert.verifySteps(['read', 'get_formview_id', 'get_views', 'read', 'write', 'read', 'onchange']); // save the main record, and check that no extra rpcs are done (record // is not dirty, only a related record was modified) @@ -3117,7 +3117,7 @@ QUnit.module('fields', {}, function () { assert.verifySteps([ 'onchange', 'name_search', // to display results in the dropdown - 'load_views', // list view in dialog + 'get_views', // list view in dialog '/web/dataset/search_read', // to display results in the dialog ]); @@ -3173,7 +3173,7 @@ QUnit.module('fields', {}, function () { 'name_search', // empty search, triggered when the user clicks in the input 'name_search', // to display results in the dropdown 'name_search', // to get preselected ids matching the search - 'load_views', // list view in dialog + 'get_views', // list view in dialog '/web/dataset/search_read', // to display results in the dialog '/web/dataset/search_read', // after removal of dynamic filter ]); @@ -3339,7 +3339,7 @@ QUnit.module('fields', {}, function () { assert.verifySteps([ 'onchange', 'name_search', // to display results in the dropdown - 'load_views', // list view in dialog + 'get_views', // list view in dialog '/web/dataset/search_read', // to display results in the dialog '/web/dataset/resequence', // resequencing lines 'read', diff --git a/addons/web/static/tests/legacy/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/legacy/fields/relational_fields/field_one2many_tests.js index aada8b45683..06593a76727 100644 --- a/addons/web/static/tests/legacy/fields/relational_fields/field_one2many_tests.js +++ b/addons/web/static/tests/legacy/fields/relational_fields/field_one2many_tests.js @@ -6233,7 +6233,7 @@ QUnit.module('fields', {}, function () { await testUtils.owlCompatibilityExtraNextTick(); assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0", "int_field should still be 0 (no onchange should have been done yet)"); - assert.verifySteps(['load_views', 'read', 'onchange']); + assert.verifySteps(['get_views', 'read', 'onchange']); // fill turtle_foo field await testUtils.fields.editInput(form.$('.o_field_widget[name="turtle_foo"]'), "some text"); @@ -7964,7 +7964,7 @@ QUnit.module('fields', {}, function () { // swap 2 lines in the one2many await testUtils.dom.dragAndDrop(form.$('.ui-sortable-handle:eq(1)'), form.$('tbody tr').first(), { position: 'top' }); - assert.verifySteps(['load_views', 'read', 'read', 'onchange', 'onchange']); + assert.verifySteps(['get_views', 'read', 'read', 'onchange', 'onchange']); form.destroy(); }); @@ -8004,7 +8004,7 @@ QUnit.module('fields', {}, function () { "should have triggered the onchange"); assert.verifySteps([ - 'load_views', // load sub list + 'get_views', // load sub list 'onchange', // main record 'onchange', // sub record 'onchange', // edition of display_name of sub record diff --git a/addons/web/static/tests/legacy/helpers/mock_server.js b/addons/web/static/tests/legacy/helpers/mock_server.js index d9cbf4c756a..055e6646067 100644 --- a/addons/web/static/tests/legacy/helpers/mock_server.js +++ b/addons/web/static/tests/legacy/helpers/mock_server.js @@ -79,7 +79,7 @@ var MockServer = Class.extend({ }, /** * helper: read a string describing an arch, and returns a simulated - * 'field_view_get' call to the server. Calls processViews() of data_manager + * 'view_get' call to the server. Calls processViews() of data_manager * to mimick the real behavior of a call to loadViews(). * * @param {Object} params @@ -90,7 +90,7 @@ var MockServer = Class.extend({ * @param {Object} [params.viewOptions] the view options set in the test (optional) * @returns {Object} an object with 2 keys: arch and fields */ - fieldsViewGet: function (params) { + getView: function (params) { var model = params.model; var toolbar = params.toolbar; var viewId = params.view_id; @@ -99,14 +99,14 @@ var MockServer = Class.extend({ throw new Error('Model ' + model + ' was not defined in mock server data'); } var fields = $.extend(true, {}, this.data[model].fields); - var fvg = this._fieldsViewGet(params.arch, model, fields, viewOptions.context || {}); + var view = this._getView(params.arch, model, fields, viewOptions.context || {}); if (toolbar) { - fvg.toolbar = toolbar; + view.toolbar = toolbar; } if (viewId) { - fvg.view_id = viewId; + view.id = viewId; } - return fvg; + return view; }, /** * Simulates a complete fetch call. @@ -266,7 +266,7 @@ var MockServer = Class.extend({ }, /** * helper: read a string describing an arch, and returns a simulated - * 'fields_view_get' call to the server. + * 'get_view' call to the server. * * @private * @param {string} arch a string OR a parsed xml document @@ -276,12 +276,13 @@ var MockServer = Class.extend({ * @returns {Object} an object with 2 keys: arch and fields (the fields * appearing in the views) */ - _fieldsViewGet: function (arch, model, fields, context) { + _getView: function (arch, model, fields, context) { var self = this; var modifiersNames = ['invisible', 'readonly', 'required']; var onchanges = this.data[model].onchanges || {}; var fieldNodes = {}; var groupbyNodes = {}; + const relatedModels = new Set([model]); var doc; if (typeof arch === 'string') { @@ -394,7 +395,7 @@ var MockServer = Class.extend({ return !isField; }); - var relModel, relFields; + let relModel, relFields; _.each(fieldNodes, function (node, name) { var field = fields[name]; if (field.type === "many2one" || field.type === "many2many") { @@ -404,13 +405,16 @@ var MockServer = Class.extend({ node.setAttribute('can_write', canWrite || "true"); } if (field.type === "one2many" || field.type === "many2many") { - field.views = {}; - _.each(node.childNodes, function (children) { - if (children.tagName) { // skip text nodes - relModel = field.relation; + relModel = field.relation; + relatedModels.add(relModel); + _.each(node.childNodes, function (childNode) { + if (childNode.tagName) { // skip text nodes relFields = $.extend(true, {}, self.data[relModel].fields); - field.views[children.tagName] = self._fieldsViewGet(children, relModel, + // this is hackhish, but _getView modifies the subview document in place, + // especially to generate the "modifiers" attribute + const { models } = self._getView(childNode, relModel, relFields, _.extend({}, context, {base_model_name: model})); + [...models].forEach((modelName) => relatedModels.add(modelName)); } }); } @@ -427,22 +431,21 @@ var MockServer = Class.extend({ } field.views = {}; relModel = field.relation; + relatedModels.add(relModel); relFields = $.extend(true, {}, self.data[relModel].fields); node._isProcessed = true; // postprocess simulation - field.views.groupby = self._fieldsViewGet(node, relModel, relFields, context); - while (node.firstChild) { - node.removeChild(node.firstChild); - } + const { models } = self._getView(node, relModel, relFields, context); + [...models].forEach((modelName) => relatedModels.add(modelName)); }); var xmlSerializer = new XMLSerializer(); var processedArch = xmlSerializer.serializeToString(doc); return { arch: processedArch, - fields: _.pick(fields, _.keys(fieldNodes)), model: model, type: doc.tagName === 'tree' ? 'list' : doc.tagName, + models: relatedModels, }; }, /** @@ -1180,7 +1183,7 @@ var MockServer = Class.extend({ return action || false; }, /** - * Simulate a 'load_views' operation + * Simulate a 'get_views' operation * * @param {string} model * @param {Array} args @@ -1190,7 +1193,7 @@ var MockServer = Class.extend({ * @param {Object} kwargs.context * @returns {Object} */ - _mockLoadViews: function (model, kwargs) { + _mockGetViews: function (model, kwargs) { var self = this; var views = {}; _.each(kwargs.views, function (view_descr) { @@ -1199,7 +1202,7 @@ var MockServer = Class.extend({ if (!viewID) { var contextKey = (viewType === 'list' ? 'tree' : viewType) + '_view_ref'; if (contextKey in kwargs.context) { - viewID = kwargs.context[contextKey]; + viewID = parseInt(kwargs.context[contextKey]); } } var key = [model, viewID, viewType].join(','); @@ -1989,8 +1992,8 @@ var MockServer = Class.extend({ case 'search_panel_select_multi_range': return this._mockSearchPanelSelectMultiRange(args.model, args.args, args.kwargs); - case 'load_views': - return this._mockLoadViews(args.model, args.kwargs); + case 'get_views': + return this._mockGetViews(args.model, args.kwargs); case 'name_get': return this._mockNameGet(args.model, args.args); diff --git a/addons/web/static/tests/legacy/helpers/test_utils.js b/addons/web/static/tests/legacy/helpers/test_utils.js index e8b5d23250c..cd021609026 100644 --- a/addons/web/static/tests/legacy/helpers/test_utils.js +++ b/addons/web/static/tests/legacy/helpers/test_utils.js @@ -141,7 +141,7 @@ odoo.define('web.test_utils', async function (require) { patch: testUtilsMock.patch, patchDate: testUtilsMock.patchDate, unpatch: testUtilsMock.unpatch, - fieldsViewGet: testUtilsMock.fieldsViewGet, + getView: testUtilsMock.getView, patchSetTimeout: testUtilsMock.patchSetTimeout, }, controlPanel: { @@ -269,7 +269,7 @@ odoo.define('web.test_utils', async function (require) { // backward-compatibility addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'), dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'), - fieldsViewGet: deprecated(testUtilsMock.fieldsViewGet, 'mock'), + getView: deprecated(testUtilsMock.getView, 'mock'), intercept: deprecated(testUtilsMock.intercept, 'mock'), openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'), patch: deprecated(testUtilsMock.patch, 'mock'), diff --git a/addons/web/static/tests/legacy/helpers/test_utils_create.js b/addons/web/static/tests/legacy/helpers/test_utils_create.js index f1b31c26ccb..e93e2161c4a 100644 --- a/addons/web/static/tests/legacy/helpers/test_utils_create.js +++ b/addons/web/static/tests/legacy/helpers/test_utils_create.js @@ -142,13 +142,13 @@ odoo.define('web.test_utils_create', function (require) { new Widget(), serverParams, ); - const { arch, fields } = testUtilsMock.fieldsViewGet(mockServer, { + const { arch } = testUtilsMock.getView(mockServer, { arch: globalConfig.arch, fields: globalConfig.fields, model, viewOptions: { context: globalConfig.context }, }); - Object.assign(globalConfig, { arch, fields }); + Object.assign(globalConfig, { arch }); } globalConfig.env = env; @@ -283,7 +283,7 @@ odoo.define('web.test_utils_create', function (require) { // add mock environment: mock server, session, fieldviewget, ... const mockServer = await testUtilsMock.addMockEnvironment(widget, params); - const viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + const viewInfo = testUtilsMock.getView(mockServer, params); params.server = mockServer; @@ -297,7 +297,7 @@ odoo.define('web.test_utils_create', function (require) { }; const viewOptions = Object.assign({ action: Object.assign(defaultAction, params.action), - view: viewInfo, + view: { ...viewInfo, fields: mockServer.fieldsGet(params.model) }, modelName: modelName, ids: 'res_id' in params ? [params.res_id] : undefined, currentId: 'res_id' in params ? params.res_id : undefined, @@ -323,7 +323,7 @@ odoo.define('web.test_utils_create', function (require) { // TODO: probably needs to create an helper just for that view = new params.View({ viewInfo, modelName }); } else { - viewOptions.controlPanelFieldsView = Object.assign(testUtilsMock.fieldsViewGet(mockServer, { + viewOptions.controlPanelFieldsView = Object.assign(testUtilsMock.getView(mockServer, { arch: params.archs && params.archs[params.model + ',false,search'] || '', fields: viewInfo.fields, model: params.model, diff --git a/addons/web/static/tests/legacy/helpers/test_utils_mock.js b/addons/web/static/tests/legacy/helpers/test_utils_mock.js index 9e7b941ffc3..d866c8e3e9d 100644 --- a/addons/web/static/tests/legacy/helpers/test_utils_mock.js +++ b/addons/web/static/tests/legacy/helpers/test_utils_mock.js @@ -23,6 +23,7 @@ const MockServer = require('web.MockServer'); const RamStorage = require('web.RamStorage'); const session = require('web.session'); const { patchDate } = require("@web/../tests/helpers/utils"); +const { processArch } = require("@web/legacy/legacy_load_views"); const { Component } = owl; const DebouncedField = basic_fields.DebouncedField; @@ -80,11 +81,11 @@ async function _getMockedOwlEnv(params, mockServer) { options: options, views: params.views_descr, }, - method: 'load_views', + method: 'get_views', model: params.model, }).then(function (views) { views = _.mapObject(views, viewParams => { - return fieldsViewGet(mockServer, viewParams); + return getView(mockServer, viewParams); }); if (favoriteFilters && 'search' in views) { views.search.favoriteFilters = favoriteFilters; @@ -247,7 +248,7 @@ function _observe(widget) { //------------------------------------------------------------------------------ /** - * performs a fields_view_get, and mocks the postprocessing done by the + * performs a get_view, and mocks the postprocessing done by the * data_manager to return an equivalent structure. * * @param {MockServer} server @@ -255,12 +256,24 @@ function _observe(widget) { * @param {string} params.model * @returns {Object} an object with 3 keys: arch, fields and viewFields */ -function fieldsViewGet(server, params) { - var fieldsView = server.fieldsViewGet(params); +function getView(server, params) { + var view = server.getView(params); + const fields = server.fieldsGet(params.model); // mock the structure produced by the DataManager - fieldsView.viewFields = fieldsView.fields; - fieldsView.fields = server.fieldsGet(params.model); - return fieldsView; + const models = { [params.model]: fields }; + for (const modelName of view.models) { + models[modelName] = models[modelName] || server.fieldsGet(modelName); + } + const { arch, viewFields } = processArch(view.arch, view.type, params.model, models); + return { + arch, + fields, + model: view.model, + toolbar: view.toolbar, + type: view.type, + viewFields, + view_id: view.id, + }; } /** @@ -681,7 +694,7 @@ function patchSetTimeout() { return { addMockEnvironment: addMockEnvironment, - fieldsViewGet: fieldsViewGet, + getView: getView, addMockEnvironmentOwl: addMockEnvironmentOwl, intercept: intercept, patchDate: legacyPatchDate, diff --git a/addons/web/static/tests/legacy/services/data_manager_tests.js b/addons/web/static/tests/legacy/services/data_manager_tests.js index 38ee8d71970..5e36b1a093c 100644 --- a/addons/web/static/tests/legacy/services/data_manager_tests.js +++ b/addons/web/static/tests/legacy/services/data_manager_tests.js @@ -23,17 +23,20 @@ odoo.define('web.data_manager_tests', function (require) { const server = new MockServer(data, { archs }); const serverMethods = { - async load_views({ kwargs, model }) { - const { options, views } = kwargs; - const fields = server.fieldsGet(model); - const fields_views = {}; - for (const [viewId, viewType] of views) { + async get_views({ kwargs, model }) { + const models = {}; + models[model] = server.fieldsGet(model); + const views = {}; + for (const [viewId, viewType] of kwargs.views) { const arch = archs[[model, viewId || false, viewType].join()]; - fields_views[viewType] = server.fieldsViewGet({ arch, model, viewId }); + views[viewType] = server.getView({ arch, model, viewId }); + for (const modelName of views[viewType].models) { + models[modelName] = server.fieldsGet(modelName); + } } - const result = { fields, fields_views }; - if (options.load_filters) { - result.filters = data['ir.filters'].records.filter(r => r.model_id === model); + const result = { models, views }; + if (kwargs.options.load_filters && views.search) { + views.search.filters = data['ir.filters'].records.filter(r => r.model_id === model); } return result; }, @@ -133,7 +136,7 @@ odoo.define('web.data_manager_tests', function (require) { "query with same params and options should yield the same results"); assert.deepEqual(firstLoad.search.favoriteFilters, filters, "load filters should yield the same result as the first load_views' filters"); - assert.verifySteps(['oui.load_views'], + assert.verifySteps(['oui.get_views'], "only load once when not in assets debugging"); }); @@ -165,8 +168,8 @@ odoo.define('web.data_manager_tests', function (require) { assert.deepEqual(firstLoad.search.favoriteFilters, filters, "load filters should yield the same result as the first load_views' filters"); assert.verifySteps([ - 'oui.load_views', - 'oui.load_views', + 'oui.get_views', + 'oui.get_views', 'ir.filters.get_filters', ], "reload each time when in assets debugging"); }); @@ -228,7 +231,7 @@ odoo.define('web.data_manager_tests', function (require) { "Filters loaded by the load_views should be equal to the result of a load_filters"); assert.verifySteps([ - 'oui.load_views', + 'oui.get_views', 'ir.filters.create_or_replace', 'ir.filters.get_filters', 'ir.filters.unlink', diff --git a/addons/web/static/tests/legacy/views/basic_model_tests.js b/addons/web/static/tests/legacy/views/basic_model_tests.js index 3bf60f59333..6678c47f7af 100644 --- a/addons/web/static/tests/legacy/views/basic_model_tests.js +++ b/addons/web/static/tests/legacy/views/basic_model_tests.js @@ -154,7 +154,7 @@ odoo.define('web.basic_model_tests', function (require) { }); assert.verifySteps([ - 'load_views', + 'get_views', 'onchange', ]); assert.containsOnce(form, '.o_field_x2many_list', 'should have rendered a x2many list'); diff --git a/addons/web/static/tests/legacy/views/calendar_tests.js b/addons/web/static/tests/legacy/views/calendar_tests.js index 778bbc65715..8775881af10 100644 --- a/addons/web/static/tests/legacy/views/calendar_tests.js +++ b/addons/web/static/tests/legacy/views/calendar_tests.js @@ -3295,7 +3295,7 @@ QUnit.module('Views', { initialDate: initialDate, }, mockRPC: function (route, args) { - if (args.method === "load_views") { + if (args.method === "get_views") { assert.strictEqual(args.kwargs.views[0][0], 1, "should load view with id 1"); } diff --git a/addons/web/static/tests/legacy/views/form_tests.js b/addons/web/static/tests/legacy/views/form_tests.js index 654e932099b..e3725bd817a 100644 --- a/addons/web/static/tests/legacy/views/form_tests.js +++ b/addons/web/static/tests/legacy/views/form_tests.js @@ -521,10 +521,14 @@ QUnit.module('Views', { serverData.models.product.records = [{id: 1, name: 'Tromblon', partner_type_ids: [12,14]}]; serverData.models.partner.records[0].product_id = 1; + // This is an old test, written before "get_views" (formerly "load_views") automatically + // inlines x2many subviews. As the purpose of this test is to assert that the js fetches + // the correct sub view when it is not inline (which can still happen in nested form views), + // we bypass the inline mecanism of "get_views" by setting widget="one2many" on the field. serverData.views = { 'product,false,form': ''+ ''+ - '' + + '' + '', 'partner_type,false,list': ''+ @@ -539,7 +543,7 @@ QUnit.module('Views', { }; const mockRPC = (route, args) => { - if (args.method === 'load_views') { + if (args.method === 'get_views') { var context = args.kwargs.context; if (args.model === 'product') { assert.strictEqual(context.tree_view_ref, 'some_tree_view', @@ -6192,7 +6196,7 @@ QUnit.module('Views', { "the row should contains the 2 fields defined in the form view"); assert.strictEqual($(modal_row).text(), "gold2", "the value of the fields should be fetched and displayed"); - assert.verifySteps(['read', 'read', 'load_views', 'read', 'read'], + assert.verifySteps(['read', 'read', 'get_views', 'read', 'read'], "there should be 4 read rpcs"); form.destroy(); }); @@ -8222,10 +8226,10 @@ QUnit.module('Views', { assert.verifySteps([ "read", // main record "get_formview_id", // id of first form view opened in a dialog - "load_views", // arch of first form view opened in a dialog + "get_views", // arch of first form view opened in a dialog "read", // first dialog "get_formview_id", // id of second form view opened in a dialog - "load_views", // arch of second form view opened in a dialog + "get_views", // arch of second form view opened in a dialog "read", // second dialog "write", // save second dialog "read", // reload first dialog @@ -8799,7 +8803,7 @@ QUnit.module('Views', { } }); - assert.verifySteps(['partner_type:load_views', 'partner:read', 'partner_type:read']); + assert.verifySteps(['partner_type:get_views', 'partner:read', 'partner_type:read']); form.destroy(); }); diff --git a/addons/web/static/tests/legacy/views/kanban_tests.js b/addons/web/static/tests/legacy/views/kanban_tests.js index 54e19fcb9b7..a79e008acef 100644 --- a/addons/web/static/tests/legacy/views/kanban_tests.js +++ b/addons/web/static/tests/legacy/views/kanban_tests.js @@ -745,11 +745,11 @@ QUnit.module('Views', { 'web_read_group', // initial read_group '/web/dataset/search_read', // initial search_read (first column) '/web/dataset/search_read', // initial search_read (second column) - 'load_views', // form view in quick create + 'get_views', // form view in quick create 'onchange', // quick create 'create', // should perform a create to create the record 'read', // read the created record - 'load_views', // form view in quick create (is actually in cache) + 'get_views', // form view in quick create (is actually in cache) 'onchange', // reopen the quick create automatically ]); @@ -870,11 +870,11 @@ QUnit.module('Views', { 'web_read_group', // initial read_group '/web/dataset/search_read', // initial search_read (first column) '/web/dataset/search_read', // initial search_read (second column) - 'load_views', // form view in quick create + 'get_views', // form view in quick create 'onchange', // quick create 'create', // should perform a create to create the record 'read', // read the created record - 'load_views', // form view in quick create (is actually in cache) + 'get_views', // form view in quick create (is actually in cache) 'onchange', // reopen the quick create automatically ]); @@ -934,7 +934,7 @@ QUnit.module('Views', { 'web_read_group', // initial read_group '/web/dataset/search_read', // initial search_read (first column) '/web/dataset/search_read', // initial search_read (second column) - 'load_views', // form view in quick create + 'get_views', // form view in quick create 'onchange', // quick create 'onchange', // onchange due to 'foo' field change ]); @@ -2600,7 +2600,7 @@ QUnit.module('Views', { groupBy: ['product_id'], async mockRPC(route, args) { const result = this._super(...arguments); - if (args.method === 'load_views') { + if (args.method === 'get_views') { await def; } return result; diff --git a/addons/web/static/tests/search/search_panel_tests.js b/addons/web/static/tests/search/search_panel_tests.js index 3699f340ae2..b354e7f3c81 100644 --- a/addons/web/static/tests/search/search_panel_tests.js +++ b/addons/web/static/tests/search/search_panel_tests.js @@ -1060,7 +1060,7 @@ QUnit.module("Search", (hooks) => { promise.resolve(); await compPromise; - assert.verifySteps(["load_views", "search_panel_select_range"]); + assert.verifySteps(["get_views", "search_panel_select_range"]); // Case 2: search domain changed so we wait for the search panel once again promise = makeDeferred(); @@ -1123,7 +1123,7 @@ QUnit.module("Search", (hooks) => { await compPromise; assert.verifySteps([ - "load_views", + "get_views", "search_panel_select_range", "search_panel_select_multi_range", ]); @@ -1160,7 +1160,7 @@ QUnit.module("Search", (hooks) => { await compPromise; assert.verifySteps([ - "load_views", + "get_views", "search_panel_select_range", "search_panel_select_multi_range", ]); diff --git a/addons/web/static/tests/search/with_search_tests.js b/addons/web/static/tests/search/with_search_tests.js index 2224c9314ce..a881ee97b7e 100644 --- a/addons/web/static/tests/search/with_search_tests.js +++ b/addons/web/static/tests/search/with_search_tests.js @@ -137,8 +137,8 @@ QUnit.module("Search", (hooks) => { await makeWithSearch({ serverData, mockRPC: function (_, args) { - if (args.method === "load_views") { - throw new Error("No load_views should be done"); + if (args.method === "get_views") { + throw new Error("No get_views should be done"); } }, resModel: "animal", @@ -159,7 +159,7 @@ QUnit.module("Search", (hooks) => { await makeWithSearch({ serverData, mockRPC: function (_, args) { - if (args.method === "load_views") { + if (args.method === "get_views") { assert.deepEqual(args.kwargs, { context: { lang: "en", @@ -193,8 +193,8 @@ QUnit.module("Search", (hooks) => { await makeWithSearch({ serverData, mockRPC: function (_, args) { - if (args.method === "load_views") { - throw new Error("No load_views should be done"); + if (args.method === "get_views") { + throw new Error("No get_views should be done"); } }, resModel: "animal", @@ -218,7 +218,7 @@ QUnit.module("Search", (hooks) => { await makeWithSearch({ serverData, mockRPC: function (_, args) { - if (args.method === "load_views") { + if (args.method === "get_views") { assert.deepEqual(args.kwargs.options, { action_id: false, load_filters: true, @@ -253,7 +253,7 @@ QUnit.module("Search", (hooks) => { await makeWithSearch({ serverData, mockRPC: function (_, args) { - if (args.method === "load_views") { + if (args.method === "get_views") { assert.deepEqual(args.kwargs.views, [[1, "search"]]); } }, diff --git a/addons/web/static/tests/views/helpers.js b/addons/web/static/tests/views/helpers.js index fa8d0a4271a..d8e2bb60ede 100644 --- a/addons/web/static/tests/views/helpers.js +++ b/addons/web/static/tests/views/helpers.js @@ -3,7 +3,7 @@ import { makeTestEnv } from "@web/../tests/helpers/mock_env"; import { getFixture, mount } from "@web/../tests/helpers/utils"; import { getDefaultConfig, View } from "@web/views/view"; -import { _fieldsViewGet } from "../helpers/mock_server"; +import { _getView } from "../helpers/mock_server"; import { addLegacyMockEnvironment } from "../webclient/helpers"; /** @@ -46,15 +46,14 @@ export const makeView = async (params) => { props.fields[fieldName].name = fieldName; } } - const fvg = _fieldsViewGet({ + const view = _getView({ arch: props.arch, modelName: props.resModel, fields: props.fields, context: props.context || {}, models: serverData.models, }); - props.arch = fvg.arch; - props.fields = Object.assign({}, props.fields, fvg.fields); + props.arch = view.arch; props.searchViewArch = props.searchViewArch || ""; props.searchViewFields = props.searchViewFields || Object.assign({}, props.fields); } diff --git a/addons/web/static/tests/views/pivot_view_tests.js b/addons/web/static/tests/views/pivot_view_tests.js index 8e3dee7299a..d6aafe09ce8 100644 --- a/addons/web/static/tests/views/pivot_view_tests.js +++ b/addons/web/static/tests/views/pivot_view_tests.js @@ -4780,7 +4780,7 @@ QUnit.module("Views", (hooks) => { assert.containsOnce(target, ".o_pivot_view"); assert.strictEqual(getCurrentValues(target), ["4", "2", "2"].join(",")); - assert.verifySteps(["/web/webclient/load_menus", "load_views", "read_group", "read_group"]); + assert.verifySteps(["/web/webclient/load_menus", "get_views", "read_group", "read_group"]); // switch to list view await click(target.querySelector(".o_control_panel .o_switch_view.o_list")); diff --git a/addons/web/static/tests/views/view_service_tests.js b/addons/web/static/tests/views/view_service_tests.js index 904c037c77d..f4e78e36d01 100644 --- a/addons/web/static/tests/views/view_service_tests.js +++ b/addons/web/static/tests/views/view_service_tests.js @@ -20,16 +20,29 @@ QUnit.module("View service", (hooks) => { }, }; + const fakeUiService = { + start(env) { + Object.defineProperty(env, "isSmall", { + get() { + return false; + }, + }); + }, + }; serverData = { models, views }; - registry.category("services").add("views", viewService).add("orm", ormService); + registry + .category("services") + .add("views", viewService) + .add("orm", ormService) + .add("ui", fakeUiService); }); QUnit.test("stores calls in cache in success", async (assert) => { assert.expect(2); const mockRPC = (route, args) => { - if (route.includes("load_views")) { - assert.step("load_views"); + if (route.includes("get_views")) { + assert.step("get_views"); } }; @@ -51,15 +64,15 @@ QUnit.module("View service", (hooks) => { {} ); - assert.verifySteps(["load_views"]); + assert.verifySteps(["get_views"]); }); QUnit.test("stores calls in cache when failed", async (assert) => { assert.expect(5); const mockRPC = (route, args) => { - if (route.includes("load_views")) { - assert.step("load_views"); + if (route.includes("get_views")) { + assert.step("get_views"); return Promise.reject("my little error"); } }; @@ -91,6 +104,6 @@ QUnit.module("View service", (hooks) => { assert.strictEqual(error, "my little error"); } - assert.verifySteps(["load_views", "load_views"]); + assert.verifySteps(["get_views", "get_views"]); }); }); diff --git a/addons/web/static/tests/views/view_tests.js b/addons/web/static/tests/views/view_tests.js index 22f3e57e57e..a7bb4063e96 100644 --- a/addons/web/static/tests/views/view_tests.js +++ b/addons/web/static/tests/views/view_tests.js @@ -110,7 +110,7 @@ QUnit.module("Views", (hooks) => { QUnit.module("View component"); //////////////////////////////////////////////////////////////////////////// - // load_views + // get_views //////////////////////////////////////////////////////////////////////////// QUnit.test("simple rendering", async function (assert) { @@ -132,7 +132,7 @@ QUnit.module("Views", (hooks) => { serverData, mockRPC: (_, args) => { assert.strictEqual(args.model, "animal"); - assert.strictEqual(args.method, "load_views"); + assert.strictEqual(args.method, "get_views"); assert.deepEqual(args.kwargs.views, [[false, "toy"]]); assert.deepEqual(args.kwargs.options, { action_id: false, diff --git a/addons/web/static/tests/webclient/actions/concurrency_tests.js b/addons/web/static/tests/webclient/actions/concurrency_tests.js index 6fdaf48b963..31292228366 100644 --- a/addons/web/static/tests/webclient/actions/concurrency_tests.js +++ b/addons/web/static/tests/webclient/actions/concurrency_tests.js @@ -49,7 +49,7 @@ QUnit.module("ActionManager", (hooks) => { "/web/webclient/load_menus", "/web/action/load", "/web/action/load", - "/web/dataset/call_kw/pony/load_views", + "/web/dataset/call_kw/pony/get_views", "/web/dataset/search_read", ]); }); @@ -80,7 +80,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/search_read", "/web/dataset/search_read", "/web/dataset/search_read", @@ -187,11 +187,11 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "read", "/web/dataset/search_read", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); // unblock the switch to Kanban in action 4 @@ -238,12 +238,12 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "read", "object", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); // unblock the call_button request @@ -302,11 +302,11 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "read", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); // unblock the switch to the form view in action 3 @@ -332,12 +332,12 @@ QUnit.module("ActionManager", (hooks) => { const def = testUtils.makeTestPromise(); const mockRPC = async function (route, args) { assert.step((args && args.method) || route); - if (args && args.method === "load_views") { + if (args && args.method === "get_views") { await def; } }; const webClient = await createWebClient({ serverData, mockRPC }); - // execute a first action (its 'load_views' RPC is blocked) + // execute a first action (its 'get_views' RPC is blocked) doAction(webClient, 3); await testUtils.nextTick(); await legacyExtraNextTick(); @@ -358,9 +358,9 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -395,10 +395,10 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -499,7 +499,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "read", "/web/action/load", @@ -537,19 +537,19 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "/web/action/load", "/web/dataset/search_read", ]); }); - QUnit.test("switching when doing an action -- load_views slow", async function (assert) { + QUnit.test("switching when doing an action -- get_views slow", async function (assert) { assert.expect(13); let def; const mockRPC = async (route, args) => { assert.step((args && args.method) || route); - if (args && args.method === "load_views") { + if (args && args.method === "get_views") { return Promise.resolve(def); } }; @@ -574,10 +574,10 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -611,10 +611,10 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "/web/dataset/search_read", ]); diff --git a/addons/web/static/tests/webclient/actions/load_state_tests.js b/addons/web/static/tests/webclient/actions/load_state_tests.js index fc9233372ae..afcfd375e1a 100644 --- a/addons/web/static/tests/webclient/actions/load_state_tests.js +++ b/addons/web/static/tests/webclient/actions/load_state_tests.js @@ -216,7 +216,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -239,7 +239,7 @@ QUnit.module("ActionManager", (hooks) => { "Second record", "should have opened the second record" ); - assert.verifySteps(["/web/webclient/load_menus", "load_views", "read"]); + assert.verifySteps(["/web/webclient/load_menus", "get_views", "read"]); }); QUnit.test("properly load records with existing first APP", async function (assert) { @@ -266,7 +266,7 @@ QUnit.module("ActionManager", (hooks) => { "should have opened the second record" ); assert.containsNone(target, ".o_menu_brand"); - assert.verifySteps(["/web/webclient/load_menus", "load_views", "read"]); + assert.verifySteps(["/web/webclient/load_menus", "get_views", "read"]); }); QUnit.test("properly load default record", async function (assert) { @@ -287,7 +287,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "onchange", ]); }); @@ -309,7 +309,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -345,7 +345,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "read", "/web/dataset/search_read", ]); @@ -458,12 +458,12 @@ QUnit.module("ActionManager", (hooks) => { "should have opened the requested record" ); // verify steps to ensure that the whole action hasn't been re-executed - // (if it would have been, /web/action/load and load_views would appear + // (if it would have been, /web/action/load and get_views would appear // several times) assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "/web/dataset/search_read", "read", @@ -507,12 +507,12 @@ QUnit.module("ActionManager", (hooks) => { "should have switched to the requested record" ); // verify steps to ensure that the whole action hasn't been re-executed - // (if it would have been, /web/action/load and load_views would appear + // (if it would have been, /web/action/load and get_views would appear // twice) assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "read", "read", @@ -717,7 +717,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "setItem session current_action", "getItem session current_action", @@ -749,7 +749,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/search_read", ]); }); @@ -799,7 +799,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/search_read", ]); }); @@ -830,7 +830,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/call_kw/partner/read", ]); await loadState(webClient, { diff --git a/addons/web/static/tests/webclient/actions/misc_tests.js b/addons/web/static/tests/webclient/actions/misc_tests.js index 0cf598d2e7e..86e936665f1 100644 --- a/addons/web/static/tests/webclient/actions/misc_tests.js +++ b/addons/web/static/tests/webclient/actions/misc_tests.js @@ -311,7 +311,7 @@ QUnit.module("ActionManager", (hooks) => { }, }); const mockRPC = async function (route, args) { - if (args && args.method === "load_views") { + if (args && args.method === "get_views") { await Promise.resolve(def); } }; @@ -319,7 +319,7 @@ QUnit.module("ActionManager", (hooks) => { // execute action 4 to know the number of widgets it instantiates await doAction(webClient, 4); const n = delta; - // execute a first action (its 'load_views' RPC is blocked) + // execute a first action (its 'get_views' RPC is blocked) def = testUtils.makeTestPromise(); doAction(webClient, 3, { clearBreadcrumbs: true }); await testUtils.nextTick(); diff --git a/addons/web/static/tests/webclient/actions/server_action_tests.js b/addons/web/static/tests/webclient/actions/server_action_tests.js index d08bff74d79..d1322301bd1 100644 --- a/addons/web/static/tests/webclient/actions/server_action_tests.js +++ b/addons/web/static/tests/webclient/actions/server_action_tests.js @@ -32,7 +32,7 @@ QUnit.module("ActionManager", (hooks) => { "/web/action/load", "/web/action/run", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -62,7 +62,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "onchange", "/web/action/load", "/web/action/run", diff --git a/addons/web/static/tests/webclient/actions/target_tests.js b/addons/web/static/tests/webclient/actions/target_tests.js index 6fdbc735ddf..e30850d8b98 100644 --- a/addons/web/static/tests/webclient/actions/target_tests.js +++ b/addons/web/static/tests/webclient/actions/target_tests.js @@ -56,7 +56,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "onchange", ]); }); @@ -153,7 +153,7 @@ QUnit.module("ActionManager", (hooks) => { await doAction(webClient, 4); assert.verifySteps([ "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/call_kw/partner/onchange", ]); await testUtils.dom.click(`button[name="5"]`); @@ -161,7 +161,7 @@ QUnit.module("ActionManager", (hooks) => { "/web/dataset/call_kw/partner/create", "/web/dataset/call_kw/partner/read", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/call_kw/partner/onchange", ]); await legacyExtraNextTick(); @@ -471,7 +471,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "read", ]); } diff --git a/addons/web/static/tests/webclient/actions/window_action_tests.js b/addons/web/static/tests/webclient/actions/window_action_tests.js index eeeabc0248b..a786baa9890 100644 --- a/addons/web/static/tests/webclient/actions/window_action_tests.js +++ b/addons/web/static/tests/webclient/actions/window_action_tests.js @@ -55,7 +55,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -66,7 +66,7 @@ QUnit.module("ActionManager", (hooks) => { print: [{ name: "Print that record" }], }; const mockRPC = async (route, args) => { - if (args && args.method === "load_views") { + if (args && args.method === "get_views") { assert.strictEqual( args.kwargs.options.toolbar, true, @@ -122,7 +122,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "/web/dataset/search_read", "/web/dataset/search_read", @@ -176,7 +176,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "web_read_group", "/web/dataset/search_read", "/web/dataset/search_read", @@ -679,7 +679,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "onchange", "/web/dataset/search_read", @@ -737,7 +737,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "read", "object", @@ -884,11 +884,11 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "read", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -935,10 +935,10 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/call_kw/partner/read", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/search_read", ]); }); @@ -1050,7 +1050,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "read", "/web/dataset/search_read", @@ -1191,7 +1191,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/search_read", "/web/dataset/call_kw/partner/read", "/web/dataset/call_kw/partner/get_formview_id", @@ -1416,7 +1416,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", ]); }); @@ -1449,7 +1449,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "onchange", ]); }); @@ -1702,12 +1702,12 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "load_views", + "get_views", "/web/dataset/search_read", "onchange", "get_formview_action", "create", // FIXME: to check with mcm - "load_views", + "get_views", "read", "read", ]); @@ -1722,7 +1722,7 @@ QUnit.module("ActionManager", (hooks) => { print: [], }; const mockRPC = async (route, args) => { - if (args && args.method === "load_views" && args.model === "partner") { + if (args && args.method === "get_views" && args.model === "partner") { assert.strictEqual( args.kwargs.options.toolbar, true, @@ -1992,7 +1992,7 @@ QUnit.module("ActionManager", (hooks) => { assert.verifySteps([ "/web/webclient/load_menus", "/web/action/load", - "/web/dataset/call_kw/partner/load_views", + "/web/dataset/call_kw/partner/get_views", "/web/dataset/call_kw/partner/read", ]); assert.containsN(target, ".o_form_buttons_view button:not([disabled])", 2); @@ -2365,7 +2365,7 @@ QUnit.module("ActionManager", (hooks) => { ); }); - QUnit.test("action and load_views rpcs are cached", async function (assert) { + QUnit.test("action and get_views rpcs are cached", async function (assert) { const mockRPC = async (route, args) => { assert.step(args.method || route); }; @@ -2374,7 +2374,7 @@ QUnit.module("ActionManager", (hooks) => { await doAction(webClient, 1); assert.containsOnce(target, ".o_kanban_view"); - assert.verifySteps(["/web/action/load", "load_views", "/web/dataset/search_read"]); + assert.verifySteps(["/web/action/load", "get_views", "/web/dataset/search_read"]); await doAction(webClient, 1); assert.containsOnce(target, ".o_kanban_view"); diff --git a/addons/web/static/tests/webclient/helpers.js b/addons/web/static/tests/webclient/helpers.js index 9fd42d88e68..99c226b8354 100644 --- a/addons/web/static/tests/webclient/helpers.js +++ b/addons/web/static/tests/webclient/helpers.js @@ -12,6 +12,7 @@ import { makeLegacySessionService, } from "@web/legacy/utils"; import { makeLegacyActionManagerService } from "@web/legacy/backend_utils"; +import { generateLegacyLoadViewsResult } from "@web/legacy/legacy_load_views"; import { viewService } from "@web/views/view_service"; import { actionService } from "@web/webclient/actions/action_service"; import { effectService } from "@web/core/effects/effect_service"; @@ -118,16 +119,18 @@ export function addLegacyMockEnvironment(env, legacyParams = {}) { }); }, load_views: async (params, options) => { - const result = await env.services.rpc(`/web/dataset/call_kw/${params.model}`, { + let result = await env.services.rpc(`/web/dataset/call_kw/${params.model}`, { args: [], kwargs: { context: params.context, options: options, views: params.views_descr, }, - method: "load_views", + method: "get_views", model: params.model, }); + const { models, views: _views } = result; + result = generateLegacyLoadViewsResult(params.model, _views, models); const views = result.fields_views; for (const [, viewType] of params.views_descr) { const fvg = views[viewType]; diff --git a/odoo/addons/base/static/tests/base_settings_tests.js b/odoo/addons/base/static/tests/base_settings_tests.js index b278b54b59b..1ee83575a37 100644 --- a/odoo/addons/base/static/tests/base_settings_tests.js +++ b/odoo/addons/base/static/tests/base_settings_tests.js @@ -206,11 +206,11 @@ QUnit.module('base_settings_tests', { await legacyExtraNextTick(); assert.hasClass($(target).find(".o_form_view"), "o_form_editable"); assert.verifySteps([ - "load_views", // initial setting action + "get_views", // initial setting action "onchange", // this is a setting view => create new record "create", // when we click on action button => save "read", // with save, we have a reload... (not necessary actually) - "load_views", // for other action in breadcrumb, + "get_views", // for other action in breadcrumb, // with a searchread (not shown here since it is a route) "onchange", // when we come back, we want to restart from scratch ]); From 89112f8ecf7d166e6a8382a4fff57beab490ab74 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 31 Mar 2022 11:16:10 +0000 Subject: [PATCH 3/6] [IMP] mail: do not call getViews for chatter fields Before this commit, in form views, a call to "getViews" was done for each field in the div with className "oe_chatter" (i.e. "message_ids", "message_follower_ids" and "activity_ids"). Those calls were unnecessary since those fields are displayed with a custom widget (the Chatter) anyway. Part-of: odoo/odoo#87522 --- addons/mail/static/src/js/basic_view.js | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js index 82e2b836f5b..aaaf356734b 100644 --- a/addons/mail/static/src/js/basic_view.js +++ b/addons/mail/static/src/js/basic_view.js @@ -4,6 +4,8 @@ import BasicView from 'web.BasicView'; const mailWidgets = ['kanban_activity']; +const chatterFields = ["message_ids", "message_follower_ids", "activity_ids"]; + BasicView.include({ init: function () { this._super.apply(this, arguments); @@ -62,4 +64,29 @@ BasicView.include({ _hasField(fieldName) { return !!this.fieldsInfo[this.viewType][fieldName]; }, + /** + * Override to mark chatter fields inside the "oe_chatter" div such that we + * don't fetch their sub views, as we don't need them. Without this, those + * subviews would be loaded as there is no "widget" set on those fields. + * + * @override + */ + _processNode(node, fv) { + if (node.tag === "div" && node.attrs.class && node.attrs.class.includes("oe_chatter")) { + const viewType = fv.type; + const fieldsInfo = fv.fieldsInfo[viewType]; + const fields = fv.viewFields; + for (const child of node.children) { + if (child.tag === 'field' && chatterFields.includes(child.attrs.name)) { + const attrs = { ...child.attrs, modifiers: {}, __no_fetch: true }; + const fieldName = attrs.name; + fieldsInfo[fieldName] = this._processField(viewType, fields[fieldName], attrs); + } else { + this._processNode(child, fv); + } + } + return false; + } + return this._super(...arguments); + }, }); From 45119056ee7a4609c7f505835a04dc9bdf8d2d13 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Tue, 19 Apr 2022 13:41:44 +0000 Subject: [PATCH 4/6] [REF] web: tests: makeView: let View do the "get_views" Before this commit, the makeView test helper processed the given arch and generated the fields to pass to the View component. This led us to do a similar work as what is done in the view service, i.e. duplicate code, or factorize/reuse code. An alternative is to let the View component fetch whatever it needs (view arch, fields, search view...). This is the purpose of this commit. That way, the code of the view service is executed (and thus tested as well) in each test doing a "makeView". This commit makes that change, and adapts some tests accordingly (mainly, tests doing assertions in mockRPC, since we do a get_view in every test). Part-of: odoo/odoo#87522 --- addons/web/static/tests/views/helpers.js | 31 +- .../static/tests/views/pivot_view_tests.js | 76 +- addons/web/static/tests/views/view_tests.js | 671 +++++++++--------- 3 files changed, 401 insertions(+), 377 deletions(-) diff --git a/addons/web/static/tests/views/helpers.js b/addons/web/static/tests/views/helpers.js index d8e2bb60ede..8cc6021d233 100644 --- a/addons/web/static/tests/views/helpers.js +++ b/addons/web/static/tests/views/helpers.js @@ -3,7 +3,6 @@ import { makeTestEnv } from "@web/../tests/helpers/mock_env"; import { getFixture, mount } from "@web/../tests/helpers/utils"; import { getDefaultConfig, View } from "@web/views/view"; -import { _getView } from "../helpers/mock_server"; import { addLegacyMockEnvironment } from "../webclient/helpers"; /** @@ -35,29 +34,19 @@ export const makeView = async (params) => { delete props.legacyParams; delete props.config; - const env = await makeTestEnv({ serverData, mockRPC, config }); - if (props.arch) { - const defaultFields = serverData.models[props.resModel].fields; - if (!props.fields) { - props.fields = Object.assign({}, defaultFields); - // write the field name inside the field description (as done by fields_get) - for (const fieldName in props.fields) { - props.fields[fieldName].name = fieldName; - } - } - const view = _getView({ - arch: props.arch, - modelName: props.resModel, - fields: props.fields, - context: props.context || {}, - models: serverData.models, - }); - props.arch = view.arch; - props.searchViewArch = props.searchViewArch || ""; - props.searchViewFields = props.searchViewFields || Object.assign({}, props.fields); + serverData.views = serverData.views || {}; + props.viewId = 100000001; // hopefully will not conflict with an id already in views + serverData.views[`${props.resModel},${props.viewId},${props.type}`] = props.arch; + delete props.arch; + props.searchViewId = 100000002; // hopefully will not conflict with an id already in views + const searchViewArch = props.searchViewArch || ""; + serverData.views[`${props.resModel},${props.searchViewId},search`] = searchViewArch; + delete props.searchViewArch; } + const env = await makeTestEnv({ serverData, mockRPC, config }); + /** Legacy Environment, for compatibility sakes * Remove this as soon as we drop the legacy support */ diff --git a/addons/web/static/tests/views/pivot_view_tests.js b/addons/web/static/tests/views/pivot_view_tests.js index d6aafe09ce8..46b5e519f4b 100644 --- a/addons/web/static/tests/views/pivot_view_tests.js +++ b/addons/web/static/tests/views/pivot_view_tests.js @@ -211,11 +211,13 @@ QUnit.module("Views", (hooks) => { `, mockRPC(route, args) { - assert.strictEqual( - args.kwargs.lazy, - false, - "the read_group should be done with the lazy=false option" - ); + if (args.method === "read_group") { + assert.strictEqual( + args.kwargs.lazy, + false, + "the read_group should be done with the lazy=false option" + ); + } }, }); @@ -404,6 +406,11 @@ QUnit.module("Views", (hooks) => { QUnit.test("clicking on a cell triggers a doAction", async function (assert) { assert.expect(2); + serverData.views = { + "partner,2,form": "
", + "partner,false,list": "", + "partner,5,kanban": "", + }; patchWithCleanup(session, { user_context: { userContextKey: true }, }); @@ -748,14 +755,16 @@ QUnit.module("Views", (hooks) => { `, - mockRPC(route, params) { - const wrongFields = params.kwargs.fields.filter((field) => { - return !(field.split(":")[0] in serverData.models.partner.fields); - }); - assert.ok( - !wrongFields.length, - "fields given to read_group should exist on the model" - ); + mockRPC(route, args) { + if (args.method === "read_group") { + const wrongFields = args.kwargs.fields.filter((field) => { + return !(field.split(":")[0] in serverData.models.partner.fields); + }); + assert.ok( + !wrongFields.length, + "fields given to read_group should exist on the model" + ); + } }, }); }); @@ -850,7 +859,6 @@ QUnit.module("Views", (hooks) => { assert.expect(7); let rpcCount = 0; - await makeView({ type: "pivot", resModel: "partner", @@ -860,8 +868,10 @@ QUnit.module("Views", (hooks) => { `, - mockRPC() { - rpcCount++; + mockRPC(route, args) { + if (args.method === "read_group") { + rpcCount++; + } }, }); assert.containsN( @@ -4571,23 +4581,25 @@ QUnit.module("Views", (hooks) => { `, mockRPC(route, args) { - if (args.method === "read_group" && isSecondReadGroup) { - return Promise.resolve([ - { - customer: [2, "Second"], - foo: 18, - __count: 2, - __domain: [["customer", "=", 2]], - }, - { - customer: [1, "First"], - foo: 14, - __count: 2, - __domain: [["customer", "=", 1]], - }, - ]); + if (args.method === "read_group") { + if (isSecondReadGroup) { + return Promise.resolve([ + { + customer: [2, "Second"], + foo: 18, + __count: 2, + __domain: [["customer", "=", 2]], + }, + { + customer: [1, "First"], + foo: 14, + __count: 2, + __domain: [["customer", "=", 1]], + }, + ]); + } + isSecondReadGroup = true; } - isSecondReadGroup = true; }, }); diff --git a/addons/web/static/tests/views/view_tests.js b/addons/web/static/tests/views/view_tests.js index a7bb4063e96..fa15569ae21 100644 --- a/addons/web/static/tests/views/view_tests.js +++ b/addons/web/static/tests/views/view_tests.js @@ -10,13 +10,12 @@ import { patchWithCleanup, } from "@web/../tests/helpers/utils"; import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers"; -import { makeView } from "@web/../tests/views/helpers"; import { registry } from "@web/core/registry"; import { OnboardingBanner } from "@web/views/onboarding_banner"; import { View } from "@web/views/view"; import { actionService } from "@web/webclient/actions/action_service"; -const { Component, useState, xml } = owl; +const { Component, onWillStart, onWillUpdateProps, useState, xml } = owl; const serviceRegistry = registry.category("services"); const viewRegistry = registry.category("views"); @@ -128,21 +127,22 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - assert.strictEqual(args.model, "animal"); - assert.strictEqual(args.method, "get_views"); - assert.deepEqual(args.kwargs.views, [[false, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.strictEqual(args.model, "animal"); + assert.strictEqual(args.method, "get_views"); + assert.deepEqual(args.kwargs.views, [[false, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view.o_view_controller"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -165,20 +165,21 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - assert.deepEqual(args.kwargs.views, [[1, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.deepEqual(args.kwargs.views, [[1, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", viewId: 1, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -201,23 +202,23 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - console.log(_); - assert.deepEqual(args.kwargs.views, [[1, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.deepEqual(args.kwargs.views, [[1, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -242,25 +243,26 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - assert.deepEqual(args.kwargs.views, [ - [false, "other"], - [false, "toy"], - ]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.deepEqual(args.kwargs.views, [ + [false, "other"], + [false, "toy"], + ]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const config = { + views: [[false, "other"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[false, "other"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -284,29 +286,30 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - assert.deepEqual(args.kwargs.views, [ - [1, "toy"], - [false, "other"], - ]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.deepEqual(args.kwargs.views, [ + [1, "toy"], + [false, "other"], + ]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const config = { + views: [ + [3, "toy"], + [false, "other"], + ], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", viewId: 1, - config: { - views: [ - [3, "toy"], - [false, "other"], - ], - }, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -329,16 +332,17 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: () => { - throw new Error("no RPC expected"); - }, + const mockRPC = () => { + throw new Error("no RPC expected"); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content`, fields: {}, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -361,21 +365,22 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - // the rpc is done for fields - assert.deepEqual(args.kwargs.views, [[false, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: true, - }); - }, + const mockRPC = (_, args) => { + // the rpc is done for fields + assert.deepEqual(args.kwargs.views, [[false, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: true, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", loadActionMenus: true, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -400,23 +405,24 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - // the rpc is done for fields - assert.deepEqual(args.kwargs.views, [[false, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: true, - }); - }, + const mockRPC = (_, args) => { + // the rpc is done for fields + assert.deepEqual(args.kwargs.views, [[false, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: true, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content`, fields: {}, loadActionMenus: true, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -442,11 +448,11 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: () => { - throw new Error("no RPC expected"); - }, + const mockRPC = () => { + throw new Error("no RPC expected"); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content`, @@ -455,7 +461,8 @@ QUnit.module("Views", (hooks) => { /** ... */ }, loadActionMenus: true, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view.toy").innerHTML, @@ -484,24 +491,25 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - // the rpc is done for fields - assert.deepEqual(args.kwargs.views, [ - [false, "toy"], - [false, "search"], - ]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + // the rpc is done for fields + assert.deepEqual(args.kwargs.views, [ + [false, "toy"], + [false, "search"], + ]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", searchViewId: false, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view").innerText, @@ -531,11 +539,11 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: () => { - throw new Error("no RPC expected"); - }, + const mockRPC = () => { + throw new Error("no RPC expected"); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content`, @@ -543,7 +551,8 @@ QUnit.module("Views", (hooks) => { searchViewId: false, searchViewArch: ``, searchViewFields: {}, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view").innerText, @@ -574,18 +583,19 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: () => { - throw new Error("no RPC expected"); - }, + const mockRPC = () => { + throw new Error("no RPC expected"); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content`, fields: {}, searchViewArch: ``, searchViewFields: {}, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view").innerText, @@ -616,20 +626,20 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: (_, args) => { - // the rpc is done for fields - assert.deepEqual(args.kwargs.views, [ - [false, "toy"], - [false, "search"], - ]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: true, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + // the rpc is done for fields + assert.deepEqual(args.kwargs.views, [ + [false, "toy"], + [false, "search"], + ]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: true, + toolbar: false, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content`, @@ -638,7 +648,8 @@ QUnit.module("Views", (hooks) => { searchViewArch: ``, searchViewFields: {}, loadIrFilters: true, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view").innerText, @@ -680,11 +691,11 @@ QUnit.module("Views", (hooks) => { }, }); - await makeView({ - serverData, - mockRPC: () => { - throw new Error("no RPC expected"); - }, + const mockRPC = () => { + throw new Error("no RPC expected"); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content`, @@ -693,7 +704,8 @@ QUnit.module("Views", (hooks) => { searchViewFields: {}, loadIrFilters: true, irFilters, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view"); assert.strictEqual( target.querySelector(".o_toy_view").innerText, @@ -741,16 +753,15 @@ QUnit.module("Views", (hooks) => { }; } }; - - await makeView({ - serverData, - mockRPC, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, "a"); await click(target.querySelector("a")); @@ -788,14 +799,15 @@ QUnit.module("Views", (hooks) => { `; - await makeView({ - serverData, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, "a"); await click(target.querySelector("a")); @@ -840,14 +852,15 @@ QUnit.module("Views", (hooks) => { `; - await makeView({ - serverData, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, "a"); await click(target.querySelector("a")); @@ -866,16 +879,15 @@ QUnit.module("Views", (hooks) => { return { html: `
myBanner
` }; } }; - - await makeView({ - serverData, - mockRPC, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.verifySteps(["/mybody/isacage"]); assert.containsOnce(target, ".setmybodyfree"); @@ -929,15 +941,15 @@ QUnit.module("Views", (hooks) => { patchWithCleanup(document, { createElement }); - await makeView({ - serverData, - mockRPC, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.verifySteps(["/mybody/isacage", "js loaded", "css loaded"]); assert.containsOnce(target, ".setmybodyfree"); @@ -946,7 +958,6 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("banner can re-render with new HTML", async (assert) => { - assert.expect(10); assert.expect(8); serviceRegistry.add("action", actionService, { force: true }); @@ -975,16 +986,15 @@ QUnit.module("Views", (hooks) => { }; } }; - - await makeView({ - serverData, - mockRPC, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.verifySteps(["/mybody/isacage"]); assert.containsOnce(target, ".banner1"); @@ -1007,22 +1017,31 @@ QUnit.module("Views", (hooks) => { myBanner
`; + let toy; + const ToyView = viewRegistry.get("toy"); + class ToyViewExtended extends ToyView { + setup() { + super.setup(); + toy = this; + } + } + viewRegistry.add("toy", ToyViewExtended, { force: true }); + const mockRPC = (route) => { if (route === "/mybody/isacage") { assert.step(route); return { html: bannerArch }; } }; - - const toy = await makeView({ - serverData, - mockRPC, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); assert.verifySteps(["/mybody/isacage"]); assert.containsOnce(target, ".setmybodyfree"); @@ -1081,16 +1100,15 @@ QUnit.module("Views", (hooks) => { }; } }; - - await makeView({ - mockRPC, - serverData, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); await click(target.querySelector("a[data-method='setTheControl']")); click(target.querySelector("a[data-method='heartOfTheSun']")); @@ -1147,16 +1165,15 @@ QUnit.module("Views", (hooks) => { return true; } }; - - await makeView({ - serverData, - mockRPC, + const config = { + views: [[1, "toy"]], + }; + const env = await makeTestEnv({ serverData, mockRPC, config }); + const props = { resModel: "animal", type: "toy", - config: { - views: [[1, "toy"]], - }, - }); + }; + await mount(View, target, { env, props }); const prom = new Promise((resolve) => { const complete = (ev) => { @@ -1191,19 +1208,20 @@ QUnit.module("Views", (hooks) => { QUnit.test("rendering with given jsClass", async function (assert) { assert.expect(4); - await makeView({ - serverData, - mockRPC: (_, args) => { - assert.deepEqual(args.kwargs.views, [[false, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.deepEqual(args.kwargs.views, [[false, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy_imp", - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view.toy_imp"); assert.strictEqual( target.querySelector(".o_toy_view.toy_imp").innerText, @@ -1213,20 +1231,21 @@ QUnit.module("Views", (hooks) => { QUnit.test("rendering with loaded arch attribute 'js_class'", async function (assert) { assert.expect(4); - await makeView({ - serverData, - mockRPC: (_, args) => { - assert.deepEqual(args.kwargs.views, [[2, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.deepEqual(args.kwargs.views, [[2, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", viewId: 2, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view.toy_imp"); assert.strictEqual( target.querySelector(".o_toy_view.toy_imp").innerText, @@ -1235,17 +1254,17 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("rendering with given arch attribute 'js_class'", async function (assert) { - assert.expect(2); - await makeView({ - serverData, - mockRPC: () => { - throw new Error("no RPC expected"); - }, + const mockRPC = () => { + throw new Error("no RPC expected"); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy", arch: `Specific arch content for specific class`, fields: {}, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view.toy_imp"); assert.strictEqual( target.querySelector(".o_toy_view.toy_imp").innerText, @@ -1263,20 +1282,21 @@ QUnit.module("Views", (hooks) => { ToyView2.type = "toy"; viewRegistry.add("toy_2", ToyView2); - await makeView({ - serverData, - mockRPC: (_, args) => { - assert.deepEqual(args.kwargs.views, [[2, "toy"]]); - assert.deepEqual(args.kwargs.options, { - action_id: false, - load_filters: false, - toolbar: false, - }); - }, + const mockRPC = (_, args) => { + assert.deepEqual(args.kwargs.views, [[2, "toy"]]); + assert.deepEqual(args.kwargs.options, { + action_id: false, + load_filters: false, + toolbar: false, + }); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy_2", viewId: 2, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view.toy_imp", "jsClass from arch prefered"); } ); @@ -1284,23 +1304,22 @@ QUnit.module("Views", (hooks) => { QUnit.test( "rendering with given arch attribute 'js_class' and given jsClass", async function (assert) { - assert.expect(1); - class ToyView2 extends Component {} ToyView2.template = xml`
`; ToyView2.type = "toy"; viewRegistry.add("toy_2", ToyView2); - await makeView({ - serverData, - mockRPC: () => { - throw new Error("no RPC expected"); - }, + const mockRPC = () => { + throw new Error("no RPC expected"); + }; + const env = await makeTestEnv({ serverData, mockRPC }); + const props = { resModel: "animal", type: "toy_2", arch: ``, fields: {}, - }); + }; + await mount(View, target, { env, props }); assert.containsOnce(target, ".o_toy_view.toy_imp", "jsClass from arch prefered"); } ); @@ -1310,9 +1329,10 @@ QUnit.module("Views", (hooks) => { //////////////////////////////////////////////////////////////////////////// QUnit.test("'resModel' must be passed as prop", async function (assert) { - assert.expect(2); + const env = await makeTestEnv({ serverData }); + const props = {}; try { - await makeView({ serverData }); + await mount(View, target, { env, props }); } catch (error) { assert.step(error.message); } @@ -1320,9 +1340,10 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("'type' must be passed as prop", async function (assert) { - assert.expect(2); + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal" }; try { - await makeView({ serverData, resModel: "animal" }); + await mount(View, target, { env, props }); } catch (error) { assert.step(error.message); } @@ -1330,9 +1351,7 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("'arch' cannot be passed as prop alone", async function (assert) { - assert.expect(2); const env = await makeTestEnv({ serverData }); - const target = getFixture(); const props = { resModel: "animal", type: "toy", arch: "" }; try { await mount(View, target, { env, props }); @@ -1343,9 +1362,10 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("'fields' cannot be passed as prop alone", async function (assert) { - assert.expect(2); + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", fields: {} }; try { - await makeView({ serverData, resModel: "animal", type: "toy", fields: {} }); + await mount(View, target, { env, props }); } catch (error) { assert.step(error.message); } @@ -1353,14 +1373,10 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("'searchViewArch' cannot be passed as prop alone", async function (assert) { - assert.expect(2); + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", searchViewArch: "" }; try { - await makeView({ - serverData, - resModel: "animal", - type: "toy", - searchViewArch: "", - }); + await mount(View, target, { env, props }); } catch (error) { assert.step(error.message); } @@ -1370,9 +1386,10 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("'searchViewFields' cannot be passed as prop alone", async function (assert) { - assert.expect(2); + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", searchViewFields: {} }; try { - await makeView({ serverData, resModel: "animal", type: "toy", searchViewFields: {} }); + await mount(View, target, { env, props }); } catch (error) { assert.step(error.message); } @@ -1409,15 +1426,16 @@ QUnit.module("Views", (hooks) => { viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ - serverData, + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", domain: [[0, "=", 1]], groupBy: ["birthday"], context: { key: "val" }, orderBy: ["bar"], - }); + }; + await mount(View, target, { env, props }); } ); @@ -1433,12 +1451,13 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ - serverData, + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", noContentHelp: "
Help
", - }); + }; + await mount(View, target, { env, props }); }); QUnit.test("useSampleModel false by default", async function (assert) { @@ -1453,7 +1472,9 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ serverData, resModel: "animal", type: "toy" }); + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy" }; + await mount(View, target, { env, props }); }); QUnit.test("sample='1' on arch", async function (assert) { @@ -1468,13 +1489,14 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ - serverData, + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", arch: ``, fields: {}, - }); + }; + await mount(View, target, { env, props }); }); QUnit.test("sample='0' on arch and useSampleModel=true", async function (assert) { @@ -1489,14 +1511,15 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ - serverData, + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", useSampleModel: true, arch: ``, fields: {}, - }); + }; + await mount(View, target, { env, props }); }); QUnit.test("sample='1' on arch and useSampleModel=false", async function (assert) { @@ -1511,14 +1534,15 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ - serverData, + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", useSampleModel: false, arch: ``, fields: {}, - }); + }; + await mount(View, target, { env, props }); }); QUnit.test("useSampleModel=true", async function (assert) { @@ -1533,7 +1557,9 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ serverData, resModel: "animal", type: "toy", useSampleModel: true }); + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", useSampleModel: true }; + await mount(View, target, { env, props }); }); QUnit.test("rendering with given prop", async function (assert) { @@ -1548,12 +1574,9 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ - serverData, - resModel: "animal", - type: "toy", - specificProp: "specificProp", - }); + const env = await makeTestEnv({ serverData }); + const props = { resModel: "animal", type: "toy", specificProp: "specificProp" }; + await mount(View, target, { env, props }); }); QUnit.test( @@ -1578,8 +1601,8 @@ QUnit.module("Views", (hooks) => { ToyView.type = "toy"; viewRegistry.add("toy", ToyView, { force: true }); - await makeView({ - serverData, + const env = await makeTestEnv({ serverData }); + const props = { type: "toy", resModel: "animal", searchViewId: 1, @@ -1587,7 +1610,8 @@ QUnit.module("Views", (hooks) => { groupBy: ["birthday"], context: { search_default_filter: 1, search_default_group_by: 1 }, orderBy: ["bar"], - }); + }; + await mount(View, target, { env, props }); } ); @@ -1600,10 +1624,10 @@ QUnit.module("Views", (hooks) => { class ToyView extends Component { setup() { - owl.onWillStart(() => { + onWillStart(() => { assert.deepEqual(this.props.domain, [["type", "=", "carnivorous"]]); }); - owl.onWillUpdateProps((nextProps) => { + onWillUpdateProps((nextProps) => { assert.deepEqual(nextProps.domain, [["type", "=", "herbivorous"]]); }); } @@ -1626,7 +1650,6 @@ QUnit.module("Views", (hooks) => { Parent.template = xml``; Parent.components = { View }; - const target = getFixture(); const parent = await mount(Parent, target, { env }); parent.state.domain = [["type", "=", "herbivorous"]]; From 0c30f9e3789be25b8b3abd3f31a3b7e1416912d6 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Wed, 20 Apr 2022 13:56:08 +0000 Subject: [PATCH 5/6] [IMP] web: utils: add array intersection Part-of: odoo/odoo#87522 --- addons/web/static/src/core/utils/arrays.js | 11 +++++++++++ addons/web/static/tests/core/utils/arrays_tests.js | 10 +++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/addons/web/static/src/core/utils/arrays.js b/addons/web/static/src/core/utils/arrays.js index ded0b886d4a..f73d4cc121c 100644 --- a/addons/web/static/src/core/utils/arrays.js +++ b/addons/web/static/src/core/utils/arrays.js @@ -52,6 +52,17 @@ function _getExtractorFrom(criterion) { } } +/** + * Returns the array of elements contained in both arrays. + * + * @param {any[]} array1 + * @param {any[]} array2 + * @returns {any[]} + */ +export function intersection(array1, array2) { + return array1.filter((v) => array2.includes(v)); +} + /** * Returns an object holding different groups defined by a given criterion * or a default one. Each group is a subset of the original given list. diff --git a/addons/web/static/tests/core/utils/arrays_tests.js b/addons/web/static/tests/core/utils/arrays_tests.js index 7a52bc13483..d78bfc2098a 100644 --- a/addons/web/static/tests/core/utils/arrays_tests.js +++ b/addons/web/static/tests/core/utils/arrays_tests.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { cartesian, groupBy, sortBy, unique } from "@web/core/utils/arrays"; +import { cartesian, groupBy, intersection, sortBy, unique } from "@web/core/utils/arrays"; QUnit.module("utils", () => { QUnit.module("Arrays"); @@ -153,6 +153,14 @@ QUnit.module("utils", () => { ]); }); + QUnit.test("intersection of arrays", function (assert) { + assert.deepEqual(intersection([], [1, 2]), []); + assert.deepEqual(intersection([1, 2], []), []); + assert.deepEqual(intersection([1], [2]), []); + assert.deepEqual(intersection([1, 2], [2, 3]), [2]); + assert.deepEqual(intersection([1, 2, 3], [1, 2, 3]), [1, 2, 3]); + }); + QUnit.test("cartesian product of zero arrays", function (assert) { assert.deepEqual(cartesian(), [undefined], "the unit of the product is a singleton"); }); From 30d2d2e7dc577b543ffec7acd8d96fa3cd7b4831 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 21 Apr 2022 07:06:46 +0000 Subject: [PATCH 6/6] [FIX] web: py_js: add "true"/"false" in context Before this commit, py_js was unabled to evaluate expressions containing "true" or "false" (boolean values, JS syntax), which seems reasonnable for a python interpreter. However in Odoo, we often use that syntax in modifiers (e.g. required="true"). For that reason, we need to add those two keywords in the evaluation context. Part-of: odoo/odoo#87522 --- addons/web/static/src/core/py_js/py_builtin.js | 3 +++ addons/web/static/tests/core/py_js/py_interpreter_tests.js | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/addons/web/static/src/core/py_js/py_builtin.js b/addons/web/static/src/core/py_js/py_builtin.js index 9a16bcd57bc..bc57a9b4e21 100644 --- a/addons/web/static/src/core/py_js/py_builtin.js +++ b/addons/web/static/src/core/py_js/py_builtin.js @@ -58,4 +58,7 @@ export const BUILTINS = { }, relativedelta: PyRelativeDelta, + + true: true, + false: false, }; diff --git a/addons/web/static/tests/core/py_js/py_interpreter_tests.js b/addons/web/static/tests/core/py_js/py_interpreter_tests.js index d9d3b9c115a..6d795c41375 100644 --- a/addons/web/static/tests/core/py_js/py_interpreter_tests.js +++ b/addons/web/static/tests/core/py_js/py_interpreter_tests.js @@ -145,6 +145,11 @@ QUnit.module("py", {}, () => { } ); + QUnit.test("true and false available in context", (assert) => { + assert.strictEqual(evaluateExpr("true"), true); + assert.strictEqual(evaluateExpr("false"), false); + }); + QUnit.test("throw error if name is not defined", (assert) => { assert.throws(() => evaluateExpr("a")); });