[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) <tde@openerp.com>
This commit is contained in:
Renaud Thiry
2022-09-28 14:19:11 +02:00
parent c96c8b27f7
commit 0553e16dbd
11 changed files with 142 additions and 7 deletions
@@ -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',
+1 -1
View File
@@ -1338,7 +1338,7 @@
<t t-name="FieldBinaryFileUploader.attachment_preview">
<t t-set="url" t-value="widget.metadata[file.id] ? widget.metadata[file.id].url : false"/>
<t t-if="file.data" t-set="file" t-value="file.data"/>
<t t-set="editable" t-value="widget.mode === 'edit'"/>
<t t-set="editable" t-value="widget.mode === 'edit' and !(file.res_id === 0 and file.access_token)"/>
<t t-if="file.mimetype" t-set="mimetype" t-value="file.mimetype"/>
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' } #{upload ? 'o_attachment_uploading' : ''}" t-att-title="file.name">
<div class="o_attachment_wrap">
@@ -25,7 +25,7 @@
</t>
<t t-name="Many2ManyBinaryField.attachment_preview" owl="1">
<t t-set="editable" t-value="!props.readonly"/>
<t t-set="editable" t-value="!props.readonly and !(file.res_id === 0 and file.access_token)"/>
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' } #{upload ? 'o_attachment_uploading' : ''}" t-att-title="file.name">
<div class="o_attachment_wrap">
<t t-set="ext" t-value="getExtension(file)"/>
@@ -46,7 +46,7 @@
</div>
<div class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
<div t-if="!props.readonly" class="o_attachment_delete" t-on-click.stop="() => this.onFileRemove(file.id)"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
<div t-if="editable" class="o_attachment_delete" t-on-click.stop="() => this.onFileRemove(file.id)"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
</div>
</div>
</t>
@@ -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);
},
@@ -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"]);
}
},
});
@@ -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) {
@@ -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,
},
});
}
@@ -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;
}
@@ -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 });
}
@@ -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;
@@ -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": "<search></search>",
"note.note,false,form": `
<form>
<field name="body" type="html"/>
</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);