From 0553e16dbdc7fddd75b9f70905c32943fed6896a Mon Sep 17 00:00:00 2001 From: Renaud Thiry Date: Mon, 29 Aug 2022 08:26:50 +0000 Subject: [PATCH] [FIX] mail, web_editor: dialog file update event An event was not called on uploading files in a media dialog. This meant the parent of html field didn't know of the new attachments which resulted in attachments being linked to no record at all and not being garbage collected later. This adds a reference inside the media dialog file input so that it can trigger that event on behalf of the html field. The attachments newly uploaded in media dialog are uneditable to prevent users from unlinking attachments that are still used in the body of the composer (causing the same issue) Task-2860761 closes odoo/odoo#101405 X-original-commit: f4dd1eb3f25672e7309759c347e2b552c5652b2f Signed-off-by: Thibault Delavallee (tde) --- .../src/legacy/js/fields/relational_fields.js | 2 + addons/web/static/src/legacy/xml/base.xml | 2 +- .../many2many_binary_field.xml | 4 +- .../legacy/fields/relational_fields_tests.js | 5 +- .../fields/many2many_binary_field_tests.js | 2 +- .../components/media_dialog/file_selector.js | 3 + .../components/media_dialog/media_dialog.js | 1 + .../static/src/js/backend/field_html.js | 5 +- .../static/src/js/backend/html_field.js | 10 ++ .../static/src/js/wysiwyg/wysiwyg.js | 8 ++ .../static/tests/field_html_tests.js | 107 ++++++++++++++++++ 11 files changed, 142 insertions(+), 7 deletions(-) diff --git a/addons/web/static/src/legacy/js/fields/relational_fields.js b/addons/web/static/src/legacy/js/fields/relational_fields.js index 0328f650938..f985f187f61 100644 --- a/addons/web/static/src/legacy/js/fields/relational_fields.js +++ b/addons/web/static/src/legacy/js/fields/relational_fields.js @@ -2370,6 +2370,8 @@ var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({ fieldsToFetch: { name: {type: 'char'}, mimetype: {type: 'char'}, + res_id: {type: 'number'}, + access_token: {type: 'char'}, }, events: { 'click .o_attach': '_onAttach', diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index 282924134c1..1ba74e6c8c7 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -1338,7 +1338,7 @@ - +
diff --git a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml index 2afe2c5a19c..ed18b060e35 100644 --- a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml +++ b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.xml @@ -25,7 +25,7 @@ - +
@@ -46,7 +46,7 @@
-
×
+
×
diff --git a/addons/web/static/tests/legacy/fields/relational_fields_tests.js b/addons/web/static/tests/legacy/fields/relational_fields_tests.js index 2ae838787dc..c98ea14e015 100644 --- a/addons/web/static/tests/legacy/fields/relational_fields_tests.js +++ b/addons/web/static/tests/legacy/fields/relational_fields_tests.js @@ -2977,11 +2977,14 @@ QUnit.module('Legacy relational_fields', { fields: { name: {string:"Name", type: "char"}, mimetype: {string: "Mimetype", type: "char"}, + res_id: {type: "number"}, + access_token: {type: "char"} }, records: [{ id: 17, name: 'Marley&Me.jpg', mimetype: 'jpg', + res_id: 1, //non-zero to avoid transiant model editor attachment protection }], }; this.data.turtle.fields.picture_ids = { @@ -3005,7 +3008,7 @@ QUnit.module('Legacy relational_fields', { mockRPC: function (route, args) { assert.step(route); if (route === '/web/dataset/call_kw/ir.attachment/read') { - assert.deepEqual(args.args[1], ['name', 'mimetype']); + assert.deepEqual(args.args[1], ['name', 'mimetype', 'res_id', 'access_token']); } return this._super.apply(this, arguments); }, diff --git a/addons/web/static/tests/views/fields/many2many_binary_field_tests.js b/addons/web/static/tests/views/fields/many2many_binary_field_tests.js index 796327e478b..cf80b3fcd33 100644 --- a/addons/web/static/tests/views/fields/many2many_binary_field_tests.js +++ b/addons/web/static/tests/views/fields/many2many_binary_field_tests.js @@ -96,7 +96,7 @@ QUnit.module("Fields", (hooks) => { assert.step(route); } if (route === "/web/dataset/call_kw/ir.attachment/read") { - assert.deepEqual(args.args[1], ["name", "mimetype"]); + assert.deepEqual(args.args[1], ["name", "mimetype", "res_id", "access_token"]); } }, }); diff --git a/addons/web_editor/static/src/components/media_dialog/file_selector.js b/addons/web_editor/static/src/components/media_dialog/file_selector.js index 21a4acc5799..09e67424cfa 100644 --- a/addons/web_editor/static/src/components/media_dialog/file_selector.js +++ b/addons/web_editor/static/src/components/media_dialog/file_selector.js @@ -239,6 +239,9 @@ export class FileSelector extends Component { if (!this.props.multiSelect) { await this.props.save(); } + if (this.props.onAttachmentChange) { + this.props.onAttachmentChange(attachment); + } } onRemoved(attachmentId) { diff --git a/addons/web_editor/static/src/components/media_dialog/media_dialog.js b/addons/web_editor/static/src/components/media_dialog/media_dialog.js index cb47d0edead..f7b33257a3f 100644 --- a/addons/web_editor/static/src/components/media_dialog/media_dialog.js +++ b/addons/web_editor/static/src/components/media_dialog/media_dialog.js @@ -83,6 +83,7 @@ export class MediaDialog extends Component { selectedMedia: this.selectedMedia, selectMedia: (...args) => this.selectMedia(...args, tab.id, additionalProps.multiSelect), save: this.save.bind(this), + onAttachmentChange: this.props.onAttachmentChange, }, }); } diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js index 8a810a0b27b..0a4db994d95 100644 --- a/addons/web_editor/static/src/js/backend/field_html.js +++ b/addons/web_editor/static/src/js/backend/field_html.js @@ -331,9 +331,10 @@ var FieldHtml = basic_fields.DebouncedField.extend(DynamicPlaceholderFieldMixin) * when closing the wizard. * * @private - * @param {Object} attachments + * @param {Object} event the event containing attachment data */ - _onAttachmentChange: function (attachments) { + _onAttachmentChange: function (event) { + const attachments = event.data; if (!this.fieldNameAttachment) { return; } diff --git a/addons/web_editor/static/src/js/backend/html_field.js b/addons/web_editor/static/src/js/backend/html_field.js index 16b398c4639..36013ee9615 100644 --- a/addons/web_editor/static/src/js/backend/html_field.js +++ b/addons/web_editor/static/src/js/backend/html_field.js @@ -145,6 +145,7 @@ export class HtmlField extends Component { return { value: this.props.value, autostart: false, + onAttachmentChange: this._onAttachmentChange.bind(this), onWysiwygBlur: this._onWysiwygBlur.bind(this), ...this.props.wysiwygOptions, recordInfo: { @@ -397,6 +398,15 @@ export class HtmlField extends Component { async _getWysiwygClass() { return getWysiwygClass(); } + _onAttachmentChange(attachment) { + if (!this.props.record.fieldNames.includes('attachment_ids')) { + return; + } + this.props.record.update(_.object(['attachment_ids'], [{ + operation: 'ADD_M2M', + ids: attachment + }])); + } _onWysiwygBlur() { this.commitChanges({ urgent: true }); } diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 8080389feb5..f12fa89e336 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -1303,6 +1303,7 @@ const Wysiwyg = Widget.extend({ node: params.node, restoreSelection: restoreSelection, }), + onAttachmentChange: this._onAttachmentChange.bind(this), close: () => restoreSelection(), ...this.options.mediaModalParams, ...params, @@ -2310,6 +2311,13 @@ const Wysiwyg = Widget.extend({ } return new Promise(function () {}); }, + _onAttachmentChange(attachment) { + // todo: to remove when removing the legacy field_html + this.trigger_up('attachment_changed', attachment); + if (this.options.onAttachmentChange) { + this.options.onAttachmentChange(attachment); + } + }, _onSelectionChange() { if (this.options.autohideToolbar) { const isVisible = this.linkPopover && this.linkPopover.el.offsetParent; diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js index 4afbc222408..1811dd5e64b 100644 --- a/addons/web_editor/static/tests/field_html_tests.js +++ b/addons/web_editor/static/tests/field_html_tests.js @@ -13,6 +13,14 @@ var LinkDialog = require('wysiwyg.widgets.LinkDialog'); const { legacyExtraNextTick, patchWithCleanup } = require("@web/../tests/helpers/utils"); +const { HtmlField } = require('@web_editor/js/backend/html_field'); +const { FileSelectorControlPanel } = require('@web_editor/components/media_dialog/file_selector'); +const { getFixture } = require("@web/../tests/helpers/utils"); +const Registry = require('@web/core/registry'); +const UploadService = require('@web_editor/components/upload_progress_toast/upload_service'); +const UnsplashService = require('@web_unsplash/services/unsplash_service'); +const webClientHelpers = require("@web/../tests/webclient/helpers"); + const { useEffect } = owl; var _t = core._t; @@ -314,6 +322,105 @@ QUnit.module('web_editor', {}, function () { form.destroy(); }); + QUnit.test('media dialog: upload', async function (assert) { + assert.expect(3); + + const onAttachmentChangeTriggered = testUtils.makeTestPromise(); + patchWithCleanup(HtmlField.prototype, { + '_onAttachmentChange': function (event) { + onAttachmentChangeTriggered.resolve(true); + } + }); + + const defFileSelector = testUtils.makeTestPromise(); + const onChangeTriggered = testUtils.makeTestPromise(); + patchWithCleanup(FileSelectorControlPanel.prototype, { + setup() { + this._super(); + useEffect(() => { + defFileSelector.resolve(true); + }, () => []); + }, + async onChangeFileInput() { + this._super(); + onChangeTriggered.resolve(true); + } + }); + + // create and load form view + const serviceRegistry = Registry.registry.category("services"); + serviceRegistry.add("upload", UploadService.uploadService); + serviceRegistry.add("unsplash", UnsplashService.unsplashService); + + const serverData = { + models: this.data, + }; + serverData.actions = { + 1: { + id: 1, + name: "test", + res_model: "note.note", + type: "ir.actions.act_window", + views: [[false, "form"]], + }, + }; + serverData.views = { + "note.note,false,search": "", + "note.note,false,form": ` +
+ + `, + }; + const mockRPC = (route, args) => { + if (route === "/web_editor/attachment/add_data") { + return Promise.resolve({"id": 5, "name": "test.jpg", "description": false, "mimetype": "image/jpeg", "checksum": "7951a43bbfb08fd742224ada280913d1897b89ab", + "url": false, "type": "binary", "res_id": 1, "res_model": "note.note", "public": false, "access_token": false, + "image_src": "/web/image/1-a0e63e61/test.jpg", "image_width": 1, "image_height": 1, "original_id": false + }); + } + else if (route === "/web/dataset/call_kw/ir.attachment/generate_access_token") { + return Promise.resolve(["129a52e1-6bf2-470a-830e-8e368b022e13"]); + } + }; + const webClient = await webClientHelpers.createWebClient({ serverData, mockRPC }); + await webClientHelpers.doAction(webClient, 1); + + //trigger wysiwyg mediadialog + + const fixture = getFixture(); + const formField = fixture.querySelector('.o_field_html[name="body"]'); + const textInput = formField.querySelector('.note-editable p'); + textInput.innerText = "test"; + const pText = $(textInput).contents()[0]; + Wysiwyg.setRange(pText, 1, pText, 2); + await new Promise((resolve) => setTimeout(resolve)); //ensure fully set up + + const wysiwyg = $(textInput.parentElement).data('wysiwyg'); + + wysiwyg.openMediaDialog(); + assert.ok(await Promise.race([defFileSelector, new Promise((res, _) => setTimeout(() => res(false), 400))]), "File Selector did not mount"); + + // upload test + + const fileInputs = document.querySelectorAll(".o_select_media_dialog input.d-none.o_file_input"); + const fileB64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q=='; + const fileBytes = new Uint8Array(atob(fileB64).split('').map(char => char.charCodeAt(0))); + // redefine 'files' so we can put mock data in through js + fileInputs.forEach((input) => Object.defineProperty(input, 'files', { + value: [new File(fileBytes, "test.jpg", { type: 'image/jpeg' })], + })); + fileInputs.forEach(input => { + input.dispatchEvent(new Event('change', {})); + }); + + assert.ok(await Promise.race([onChangeTriggered, new Promise((res, _) => setTimeout(() => res(false), 400))]), + "File change event was not triggered"); + + assert.ok(await Promise.race([onAttachmentChangeTriggered, new Promise((res, _) => setTimeout(() => res(false), 400))]), + "_onAttachmentChange was not called with the new attachment, necessary for unsused upload cleanup on backend"); + + }); + QUnit.test('media dialog: image', async function (assert) { assert.expect(1);