From 28200c5ad584e9b7f51ffcf7902c8d366b93e2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Tue, 12 Jul 2022 14:21:03 +0000 Subject: [PATCH] [IMP] mail, web: adapt chatter container in new form task-2871070 Part-of: odoo/odoo#78221 --- addons/mail/__manifest__.py | 1 + .../chatter_container/chatter_container.js | 14 +- .../chatter_container/chatter_container.xml | 2 +- ...b_client_view_attachment_view_container.js | 2 +- addons/mail/static/src/models/chatter.js | 17 ++- addons/mail/static/src/models/thread.js | 4 + addons/mail/static/src/models/thread_cache.js | 1 + .../static/src/models/tracking_value_item.js | 9 +- .../models/web_client_view_attachment_view.js | 14 +- .../static/src/views/form/form_compiler.js | 144 ++++++++++++++++++ .../static/src/views/form/form_renderer.js | 48 ++++++ .../mail/static/tests/helpers/test_utils.js | 20 +-- .../tests/qunit_suite_tests/chatter_tests.js | 29 ++-- .../widgets/form_renderer_tests.js | 2 +- .../static/tests/tracking_value_tests.js | 60 ++++---- addons/web/static/src/core/utils/xml.js | 10 ++ addons/web/static/src/legacy/legacy_fields.js | 1 + .../static/src/views/form/form_compiler.js | 3 +- .../static/src/views/form/form_renderer.js | 9 +- .../static/src/views/list/list_renderer.js | 5 +- .../static/src/views/list/list_renderer.xml | 2 +- addons/web/static/tests/helpers/utils.js | 1 + .../tests/views/form/form_compiler_tests.js | 8 +- .../messaging_notification_handler_tests.js | 115 +++++++------- 24 files changed, 372 insertions(+), 149 deletions(-) create mode 100644 addons/mail/static/src/views/form/form_compiler.js create mode 100644 addons/mail/static/src/views/form/form_renderer.js diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 779db4f0546..8d3faa3c86f 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -198,6 +198,7 @@ For more specific needs, you may also assign custom-defined actions 'mail/static/src/components/*/*.scss', ('include', 'mail.assets_messaging'), 'mail/static/src/services/*.js', + 'mail/static/src/views/**/*.js', 'mail/static/src/webclient/commands/*.js', 'mail/static/src/widgets/*/*.js', 'mail/static/src/widgets/*/*.scss', diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.js b/addons/mail/static/src/components/chatter_container/chatter_container.js index 54a932bb4c3..e4df55eb19b 100644 --- a/addons/mail/static/src/components/chatter_container/chatter_container.js +++ b/addons/mail/static/src/components/chatter_container/chatter_container.js @@ -36,7 +36,7 @@ export class ChatterContainer extends Component { this.chatterId = getChatterNextTemporaryId(); this._insertFromProps(this.props); onWillUpdateProps(nextProps => this._willUpdateProps(nextProps)); - onWillDestroy(this._onWillDestroy); + onWillDestroy(() => this._onWillDestroy()); } _willUpdateProps(nextProps) { @@ -61,10 +61,10 @@ export class ChatterContainer extends Component { */ async _insertFromProps(props) { const messaging = await this.env.services.messaging.get(); - if (this.__owl__.status === 5 /* destroyed */) { + if (owl.status(this) === "destroyed") { return; } - const values = { id: this.chatterId, ...props }; + const values = { id: this.chatterId, ...props, className: undefined }; if (values.threadId === undefined) { values.threadId = clear(); } @@ -93,6 +93,10 @@ export class ChatterContainer extends Component { Object.assign(ChatterContainer, { components: { Chatter: getMessagingComponent('Chatter') }, props: { + className: { + type: String, + optional: true, + }, hasActivities: { type: Boolean, optional: true, @@ -142,6 +146,10 @@ Object.assign(ChatterContainer, { optional: true, }, threadModel: String, + webRecord: { + type: Object, + optional: true, + } }, template: 'mail.ChatterContainer', }); diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.xml b/addons/mail/static/src/components/chatter_container/chatter_container.xml index f0dc3603991..a19a5868d0a 100644 --- a/addons/mail/static/src/components/chatter_container/chatter_container.xml +++ b/addons/mail/static/src/components/chatter_container/chatter_container.xml @@ -2,7 +2,7 @@ -
+
diff --git a/addons/mail/static/src/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container.js b/addons/mail/static/src/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container.js index afdabf2e61e..f73dc25aaf7 100644 --- a/addons/mail/static/src/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container.js +++ b/addons/mail/static/src/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container.js @@ -57,7 +57,7 @@ export class WebClientViewAttachmentViewContainer extends Component { */ async _insertFromProps(props) { const messaging = await this.env.services.messaging.get(); - if (this.__owl__.status === 5 /* destroyed */) { + if (owl.status(this) === "destroyed") { this._deleteRecord(); return; } diff --git a/addons/mail/static/src/models/chatter.js b/addons/mail/static/src/models/chatter.js index d110f5a87ed..c6ca5bfebaa 100644 --- a/addons/mail/static/src/models/chatter.js +++ b/addons/mail/static/src/models/chatter.js @@ -160,6 +160,10 @@ registerModel({ * @param {string[]} [fieldNames] */ reloadParentView({ fieldNames } = {}) { + if (this.webRecord) { + this.webRecord.model.load({ resId: this.threadId }); + return; + } if (this.component) { const options = { keepChanges: true }; if (fieldNames) { @@ -301,8 +305,10 @@ registerModel({ */ _onThreadIsLoadingAttachmentsChanged() { if (!this.thread || !this.thread.isLoadingAttachments) { - this._stopAttachmentsLoading(); - this.update({ isShowingAttachmentsLoading: false }); + this.update({ + attachmentsLoaderTimer: clear(), + isShowingAttachmentsLoading: false, + }); return; } if (this.isPreparingAttachmentsLoading || this.isShowingAttachmentsLoading) { @@ -316,12 +322,6 @@ registerModel({ _prepareAttachmentsLoading() { this.update({ attachmentsLoaderTimer: insertAndReplace() }); }, - /** - * @private - */ - _stopAttachmentsLoading() { - this.update({ attachmentsLoaderTimer: clear() }); - }, }, fields: { activityBoxView: one('ActivityBoxView', { @@ -490,6 +490,7 @@ registerModel({ readonly: true, required: true, }), + webRecord: attr(), }, onChanges: [ new OnChange({ diff --git a/addons/mail/static/src/models/thread.js b/addons/mail/static/src/models/thread.js index 04d5bf73674..a25ff04d49f 100644 --- a/addons/mail/static/src/models/thread.js +++ b/addons/mail/static/src/models/thread.js @@ -951,6 +951,10 @@ registerModel({ */ async setMainAttachment(attachment) { this.update({ mainAttachment: replace(attachment) }); + if (this.model === 'account.move.line') { + // account.move.line is not actually a thread in python + return; + } await this.messaging.rpc({ model: 'ir.attachment', method: 'register_as_main_attachment', diff --git a/addons/mail/static/src/models/thread_cache.js b/addons/mail/static/src/models/thread_cache.js index 5c13e236a0a..323c7c31bd6 100644 --- a/addons/mail/static/src/models/thread_cache.js +++ b/addons/mail/static/src/models/thread_cache.js @@ -298,6 +298,7 @@ registerModel({ for (const threadView of this.threadViews) { threadView.addComponentHint('messages-loaded', { fetchedMessages }); } + this.messaging.messagingBus.trigger('o-thread-loaded-messages', { thread: this.thread }); }, }, fields: { diff --git a/addons/mail/static/src/models/tracking_value_item.js b/addons/mail/static/src/models/tracking_value_item.js index 28fe783ee35..58614331233 100644 --- a/addons/mail/static/src/models/tracking_value_item.js +++ b/addons/mail/static/src/models/tracking_value_item.js @@ -5,6 +5,10 @@ import { attr, one } from '@mail/model/model_field'; import { format } from 'web.field_utils'; import { session } from '@web/session'; +import { registry } from '@web/core/registry'; +import { deserializeDateTime } from '@web/core/l10n/dates'; + +const formatters = registry.category("formatters"); registerModel({ name: 'TrackingValueItem', @@ -41,10 +45,7 @@ registerModel({ } return format.date(this.value); case 'datetime': - if (this.value) { - return format.datetime(moment.utc(this.value)); - } - return format.datetime(this.value); + return formatters.get("datetime")(deserializeDateTime(this.value), { timezone: true }); case 'float': return format.float(this.value); case 'integer': diff --git a/addons/mail/static/src/models/web_client_view_attachment_view.js b/addons/mail/static/src/models/web_client_view_attachment_view.js index 605b3e7a9d1..9d886b950b0 100644 --- a/addons/mail/static/src/models/web_client_view_attachment_view.js +++ b/addons/mail/static/src/models/web_client_view_attachment_view.js @@ -39,7 +39,17 @@ registerModel({ * @private */ _onChangeThreadAttachmentsInWebClientView() { - if (!this.thread.mainAttachment && this.thread.attachmentsInWebClientView.length > 0) { + if (this.thread.model === 'account.move.line') { + // account.move.line is not actually a thread in python + this.setMainAttachmentFromIndex(0); + return; + } + if ( + this.thread.areAttachmentsLoaded && + !this.thread.isLoadingAttachments && + !this.thread.mainAttachment && + this.thread.attachmentsInWebClientView.length > 0 + ) { this.setMainAttachmentFromIndex(0); } }, @@ -57,7 +67,7 @@ registerModel({ }, onChanges: [ new OnChange({ - dependencies: ['thread.attachmentsInWebClientView'], + dependencies: ['thread.areAttachmentsLoaded', 'thread.attachmentsInWebClientView', 'thread.isLoadingAttachments'], methodName: '_onChangeThreadAttachmentsInWebClientView', }), ], diff --git a/addons/mail/static/src/views/form/form_compiler.js b/addons/mail/static/src/views/form/form_compiler.js new file mode 100644 index 00000000000..74feeed4915 --- /dev/null +++ b/addons/mail/static/src/views/form/form_compiler.js @@ -0,0 +1,144 @@ +/** @odoo-module */ + +import { evaluateExpr } from "@web/core/py_js/py"; +import { registry } from "@web/core/registry"; +import { SIZES } from "@web/core/ui/ui_service"; +import { patch } from "@web/core/utils/patch"; +import { append, createElement, setAttributes } from "@web/core/utils/xml"; +import { FormCompiler } from "@web/views/form/form_compiler"; + + +function compileChatter(node, params) { + let hasActivities = false; + let hasFollowers = false; + let hasMessageList = false; + let hasParentReloadOnAttachmentsChanged; + let hasParentReloadOnFollowersUpdate = false; + let hasParentReloadOnMessagePosted = false; + let isAttachmentBoxVisibleInitially = false; + for (const childNode of node.children) { + const options = evaluateExpr(childNode.getAttribute("options") || "{}"); + switch (childNode.getAttribute('name')) { + case 'activity_ids': + hasActivities = true; + break; + case 'message_follower_ids': + hasFollowers = true; + hasParentReloadOnFollowersUpdate = Boolean(options['post_refresh']); + isAttachmentBoxVisibleInitially = isAttachmentBoxVisibleInitially || Boolean(options['open_attachments']); + break; + case 'message_ids': + hasMessageList = true; + hasParentReloadOnAttachmentsChanged = options['post_refresh'] === 'always'; + hasParentReloadOnMessagePosted = Boolean(options['post_refresh']); + isAttachmentBoxVisibleInitially = isAttachmentBoxVisibleInitially || Boolean(options['open_attachments']); + break; + } + } + const chatterContainerXml = createElement("ChatterContainer"); + setAttributes(chatterContainerXml, { + "hasActivities": hasActivities, + "hasFollowers": hasFollowers, + "hasMessageList": hasMessageList, + "hasParentReloadOnAttachmentsChanged": hasParentReloadOnAttachmentsChanged, + "hasParentReloadOnFollowersUpdate": hasParentReloadOnFollowersUpdate, + "hasParentReloadOnMessagePosted": hasParentReloadOnMessagePosted, + "isAttachmentBoxVisibleInitially": isAttachmentBoxVisibleInitially, + "threadId": "props.record.resId or undefined", + "threadModel": "props.record.resModel", + "webRecord": "props.record", + }); + const chatterContainerHookXml = createElement("div"); + chatterContainerHookXml.classList.add("o_FormRenderer_chatterContainer"); + append(chatterContainerHookXml, chatterContainerXml); + return chatterContainerHookXml; +} + +function compileAttachmentPreview(node, params) { + const webClientViewAttachmentViewContainerHookXml = createElement("div"); + webClientViewAttachmentViewContainerHookXml.classList.add('o_attachment_preview'); + const webClientViewAttachmentViewContainerXml = createElement("WebClientViewAttachmentViewContainer"); + setAttributes(webClientViewAttachmentViewContainerXml, { + "threadId": "props.record.resId or undefined", + "threadModel": "props.record.resModel", + }); + append(webClientViewAttachmentViewContainerHookXml, webClientViewAttachmentViewContainerXml); + return webClientViewAttachmentViewContainerHookXml; +} + +registry.category("form_compilers").add("chatter_compiler", { + selector: "div.oe_chatter", + fn: compileChatter, +}); + +registry.category("form_compilers").add("attachment_preview_compiler", { + selector: "div.o_attachment_preview", + fn: compileAttachmentPreview, +}); + +patch(FormCompiler.prototype, 'mail', { + compile() { + // TODO no chatter if in dialog? + const res = this._super(...arguments); + const chatterContainerHookXml = res.querySelector('.o_FormRenderer_chatterContainer'); + if (!chatterContainerHookXml) { + return res; // no chatter, keep the result as it is + } + const chatterContainerXml = chatterContainerHookXml.querySelector('ChatterContainer'); + if (chatterContainerHookXml.parentNode.classList.contains('o_form_sheet')) { + setAttributes(chatterContainerXml, { + "hasExternalBorder": 'true', + "hasMessageListScrollAdjust": 'false', + }); + return res; // if chatter is inside sheet, keep it there + } + const formSheetBgXml = res.querySelector('.o_form_sheet_bg'); + const parentXml = formSheetBgXml && formSheetBgXml.parentNode; + if (!parentXml) { + return res; // miss-config: a sheet-bg is required for the rest + } + const webClientViewAttachmentViewHookXml = res.querySelector('.o_attachment_preview'); + // TODO hasAttachmentViewer should also depend on the groups= and/or invisible modifier on o_attachment_preview (see invoice form) + if (webClientViewAttachmentViewHookXml) { + // in sheet bg (attachment viewer present) + setAttributes(webClientViewAttachmentViewHookXml, { + 't-if': `hasAttachmentViewer() and uiService.size >= ${SIZES.XXL}`, + }); + const sheetBgChatterContainerHookXml = chatterContainerHookXml.cloneNode(true); + sheetBgChatterContainerHookXml.classList.add('o-isInFormSheetBg'); + setAttributes(sheetBgChatterContainerHookXml, { + 't-if': `hasAttachmentViewer() and uiService.size >= ${SIZES.XXL}`, + }); + append(formSheetBgXml, sheetBgChatterContainerHookXml); + const sheetBgChatterContainerXml = sheetBgChatterContainerHookXml.querySelector('ChatterContainer'); + setAttributes(sheetBgChatterContainerXml, { + "isInFormSheetBg": "true", + "hasExternalBorder": "true", + "hasMessageListScrollAdjust": "false", + }); + } + // after sheet bg (standard position, either aside or below) + if (webClientViewAttachmentViewHookXml) { + setAttributes(chatterContainerHookXml, { + 't-if': `!(hasAttachmentViewer() and uiService.size >= ${SIZES.XXL})`, + 't-attf-class': `{{ uiService.size >= ${SIZES.XXL} and !(hasAttachmentViewer() and uiService.size >= ${SIZES.XXL}) ? "o-aside" : "" }}`, + }); + setAttributes(chatterContainerXml, { + "isInFormSheetBg": "hasAttachmentViewer()", + "hasExternalBorder": `!(uiService.size >= ${SIZES.XXL} and !(hasAttachmentViewer() and uiService.size >= ${SIZES.XXL}))`, + "hasMessageListScrollAdjust": `uiService.size >= ${SIZES.XXL} and !(hasAttachmentViewer() and uiService.size >= ${SIZES.XXL})`, + }); + } else { + setAttributes(chatterContainerXml, { + "isInFormSheetBg": "false", + "hasExternalBorder": `uiService.size < ${SIZES.XXL}`, + "hasMessageListScrollAdjust": `uiService.size >= ${SIZES.XXL}`, + }); + setAttributes(chatterContainerHookXml, { + 't-attf-class': `{{ uiService.size >= ${SIZES.XXL} ? "o-aside" : "" }}`, + }); + } + append(parentXml, chatterContainerHookXml); + return res; + }, +}); diff --git a/addons/mail/static/src/views/form/form_renderer.js b/addons/mail/static/src/views/form/form_renderer.js new file mode 100644 index 00000000000..40237e82aa6 --- /dev/null +++ b/addons/mail/static/src/views/form/form_renderer.js @@ -0,0 +1,48 @@ +/** @odoo-module */ + +import { useModels } from '@mail/component_hooks/use_models'; +import { ChatterContainer } from "@mail/components/chatter_container/chatter_container"; +import { WebClientViewAttachmentViewContainer } from '@mail/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container'; + +import { patch } from "@web/core/utils/patch"; +import { FormRenderer } from "@web/views/form/form_renderer"; + +patch(FormRenderer.prototype, 'mail', { + setup() { + this._super(); + if (this.env.services.messaging) { + useModels(); + } + }, + + //-------------------------------------------------------------------------- + // Mail Methods + //-------------------------------------------------------------------------- + + /** + * @returns {Messaging|undefined} + */ + getMessaging() { + return this.env.services.messaging && this.env.services.messaging.modelManager.messaging; + }, + /** + * @returns {boolean} + */ + hasAttachmentViewer() { + if (!this.getMessaging() || !this.props.record.resId) { + return false; + } + const thread = this.getMessaging().models['Thread'].insert({ + id: this.props.record.resId, + model: this.props.record.resModel, + }); + return ( + thread.attachmentsInWebClientView.length > 0 + ); + }, +}); + +Object.assign(FormRenderer.components, { + ChatterContainer, + WebClientViewAttachmentViewContainer, +}); diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js index 1a7eeb30cde..b0f1900c161 100644 --- a/addons/mail/static/tests/helpers/test_utils.js +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -15,10 +15,6 @@ import { doAction, getActionManagerServerData } from "@web/../tests/webclient/he import core from 'web.core'; -import legacyViewRegistry from "web.view_registry"; -import FormView from 'web.FormView'; -import ListView from 'web.ListView'; - const { App, EventBus } = owl; const { afterNextRender } = App; const modelDefinitionsPromise = new Promise(resolve => { @@ -464,15 +460,13 @@ function getOpenFormView(afterEvent, openView) { }, })); const waitMessages = func => afterNextRender(() => afterEvent({ - eventName: 'o-thread-view-hint-processed', + eventName: 'o-thread-loaded-messages', func, message: "should wait until chatter loaded its messages", - predicate: ({ hint, threadViewer }) => { + predicate: ({ thread }) => { return ( - hint.type === 'messages-loaded' && - threadViewer && - threadViewer.thread.model === action.res_model && - threadViewer.thread.id === action.res_id + thread.model === action.res_model && + thread.id === action.res_id ); }, })); @@ -530,12 +524,6 @@ function getOpenFormView(afterEvent, openView) { * @returns {Object} */ async function start(param0 = {}) { - if (!param0.useWowlListForm) { - registry.category("views").remove("list"); // remove new list from registry - registry.category("views").remove("form"); // remove new form from registry - legacyViewRegistry.add("list", ListView); // add legacy list -> will be wrapped and added to new registry - legacyViewRegistry.add("form", FormView); // add legacy form -> will be wrapped and added to new registry - } // patch _.debounce and _.throttle to be fast and synchronous. patchWithCleanup(_, { debounce: func => func, diff --git a/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js b/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js index 10707c652ba..69326a553e6 100644 --- a/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js +++ b/addons/mail/static/tests/qunit_suite_tests/chatter_tests.js @@ -4,8 +4,8 @@ import { start, startServer } from '@mail/../tests/helpers/test_utils'; import { WEBCLIENT_LOAD_ROUTES } from '@mail/../tests/helpers/webclient_setup'; import testUtils from 'web.test_utils'; -import { patchWithCleanup } from '@web/../tests/helpers/utils'; -import { ViewAdapter } from "@web/legacy/action_adapters"; +import { clickEdit, patchWithCleanup, selectDropdownItem } from '@web/../tests/helpers/utils'; +import { ListController } from "@web/views/list/list_controller"; QUnit.module('mail', {}, function () { QUnit.module('Chatter'); @@ -37,7 +37,7 @@ QUnit.test('list activity widget with no activity', async function (assert) { assert.containsOnce(document.body, '.o_mail_activity .o_activity_color_default'); assert.strictEqual(document.querySelector('.o_activity_summary').innerText, ''); - assert.verifySteps(['/web/dataset/search_read']); + assert.verifySteps(['/web/dataset/call_kw/res.users/web_search_read']); }); QUnit.test('list activity widget with activities', async function (assert) { @@ -90,7 +90,7 @@ QUnit.test('list activity widget with activities', async function (assert) { assert.containsOnce(secondRow, '.o_mail_activity .o_activity_color_planned.fa-clock-o'); assert.strictEqual(secondRow.querySelector('.o_activity_summary').innerText, 'Type 2'); - assert.verifySteps(['/web/dataset/search_read']); + assert.verifySteps(['/web/dataset/call_kw/res.users/web_search_read']); }); QUnit.test('list activity widget with exception', async function (assert) { @@ -130,7 +130,7 @@ QUnit.test('list activity widget with exception', async function (assert) { assert.containsOnce(document.body, '.o_activity_color_today.text-warning.fa-warning'); assert.strictEqual(document.querySelector('.o_activity_summary').innerText, 'Warning'); - assert.verifySteps(['/web/dataset/search_read']); + assert.verifySteps(['/web/dataset/call_kw/res.users/web_search_read']); }); QUnit.test('list activity widget: open dropdown', async function (assert) { @@ -190,7 +190,7 @@ QUnit.test('list activity widget: open dropdown', async function (assert) { serverData: { views }, }); - patchWithCleanup(ViewAdapter.prototype, { + patchWithCleanup(ListController.prototype, { setup() { this._super(); const selectRecord = this.props.selectRecord; @@ -223,8 +223,8 @@ QUnit.test('list activity widget: open dropdown', async function (assert) { assert.strictEqual(document.querySelector('.o_activity_summary').innerText, 'Meet FP'); assert.verifySteps([ - '/web/dataset/search_read', - 'select_record [2,{\"mode\":\"readonly\"}]', + 'web_search_read', + 'select_record [2,{"activeIds":[2]}]', 'open dropdown', 'activity_format', 'action_feedback', @@ -283,9 +283,9 @@ QUnit.test('list activity exception widget with activity', async function (asser }); assert.containsN(document.body, '.o_data_row', 2, "should have two records"); - assert.doesNotHaveClass(document.querySelector('.o_data_row .o_activity_exception_cell div'), 'fa-warning', + assert.doesNotHaveClass(document.querySelector('.o_data_row .o_activity_exception_cell div div'), 'fa-warning', "there is no any exception activity on record"); - assert.hasClass(document.querySelectorAll('.o_data_row .o_activity_exception_cell div')[1], 'fa-warning', + assert.hasClass(document.querySelectorAll('.o_data_row .o_activity_exception_cell div div')[1], 'fa-warning', "there is an exception on a record"); }); @@ -479,7 +479,7 @@ QUnit.test('many2many_tags_email widget can load more than 40 records', async fu const views = { 'mail.message,false,form': '
', }; - var { click, openView } = await start({ + var { openView } = await start({ serverData: { views }, }); await openView({ @@ -490,13 +490,12 @@ QUnit.test('many2many_tags_email widget can load more than 40 records', async fu assert.strictEqual(document.querySelectorAll('.o_field_widget[name="partner_ids"] .badge').length, 100); - await click('.o_form_button_edit'); + await clickEdit(document.body); - assert.hasClass(document.querySelector('.o_legacy_form_view'), 'o_form_editable'); + assert.containsOnce(document.body, '.o_form_editable'); // add a record to the relation - await testUtils.fields.many2one.clickOpenDropdown('partner_ids'); - await testUtils.fields.many2one.clickHighlightedItem('partner_ids'); + await selectDropdownItem(document.body, 'partner_ids', "Public user"); assert.strictEqual(document.querySelectorAll('.o_field_widget[name="partner_ids"] .badge').length, 101); }); diff --git a/addons/mail/static/tests/qunit_suite_tests/widgets/form_renderer_tests.js b/addons/mail/static/tests/qunit_suite_tests/widgets/form_renderer_tests.js index 56fd181fc98..16e52299cda 100644 --- a/addons/mail/static/tests/qunit_suite_tests/widgets/form_renderer_tests.js +++ b/addons/mail/static/tests/qunit_suite_tests/widgets/form_renderer_tests.js @@ -867,7 +867,7 @@ QUnit.test('Attachments that have been unlinked from server should be visually u views: [[false, 'form']], }, { - currentId: resPartnerId1, + resId: resPartnerId1, resIds: [resPartnerId1, resPartnerId2], }, ); diff --git a/addons/test_mail/static/tests/tracking_value_tests.js b/addons/test_mail/static/tests/tracking_value_tests.js index a69bcc30ee9..22ee3554eb2 100644 --- a/addons/test_mail/static/tests/tracking_value_tests.js +++ b/addons/test_mail/static/tests/tracking_value_tests.js @@ -5,7 +5,7 @@ import { startServer, } from '@mail/../tests/helpers/test_utils'; -import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { editInput, editSelect, selectDropdownItem, patchWithCleanup, patchTimeZone } from "@web/../tests/helpers/utils"; import session from 'web.session'; import testUtils from 'web.test_utils'; @@ -34,16 +34,17 @@ QUnit.module('tracking_value_tests.js', { `, }; this.start = async ({ res_id }) => { - const { openView, ...remainder } = await start({ + const { openFormView, ...remainder } = await start({ serverData: { views }, }); - await openView( + await openFormView( { res_model: 'mail.test.track.all', res_id, - views: [[false, 'form']], }, - { mode: 'edit' } + { + props: { mode: 'edit' }, + }, ); return remainder; }; @@ -63,7 +64,7 @@ QUnit.test('basic rendering of tracking value (float type)', async function (ass const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ float_field: 12.30 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('input[name=float_field]'), 45.67); + await editInput(document.body, 'div[name=float_field] input', 45.67); await click('.o_form_button_save'); assert.containsOnce( document.body, @@ -114,7 +115,7 @@ QUnit.test('rendering of tracked field of type float: from non-0 to 0', async fu const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ float_field: 1 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('input[name=float_field]'), 0); + await editInput(document.body, 'div[name=float_field] input', 0); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -130,7 +131,7 @@ QUnit.test('rendering of tracked field of type float: from 0 to non-0', async fu const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ float_field: 0 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('input[name=float_field]'), 1); + await editInput(document.body, 'div[name=float_field] input', 1); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -146,7 +147,7 @@ QUnit.test('rendering of tracked field of type integer: from non-0 to 0', async const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ integer_field: 1 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('input[name=integer_field]'), 0); + await editInput(document.body, 'div[name=integer_field] input', 0); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -162,7 +163,7 @@ QUnit.test('rendering of tracked field of type integer: from 0 to non-0', async const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ integer_field: 0 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('input[name=integer_field]'), 1); + await editInput(document.body, 'div[name=integer_field] input', 1); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -178,7 +179,7 @@ QUnit.test('rendering of tracked field of type monetary: from non-0 to 0', async const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ monetary_field: 1 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editSelect(document.querySelector('div[name=monetary_field] > input'), 0); + await editInput(document.body, 'div[name=monetary_field] input', 0); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -194,7 +195,7 @@ QUnit.test('rendering of tracked field of type monetary: from 0 to non-0', async const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ monetary_field: 0 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editSelect(document.querySelector('div[name=monetary_field] > input'), 1); + await editInput(document.body, 'div[name=monetary_field] input', 1); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -242,7 +243,7 @@ QUnit.test('rendering of tracked field of type char: from a string to empty stri const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ char_field: 'Marc' }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('input[name=char_field]'), ''); + await editInput(document.body, 'div[name=char_field] input', ''); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -258,7 +259,7 @@ QUnit.test('rendering of tracked field of type char: from empty string to a stri const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ char_field: '' }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('input[name=char_field]'), 'Marc'); + await editInput(document.body, 'div[name=char_field] input', 'Marc'); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -274,7 +275,7 @@ QUnit.test('rendering of tracked field of type date: from no date to a set date' const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ date_field: false }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editAndTrigger(document.querySelector('.o_datepicker[name=date_field] .o_datepicker_input'), '12/14/2018', ['change']); + await testUtils.fields.editAndTrigger(document.querySelector('div[name=date_field] .o_datepicker .o_datepicker_input'), '12/14/2018', ['change']); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -290,7 +291,7 @@ QUnit.test('rendering of tracked field of type date: from a set date to no date' const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ date_field: '2018-12-14' }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editAndTrigger(document.querySelector('.o_datepicker[name=date_field] .o_datepicker_input'), '', ['change']); + await testUtils.fields.editAndTrigger(document.querySelector('div[name=date_field] .o_datepicker .o_datepicker_input'), '', ['change']); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -300,14 +301,18 @@ QUnit.test('rendering of tracked field of type date: from a set date to no date' }); QUnit.test('rendering of tracked field of type datetime: from no date and time to a set date and time', async function (assert) { - assert.expect(1); + assert.expect(2); + + patchTimeZone(180); const pyEnv = await startServer(); const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ datetime_field: false }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editAndTrigger(document.querySelector('.o_datepicker[name=datetime_field] .o_datepicker_input'), '12/14/2018 13:42:28', ['change']); + await testUtils.fields.editAndTrigger(document.querySelector('div[name=datetime_field] .o_datepicker .o_datepicker_input'), '12/14/2018 13:42:28', ['change']); await click('.o_form_button_save'); + const savedRecord = pyEnv.getData()["mail.test.track.all"].records.find(({id}) => id === mailTestTrackAllId1); + assert.strictEqual(savedRecord.datetime_field, '2018-12-14 10:42:28'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, "Datetime:None12/14/2018 13:42:28", @@ -318,15 +323,17 @@ QUnit.test('rendering of tracked field of type datetime: from no date and time t QUnit.test('rendering of tracked field of type datetime: from a set date and time to no date and time', async function (assert) { assert.expect(1); + patchTimeZone(180) + const pyEnv = await startServer(); const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ datetime_field: '2018-12-14 13:42:28 ' }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editAndTrigger(document.querySelector('.o_datepicker[name=datetime_field] .o_datepicker_input'), '', ['change']); + await testUtils.fields.editAndTrigger(document.querySelector('div[name=datetime_field] .o_datepicker .o_datepicker_input'), '', ['change']); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, - "Datetime:12/14/2018 13:42:28None", + "Datetime:12/14/2018 16:42:28None", "should display the correct content of tracked field of type datetime: from a set date and time to no date and time (Datetime: 12/14/2018 13:42:28 -> None)" ); }); @@ -338,7 +345,7 @@ QUnit.test('rendering of tracked field of type text: from some text to empty', a const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ text_field: 'Marc' }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('textarea[name=text_field]'), ''); + await editInput(document.body, 'div[name=text_field] textarea', ''); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -354,7 +361,7 @@ QUnit.test('rendering of tracked field of type text: from empty to some text', a const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ text_field: '' }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editInput(document.querySelector('textarea[name=text_field]'), 'Marc'); + await editInput(document.body, 'div[name=text_field] textarea', 'Marc'); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -370,7 +377,7 @@ QUnit.test('rendering of tracked field of type selection: from a selection to no const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ selection_field: 'first' }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editSelect(document.querySelector('select[name=selection_field]'), ''); + await editSelect(document.body, 'div[name=selection_field] select', false); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -386,7 +393,7 @@ QUnit.test('rendering of tracked field of type selection: from no selection to a const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({}); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editSelect(document.querySelector('select[name=selection_field]'), '"first"'); + await editSelect(document.body, 'div[name=selection_field] select', '"first"'); await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -403,7 +410,7 @@ QUnit.test('rendering of tracked field of type many2one: from having a related r const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ many2one_field_id: resPartnerId1 }); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.editAndTrigger(document.querySelector('.o_field_many2one_selection input'), '', ['keyup']); + await editInput(document.body, ".o_field_many2one_selection input", '') await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, @@ -420,8 +427,7 @@ QUnit.test('rendering of tracked field of type many2one: from no related record const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({}); const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - await testUtils.fields.many2one.clickOpenDropdown('many2one_field_id'); - await testUtils.fields.many2one.clickItem('many2one_field_id', 'Marc'); + await selectDropdownItem(document.body, "many2one_field_id", "Marc") await click('.o_form_button_save'); assert.strictEqual( document.querySelector('.o_TrackingValue').textContent, diff --git a/addons/web/static/src/core/utils/xml.js b/addons/web/static/src/core/utils/xml.js index aa27b3011a0..1f07f87f2c6 100644 --- a/addons/web/static/src/core/utils/xml.js +++ b/addons/web/static/src/core/utils/xml.js @@ -148,3 +148,13 @@ export function getTag(node, lower = false) { const tag = (node && node.nodeName) || ""; return lower ? tag.toLowerCase() : tag; } + +/** + * @param {Node} node + * @param {Object} attributes + */ +export function setAttributes(node, attributes) { + for (const [name, value] of Object.entries(attributes)) { + node.setAttribute(name, value); + } +} diff --git a/addons/web/static/src/legacy/legacy_fields.js b/addons/web/static/src/legacy/legacy_fields.js index d3b4c84e48c..a8e028c1960 100644 --- a/addons/web/static/src/legacy/legacy_fields.js +++ b/addons/web/static/src/legacy/legacy_fields.js @@ -215,6 +215,7 @@ function mapStaticListDatapoint(staticList) { data: staticList.records.map(mapRecordDatapoint), groupedBy: [], orderedBy: staticList.orderBy, + count: staticList.count, }; } diff --git a/addons/web/static/src/views/form/form_compiler.js b/addons/web/static/src/views/form/form_compiler.js index 0a8b7e0c5a7..d872d128145 100644 --- a/addons/web/static/src/views/form/form_compiler.js +++ b/addons/web/static/src/views/form/form_compiler.js @@ -1,6 +1,7 @@ /** @odoo-module **/ import { registry } from "@web/core/registry"; +import { SIZES } from "@web/core/ui/ui_service"; import { append, combineAttributes, @@ -187,7 +188,7 @@ export class FormCompiler extends ViewCompiler { compileForm(el, params) { const form = createElement("div", { "t-att-class": "props.class", - "t-attf-class": `{{props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}}`, + "t-attf-class": `{{props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-flex {{ uiService.size < ${SIZES.XXL} ? "flex-column" : "flex-nowrap h-100" }}`, }); const sheetNode = el.querySelector("sheet"); diff --git a/addons/web/static/src/views/form/form_renderer.js b/addons/web/static/src/views/form/form_renderer.js index b6e6f00af6e..e6b5fb9fad6 100644 --- a/addons/web/static/src/views/form/form_renderer.js +++ b/addons/web/static/src/views/form/form_renderer.js @@ -2,6 +2,9 @@ import { Notebook } from "@web/core/notebook/notebook"; import { Field } from "@web/views/fields/field"; +import { browser } from "@web/core/browser/browser"; +import { useService } from "@web/core/utils/hooks"; +import { useDebounced } from "@web/core/utils/timing"; import { ButtonBox } from "@web/views/form/button_box/button_box"; import { InnerGroup, OuterGroup } from "@web/views/form/form_group/form_group"; import { ViewButton } from "@web/views/view_button/view_button"; @@ -13,7 +16,7 @@ import { FormCompiler } from "./form_compiler"; import { FormLabel } from "./form_label"; import { StatusBarButtons } from "./status_bar_buttons/status_bar_buttons"; -const { Component, useSubEnv, useRef, useState, xml } = owl; +const { Component, onMounted, onWillUnmount, useSubEnv, useRef, useState, xml } = owl; export class FormRenderer extends Component { setup() { @@ -31,6 +34,10 @@ export class FormRenderer extends Component { useBounceButton(useRef("compiled_view_root"), () => { return !record.isInEdition; }); + this.uiService = useService('ui'); + this.onResize = useDebounced(this.render, 200); + onMounted(() => browser.addEventListener('resize', this.onResize)); + onWillUnmount(() => browser.removeEventListener('resize', this.onResize)); } evalDomainFromRecord(record, expr) { diff --git a/addons/web/static/src/views/list/list_renderer.js b/addons/web/static/src/views/list/list_renderer.js index b8060288d59..b43dd1354dd 100644 --- a/addons/web/static/src/views/list/list_renderer.js +++ b/addons/web/static/src/views/list/list_renderer.js @@ -772,7 +772,10 @@ export class ListRenderer extends Component { } } - async onCellClicked(record, column) { + async onCellClicked(record, column, ev) { + if (ev.target.special_click) { + return; + } const recordAfterResequence = async () => { const recordIndex = this.props.list.records.indexOf(record); await this.resequencePromise; diff --git a/addons/web/static/src/views/list/list_renderer.xml b/addons/web/static/src/views/list/list_renderer.xml index 87cedf898e9..6f2e0354ad3 100644 --- a/addons/web/static/src/views/list/list_renderer.xml +++ b/addons/web/static/src/views/list/list_renderer.xml @@ -182,7 +182,7 @@ - + diff --git a/addons/web/static/tests/helpers/utils.js b/addons/web/static/tests/helpers/utils.js index 006629a1c3a..0c37d449665 100644 --- a/addons/web/static/tests/helpers/utils.js +++ b/addons/web/static/tests/helpers/utils.js @@ -725,6 +725,7 @@ export async function dragAndDrop(from, to, position) { export async function clickDropdown(target, fieldName) { const dropdownInput = target.querySelector(`[name='${fieldName}'] .dropdown input`); dropdownInput.focus(); + await nextTick(); await click(dropdownInput); } diff --git a/addons/web/static/tests/views/form/form_compiler_tests.js b/addons/web/static/tests/views/form/form_compiler_tests.js index 01f44e24faa..528b216f00c 100644 --- a/addons/web/static/tests/views/form/form_compiler_tests.js +++ b/addons/web/static/tests/views/form/form_compiler_tests.js @@ -32,7 +32,7 @@ QUnit.module("Form Compiler", () => { const arch = /*xml*/ `
lol
`; const expected = /*xml*/ ` -
+
lol
`; @@ -44,7 +44,7 @@ QUnit.module("Form Compiler", () => { const arch = /*xml*/ `
lol
`; const expected = /*xml*/ ` -
+
lol @@ -128,7 +128,7 @@ QUnit.module("Form Compiler", () => { const expected = /*xml*/ ` -
+
someDiv
@@ -150,7 +150,7 @@ QUnit.module("Form Compiler", () => { const expected = /*xml*/ ` -
+
someDiv
diff --git a/addons/website_livechat/static/tests/qunit_suite_tests/messaging_notification_handler_tests.js b/addons/website_livechat/static/tests/qunit_suite_tests/messaging_notification_handler_tests.js index 8f37622d46c..f6aa35bf757 100644 --- a/addons/website_livechat/static/tests/qunit_suite_tests/messaging_notification_handler_tests.js +++ b/addons/website_livechat/static/tests/qunit_suite_tests/messaging_notification_handler_tests.js @@ -1,77 +1,66 @@ /** @odoo-module **/ -import { - afterNextRender, - start, - startServer, -} from '@mail/../tests/helpers/test_utils'; -import { ViewAdapter } from '@web/legacy/action_adapters'; -import { patchWithCleanup } from '@web/../tests/helpers/utils'; +import { afterNextRender, start, startServer } from "@mail/../tests/helpers/test_utils"; +import FormView from "web.FormView"; -import FormView from 'web.FormView'; +QUnit.module("website_livechat", {}, function () { + QUnit.module("messaging_notification_handler_tests.js"); -QUnit.module('website_livechat', {}, function () { -QUnit.module('messaging_notification_handler_tests.js'); + QUnit.test( + "should open chat window on send chat request to website visitor", + async function (assert) { + assert.expect(3); -QUnit.test('should open chat window on send chat request to website visitor', async function (assert) { - assert.expect(3); - - const pyEnv = await startServer(); - const websiteVisitorId1 = pyEnv['website.visitor'].create({ - display_name: "Visitor #11", - }); - const views = { - 'website.visitor,false,form': - `
+ const pyEnv = await startServer(); + const websiteVisitorId1 = pyEnv["website.visitor"].create({ + display_name: "Visitor #11", + }); + const views = { + "website.visitor,false,form": `
`, - }; - const { openView } = await start({ - serverData: { views }, - View: FormView, - }); - await openView({ - res_model: 'website.visitor', - res_id: websiteVisitorId1, - views: [[false, 'form']], - }); - patchWithCleanup(ViewAdapter.prototype, { - _trigger_up: async function({ name, data }) { - if (name === 'execute_action') { - this.env.services.rpc({ - route: '/web/dataset/call_button', - params: { - args: [data.env.resIDs], - kwargs: { context: data.env.context }, - method: data.action_data.name, - model: data.env.model, - } + }; + + const { openView, env } = await start({ + serverData: { views }, + View: FormView, + }); + + await openView({ + res_model: "website.visitor", + res_id: websiteVisitorId1, + views: [[false, "form"]], + }); + + // Simulate a click on "Send chat request" + // This is a bit of a hack as it doesn't require the button at all to work. + await afterNextRender(async () => { + await env.services.rpc("/web/dataset/call_button", { + args: [websiteVisitorId1], + kwargs: { context: env.context }, + method: "action_send_chat_request", + model: "website.visitor", }); - } - }, - }); + }); - await afterNextRender(() => - document.querySelector('button[name="action_send_chat_request"]').click() - ); - assert.containsOnce( - document.body, - '.o_ChatWindow', - "should have a chat window open after sending chat request to website visitor" - ); - assert.hasClass( - document.querySelector('.o_ChatWindow'), - 'o-focused', - "chat window of livechat should be focused on open" - ); - assert.strictEqual( - document.querySelector('.o_ChatWindowHeader_name').textContent, - "Visitor #11", - "chat window of livechat should have name of visitor in the name" + assert.containsOnce( + document.body, + ".o_ChatWindow", + "should have a chat window open after sending chat request to website visitor" + ); + assert.hasClass( + document.querySelector(".o_ChatWindow"), + "o-focused", + "chat window of livechat should be focused on open" + ); + assert.strictEqual( + document.querySelector(".o_ChatWindowHeader_name").textContent, + "Visitor #11", + "chat window of livechat should have name of visitor in the name" + ); + } ); }); - -});