[IMP] mrp: migrate mrp_documents_kanban to wowl2

This views has multiple custom features such as file upload, attachment
viewer and download.
A lot of files have been rendered obsolete and have been deleted,
notably `file_upload_mixin` which has been replaced by the
file_upload service and hooks.

closes odoo/odoo#99769

Related: odoo/enterprise#31145
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
William Braeckman
2022-09-12 17:33:14 +02:00
parent 78c59afd13
commit 44cfa03587
36 changed files with 729 additions and 1650 deletions
@@ -1,415 +0,0 @@
/** @odoo-module **/
import core from 'web.core';
import Widget from 'web.Widget';
import { hidePDFJSButtons } from '@web/legacy/js/libs/pdfjs';
var QWeb = core.qweb;
var SCROLL_ZOOM_STEP = 0.1;
var ZOOM_STEP = 0.5;
/**
* This widget is deprecated, and should instead use AttachmentViewer component.
* @see `mail/static/src/components/attachment_viewer/attachment_viewer.js`
* TODO: remove this widget when it's not longer used
*
* @deprecated
*/
var DocumentViewer = Widget.extend({
template: "DocumentViewer",
events: {
'click .o_download_btn': '_onDownload',
'click .o_viewer_img': '_onImageClicked',
'click .o_viewer_video': '_onVideoClicked',
'click .move_next': '_onNext',
'click .move_previous': '_onPrevious',
'click .o_rotate': '_onRotate',
'click .o_zoom_in': '_onZoomIn',
'click .o_zoom_out': '_onZoomOut',
'click .o_zoom_reset': '_onZoomReset',
'click .o_close_btn, .o_viewer_img_wrapper': '_onClose',
'click .o_print_btn': '_onPrint',
'DOMMouseScroll .o_viewer_content': '_onScroll', // Firefox
'mousewheel .o_viewer_content': '_onScroll', // Chrome, Safari, IE
'keydown': '_onKeydown',
'keyup': '_onKeyUp',
'mousedown .o_viewer_img': '_onStartDrag',
'mousemove .o_viewer_content': '_onDrag',
'mouseup .o_viewer_content': '_onEndDrag'
},
/**
* The documentViewer takes an array of objects describing attachments in
* argument, and the ID of an active attachment (the one to display first).
* Documents that are not of type image or video are filtered out.
*
* @override
* @param {Array<Object>} attachments list of attachments
* @param {integer} activeAttachmentID
*/
init: function (parent, attachments, activeAttachmentID) {
this._super.apply(this, arguments);
this.attachment = _.filter(attachments, function (attachment) {
var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)");
if (match) {
attachment.fileType = match[1];
if (match[1].match("(.png|.jpg|.gif)")) {
attachment.fileType = 'image';
}
if (match[1] === 'youtu') {
var youtube_array = attachment.url.split('/');
var youtube_token = youtube_array[youtube_array.length-1];
if (youtube_token.indexOf('watch') !== -1) {
youtube_token = youtube_token.split('v=')[1];
var amp = youtube_token.indexOf('&')
if (amp !== -1){
youtube_token = youtube_token.substring(0, amp);
}
}
attachment.youtube = youtube_token;
}
return true;
}
});
this.activeAttachment = _.findWhere(attachments, {id: activeAttachmentID});
this.modelName = 'ir.attachment';
this._reset();
},
/**
* Do some actions after the widget is appended to the DOM
* @override
*/
setElement: function () {
const result = this._super(...arguments);
this._hidePdfButtonsIfPresent();
return result;
},
/**
* Open a modal displaying the active attachment
* @override
*/
start: function () {
this.$el.modal('show');
this.$el.on('hidden.bs.modal', _.bind(this._onDestroy, this));
this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
this.$('[data-bs-toggle="tooltip"]').tooltip({delay: 0});
return this._super.apply(this, arguments);
},
/**
* @override
*/
destroy: function () {
if (this.isDestroyed()) {
return;
}
this.$el.modal('hide');
this.$el.remove();
this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//---------------------------------------------------------------------------
/**
* Hide some buttons in PDF.js
* @override
*/
_hidePdfButtonsIfPresent: function () {
if (this.activeAttachment.mimetype === 'application/pdf') {
hidePDFJSButtons(this.el);
}
},
/**
* @private
*/
_next: function () {
var index = _.findIndex(this.attachment, this.activeAttachment);
index = (index + 1) % this.attachment.length;
this.activeAttachment = this.attachment[index];
this._updateContent();
},
/**
* @private
*/
_previous: function () {
var index = _.findIndex(this.attachment, this.activeAttachment);
index = index === 0 ? this.attachment.length - 1 : index - 1;
this.activeAttachment = this.attachment[index];
this._updateContent();
},
/**
* @private
*/
_reset: function () {
this.scale = 1;
this.dragStartX = this.dragstopX = 0;
this.dragStartY = this.dragstopY = 0;
},
/**
* Render the active attachment
*
* @private
*/
_updateContent: function () {
this.$('.o_viewer_content').html(QWeb.render('DocumentViewer.Content', {
widget: this
}));
this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
this._hidePdfButtonsIfPresent();
this.$('[data-bs-toggle="tooltip"]').tooltip({delay: 0});
this._reset();
},
/**
* Get CSS transform property based on scale and angle
*
* @private
* @param {float} scale
* @param {float} angle
*/
_getTransform: function(scale, angle) {
return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)';
},
/**
* Rotate image clockwise by provided angle
*
* @private
* @param {float} angle
*/
_rotate: function (angle) {
this._reset();
var new_angle = (this.angle || 0) + angle;
this.$('.o_viewer_img').css('transform', this._getTransform(this.scale, new_angle));
this.$('.o_viewer_img').css('max-width', new_angle % 180 !== 0 ? $(document).height() : '100%');
this.$('.o_viewer_img').css('max-height', new_angle % 180 !== 0 ? $(document).width() : '100%');
this.angle = new_angle;
},
/**
* Zoom in/out image by provided scale
*
* @private
* @param {integer} scale
*/
_zoom: function (scale) {
if (scale > 0.5) {
this.$('.o_viewer_img').css('transform', this._getTransform(scale, this.angle || 0));
this.scale = scale;
}
this.$('.o_zoom_reset').add('.o_zoom_out').toggleClass('disabled', scale === 1);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} e
*/
_onClose: function (e) {
e.preventDefault();
this.destroy();
},
/**
* When popup close complete destroyed modal even DOM footprint too
*
* @private
*/
_onDestroy: function () {
this.destroy();
},
/**
* @private
* @param {MouseEvent} e
*/
_onDownload: function (e) {
e.preventDefault();
window.location = '/web/content/' + this.modelName + '/' + this.activeAttachment.id + '/' + 'datas' + '?download=true';
},
/**
* @private
* @param {MouseEvent} e
*/
_onDrag: function (e) {
e.preventDefault();
if (this.enableDrag) {
var $image = this.$('.o_viewer_img');
var $zoomer = this.$('.o_viewer_zoomer');
var top = $image.prop('offsetHeight') * this.scale > $zoomer.height() ? e.clientY - this.dragStartY : 0;
var left = $image.prop('offsetWidth') * this.scale > $zoomer.width() ? e.clientX - this.dragStartX : 0;
$zoomer.css("transform", "translate3d("+ left +"px, " + top + "px, 0)");
$image.css('cursor', 'move');
}
},
/**
* @private
* @param {MouseEvent} e
*/
_onEndDrag: function (e) {
e.preventDefault();
if (this.enableDrag) {
this.enableDrag = false;
this.dragstopX = e.clientX - this.dragStartX;
this.dragstopY = e.clientY - this.dragStartY;
this.$('.o_viewer_img').css('cursor', '');
}
},
/**
* On click of image do not close modal so stop event propagation
*
* @private
* @param {MouseEvent} e
*/
_onImageClicked: function (e) {
e.stopPropagation();
},
/**
* Remove loading indicator when image loaded
* @private
*/
_onImageLoaded: function () {
this.$('.o_loading_img').hide();
},
/**
* Move next previous attachment on keyboard right left key
*
* @private
* @param {KeyEvent} e
*/
_onKeydown: function (e){
switch (e.which) {
case $.ui.keyCode.RIGHT:
e.preventDefault();
this._next();
break;
case $.ui.keyCode.LEFT:
e.preventDefault();
this._previous();
break;
}
},
/**
* Close popup on ESCAPE keyup
*
* @private
* @param {KeyEvent} e
*/
_onKeyUp: function (e) {
switch (e.which) {
case $.ui.keyCode.ESCAPE:
e.preventDefault();
this._onClose(e);
break;
}
},
/**
* @private
* @param {MouseEvent} e
*/
_onNext: function (e) {
e.preventDefault();
this._next();
},
/**
* @private
* @param {MouseEvent} e
*/
_onPrevious: function (e) {
e.preventDefault();
this._previous();
},
/**
* @private
* @param {MouseEvent} e
*/
_onPrint: function (e) {
e.preventDefault();
var src = this.$('.o_viewer_img').prop('src');
var script = QWeb.render('PrintImage', {
src: src
});
var printWindow = window.open('about:blank', "_new");
printWindow.document.open();
printWindow.document.write(script);
printWindow.document.close();
},
/**
* Zoom image on scroll
*
* @private
* @param {MouseEvent} e
*/
_onScroll: function (e) {
var scale;
if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
scale = this.scale + SCROLL_ZOOM_STEP;
this._zoom(scale);
} else {
scale = this.scale - SCROLL_ZOOM_STEP;
this._zoom(scale);
}
},
/**
* @private
* @param {MouseEvent} e
*/
_onStartDrag: function (e) {
e.preventDefault();
this.enableDrag = true;
this.dragStartX = e.clientX - (this.dragstopX || 0);
this.dragStartY = e.clientY - (this.dragstopY || 0);
},
/**
* On click of video do not close modal so stop event propagation
* and provide play/pause the video instead of quitting it
*
* @private
* @param {MouseEvent} e
*/
_onVideoClicked: function (e) {
e.stopPropagation();
var videoElement = e.target;
if (videoElement.paused) {
videoElement.play();
} else {
videoElement.pause();
}
},
/**
* @private
* @param {MouseEvent} e
*/
_onRotate: function (e) {
e.preventDefault();
this._rotate(90);
},
/**
* @private
* @param {MouseEvent} e
*/
_onZoomIn: function (e) {
e.preventDefault();
var scale = this.scale + ZOOM_STEP;
this._zoom(scale);
},
/**
* @private
* @param {MouseEvent} e
*/
_onZoomOut: function (e) {
e.preventDefault();
var scale = this.scale - ZOOM_STEP;
this._zoom(scale);
},
/**
* @private
* @param {MouseEvent} e
*/
_onZoomReset: function (e) {
e.preventDefault();
this.$('.o_viewer_zoomer').css("transform", "");
this._zoom(1);
},
});
export default DocumentViewer;
-115
View File
@@ -37,121 +37,6 @@
.o_modal_fullscreen {
z-index: $o-mail-thread-window-zindex + 1;
.o_viewer_content {
display: flex;
flex-direction: column;
justify-content: flex-start;
position: relative;
width: 100%;
height: 100%;
padding: 0;
/* Act as `modal-content` */
& > div {
pointer-events: auto;
}
.o_viewer-header {
display: flex;
align-items: center;
min-height: $o-navbar-height;
background-color: rgba(black, 0.8);
z-index: 1;
color: #FFFFFF;
a {
@include o-hover-text-color(rgba(map-get($theme-colors, 'light'), 0.6), white);
}
.o_image_caption {
display: flex;
align-items: center;
min-width: 0;
margin-inline-start: map-get($spacers, 4);
.o_viewer_document_name {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.o_loading_img {
@include o-position-absolute($top: 45%, $right: 0, $left: 0);
}
.o_viewer_img_wrapper {
cursor: pointer;
width: 100%;
height: 100%;
background-color: rgba(black, 0.7);
.o_viewer_zoomer {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
img {
image-orientation: from-image; // Only supported in Firefox
}
img, video {
cursor :auto;
max-width: 100%;
max-height: 100%;
transition: 0.2s cubic-bezier(0, 0, 0.49, 1.6) 0s, opacity 0.15s;
box-shadow: 0 0 40px black;
}
.o_viewer_pdf {
width: 80%;
height: 100%;
border: 0px;
box-shadow: 1px 1px 20px 1px #000;
}
.o_viewer_text {
width: 80%;
height: 100%;
border: 0px;
box-shadow: 1px 1px 20px 1px #000;
background-color: white;
}
.o_viewer_video {
height: 80%;
}
@include media-breakpoint-down(md) {
.o_viewer_pdf, .o_viewer_text {
width: 100%;
}
}
}
}
.o_viewer_toolbar {
@include o-position-absolute($bottom: $grid-gutter-width*0.5);
width: 100%;
overflow: hidden;
justify-content: center;
border-radius: 4px;
> .btn-group {
background-color: rgba(black, 0.4);
}
.o_viewer_toolbar_btn {
@include o-viewer-black-btn;
padding-left: 8px;
padding-right: 8px;
}
}
}
.arrow {
@include o-position-absolute(50%, $grid-gutter-width*0.5);
border-radius: 100%;
-100
View File
@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!--
@param {mail.DocumentViewer} widget
-->
<t t-name="DocumentViewer.Content">
<div class="o_viewer_content modal-dialog modal-fullscreen">
<t t-set="model" t-value="widget.modelName"/>
<div class="o_viewer-header">
<span class="o_image_caption">
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
<span class="o_viewer_document_name" t-esc="widget.activeAttachment.name"/>
</span>
<div class="flex-grow-1"/>
<a class="o_download_btn o_document_viewer_topbar_button btn" href="#" title="Download">
<i class="fa fa-fw fa-download" role="img" aria-label="Download"/>
<span class="d-none d-md-inline ms-2">Download</span>
</a>
<a role="button" href="#" class="o_close_btn o_document_viewer_topbar_button btn" title="Close">
<i class="fa fa-fw fa-close" role="img" aria-label="Close"/>
</a>
</div>
<div class="o_viewer_img_wrapper">
<div class="o_viewer_zoomer">
<t t-if="widget.activeAttachment.fileType === 'image'">
<div class="o_loading_img text-center">
<i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
</div>
<t t-set="unique" t-value="widget.activeAttachment.checksum ? widget.activeAttachment.checksum.slice(-8) : ''"/>
<img class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=#{unique}&amp;model=#{model}" alt="Viewer"/>
</t>
<iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}%26filename%3D#{window.encodeURIComponent(widget.activeAttachment.name)}" />
<iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model=#{model}" />
<iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
<video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
<source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
</video>
</div>
</div>
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-bs-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-bs-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_out disabled" data-bs-toggle="tooltip" title="Zoom Out"><i class="fa fa-fw fa-minus" role="img" aria-label="Zoom Out"/></a>
</div>
<div class="btn-group" role="group">
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_rotate" data-bs-toggle="tooltip" title="Rotate"><i class="fa fa-fw fa-repeat" role="img" aria-label="Rotate"/></a>
</div>
<div class="btn-group" role="group">
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_print_btn" data-bs-toggle="tooltip" title="Print"><i class="fa fa-fw fa-print" role="img" aria-label="Print"/></a>
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_download_btn" data-bs-toggle="tooltip" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
</div>
</div>
</div>
</t>
<!--
@param {mail.DocumentViewer} widget
-->
<t t-name="DocumentViewer">
<div class="modal o_modal_fullscreen" tabindex="-1" data-keyboard="false" role="dialog">
<t class="o_document_viewer_content_call" t-call="DocumentViewer.Content"/>
<t t-if="widget.attachment.length !== 1">
<a class="arrow arrow-left move_previous" href="#">
<span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
</a>
<a class="arrow arrow-right move_next" href="#">
<span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/>
</a>
</t>
</div>
</t>
<!--
@param {string} src
-->
<t t-name="PrintImage">
<html>
<head>
<script>
function onload_img() {
setTimeout('print_img()', 10);
}
function print_img() {
window.print();
window.close();
}
</script>
</head>
<body onload='onload_img()'>
<img t-att-src='src' alt=""/>
</body>
</html>
</t>
</templates>
@@ -1,222 +0,0 @@
/** @odoo-module **/
import DocumentViewer from '@mail/js/document_viewer';
import testUtils from 'web.test_utils';
import Widget from 'web.Widget';
/**
* @param {Object} params
* @param {Object[]} params.attachments
* @param {int} params.attachmentID
* @param {function} [params.mockRPC]
* @param {boolean} [params.debug]
* @returns {DocumentViewer}
*/
var createViewer = async function (params) {
var parent = new Widget();
var viewer = new DocumentViewer(parent, params.attachments, params.attachmentID);
var mockRPC = function (route) {
if (route === '/web/static/lib/pdfjs/web/viewer.html?file=/web/content/1?model%3Dir.attachment%26filename%3DfilePdf.pdf') {
return Promise.resolve();
}
if (route === 'https://www.youtube.com/embed/FYqW0Gdwbzk') {
return Promise.resolve();
}
if (route === '/web/content/4?model=ir.attachment') {
return Promise.resolve();
}
if (route === '/web/image/6?unique=56789abc&model=ir.attachment') {
return Promise.resolve();
}
};
await testUtils.mock.addMockEnvironment(parent, {
mockRPC: function () {
if (params.mockRPC) {
var _super = this._super;
this._super = mockRPC;
var def = params.mockRPC.apply(this, arguments);
this._super = _super;
return def;
} else {
return mockRPC.apply(this, arguments);
}
},
intercepts: params.intercepts || {},
});
var $target = $("#qunit-fixture");
if (params.debug) {
$target = $('body');
$target.addClass('debug');
}
// actually destroy the parent when the viewer is destroyed
viewer.destroy = function () {
delete viewer.destroy;
parent.destroy();
};
return viewer.appendTo($target).then(function() {
return viewer;
});
};
QUnit.module('mail', {}, function () {
QUnit.module('document_viewer_tests.js', {
beforeEach: function () {
this.attachments = [
{id: 1, name: 'filePdf.pdf', type: 'binary', mimetype: 'application/pdf', datas:'R0lGOP////ywAADs='},
{id: 2, name: 'urlYoutube', type: 'url', mimetype: '', url: 'https://youtu.be/FYqW0Gdwbzk'},
{id: 3, name: 'urlRandom', type: 'url', mimetype: '', url: 'https://www.google.com'},
{id: 4, name: 'text.html', type: 'binary', mimetype: 'text/html', datas:'testee'},
{id: 5, name: 'video.mp4', type: 'binary', mimetype: 'video/mp4', datas:'R0lDOP////ywAADs='},
{id: 6, name: 'image.jpg', type: 'binary', mimetype: 'image/jpeg', checksum: '123456789abc', datas:'R0lVOP////ywAADs='},
];
},
}, function () {
QUnit.test('basic rendering', async function (assert) {
assert.expect(7);
var viewer = await createViewer({
attachmentID: 1,
attachments: this.attachments,
});
assert.containsOnce(viewer, '.o_viewer_content',
"there should be a preview");
assert.containsOnce(viewer, '.o_close_btn',
"there should be a close button");
assert.containsOnce(viewer, '.o_viewer-header',
"there should be a header");
assert.containsOnce(viewer, '.o_image_caption',
"there should be an image caption");
assert.containsOnce(viewer, '.o_viewer_zoomer',
"there should be a zoomer");
assert.containsOnce(viewer, '.fa-chevron-right',
"there should be a right nav icon");
assert.containsOnce(viewer, '.fa-chevron-left',
"there should be a left nav icon");
viewer.destroy();
});
QUnit.test('Document Viewer Youtube', async function (assert) {
assert.expect(2);
var viewer = await createViewer({
attachmentID: 2,
attachments: this.attachments,
});
assert.strictEqual(viewer.$(".o_image_caption:contains('urlYoutube')").length, 1,
"the viewer should be on the right attachment");
assert.containsOnce(viewer, '.o_viewer_text[data-src="https://www.youtube.com/embed/FYqW0Gdwbzk"]',
"there should be a video player");
viewer.destroy();
});
QUnit.test('Document Viewer html/(txt)', async function (assert) {
assert.expect(2);
var viewer = await createViewer({
attachmentID: 4,
attachments: this.attachments,
});
assert.strictEqual(viewer.$(".o_image_caption:contains('text.html')").length, 1,
"the viewer be on the right attachment");
assert.containsOnce(viewer, 'iframe[data-src="/web/content/4?model=ir.attachment"]',
"there should be an iframe with the right src");
viewer.destroy();
});
QUnit.test('Document Viewer mp4', async function (assert) {
assert.expect(2);
var viewer = await createViewer({
attachmentID: 5,
attachments: this.attachments,
});
assert.strictEqual(viewer.$(".o_image_caption:contains('video.mp4')").length, 1,
"the viewer be on the right attachment");
assert.containsOnce(viewer, '.o_viewer_video',
"there should be a video player");
viewer.destroy();
});
QUnit.test('Document Viewer jpg', async function (assert) {
assert.expect(2);
var viewer = await createViewer({
attachmentID: 6,
attachments: this.attachments,
});
assert.strictEqual(viewer.$(".o_image_caption:contains('image.jpg')").length, 1,
"the viewer be on the right attachment");
assert.containsOnce(viewer, 'img[data-src="/web/image/6?unique=56789abc&model=ir.attachment"]',
"there should be a video player");
viewer.destroy();
});
QUnit.test('is closable by button', async function (assert) {
assert.expect(3);
var viewer = await createViewer({
attachmentID: 6,
attachments: this.attachments,
});
assert.containsOnce(viewer, '.o_viewer_content',
"should have a document viewer");
assert.containsOnce(viewer, '.o_close_btn',
"should have a close button");
await testUtils.dom.click(viewer.$('.o_close_btn'));
assert.ok(viewer.isDestroyed(), 'viewer should be destroyed');
});
QUnit.test('is closable by clicking on the wrapper', async function (assert) {
assert.expect(3);
var viewer = await createViewer({
attachmentID: 6,
attachments: this.attachments,
});
assert.containsOnce(viewer, '.o_viewer_content',
"should have a document viewer");
assert.containsOnce(viewer, '.o_viewer_img_wrapper',
"should have a wrapper");
await testUtils.dom.click(viewer.$('.o_viewer_img_wrapper'));
assert.ok(viewer.isDestroyed(), 'viewer should be destroyed');
});
QUnit.test('fileType and integrity test', async function (assert) {
assert.expect(3);
var viewer = await createViewer({
attachmentID: 2,
attachments: this.attachments,
});
assert.strictEqual(this.attachments[1].type, 'url',
"the type should be url");
assert.strictEqual(this.attachments[1].fileType, 'youtu',
"there should be a fileType 'youtu'");
assert.strictEqual(this.attachments[1].youtube, 'FYqW0Gdwbzk',
"there should be a youtube token");
viewer.destroy();
});
});
});
@@ -1,17 +0,0 @@
/** @odoo-module **/
import KanbanView from 'web.KanbanView';
import MrpDocumentsKanbanController from '@mrp/js/mrp_documents_kanban_controller';
import MrpDocumentsKanbanRenderer from '@mrp/js/mrp_documents_kanban_renderer';
import viewRegistry from 'web.view_registry';
const MrpDocumentsKanbanView = KanbanView.extend({
config: Object.assign({}, KanbanView.prototype.config, {
Controller: MrpDocumentsKanbanController,
Renderer: MrpDocumentsKanbanRenderer,
}),
});
viewRegistry.add('mrp_documents_kanban', MrpDocumentsKanbanView);
export default MrpDocumentsKanbanView;
@@ -1,125 +0,0 @@
/** @odoo-module **/
import { _t, qweb } from 'web.core';
import fileUploadMixin from 'web.fileUploadMixin';
import DocumentViewer from '@mrp/js/mrp_documents_document_viewer';
const MrpDocumentsControllerMixin = Object.assign({}, fileUploadMixin, {
events: {
'click .o_mrp_documents_kanban_upload': '_onClickMrpDocumentsUpload',
},
custom_events: Object.assign({}, fileUploadMixin.custom_events, {
kanban_image_clicked: '_onKanbanPreview',
upload_file: '_onUploadFile',
}),
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Called right after the reload of the view.
*/
async reload() {
await this._renderFileUploads();
},
/**
* @override
* @param {jQueryElement} $node
*/
renderButtons($node) {
if (this.is_action_enabled('edit')) {
this.$buttons = $(qweb.render('MrpDocumentsKanbanView.buttons'));
this.$buttons.appendTo($node);
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_getFileUploadRoute() {
return '/mrp/upload_attachment';
},
/**
* @override
* @param {integer} param0.recordId
*/
_makeFileUploadFormDataKeys() {
const context = this.model.get(this.handle, { raw: true }).getContext();
return {
res_id: context.default_res_id,
res_model: context.default_res_model,
};
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onClickMrpDocumentsUpload() {
const $uploadInput = $('<input>', {
type: 'file',
name: 'files[]',
multiple: 'multiple'
});
$uploadInput.on('change', async ev => {
await this._uploadFiles(ev.target.files);
$uploadInput.remove();
});
$uploadInput.click();
},
/**
* Handles custom event to display the document viewer.
*
* @private
* @param {OdooEvent} ev
* @param {integer} ev.data.recordID
* @param {Array<Object>} ev.data.recordList
*/
_onKanbanPreview(ev) {
ev.stopPropagation();
const documents = ev.data.recordList;
const documentID = ev.data.recordID;
const documentViewer = new DocumentViewer(this, documents, documentID);
documentViewer.appendTo(this.$('.o_mrp_documents_kanban_view'));
},
/**
* Specially created to call `_uploadFiles` method from tests.
*
* @private
* @param {OdooEvent} ev
*/
async _onUploadFile(ev) {
await this._uploadFiles(ev.data.files);
},
/**
* @override
* @param {Object} param0
* @param {XMLHttpRequest} param0.xhr
*/
_onUploadLoad({ xhr }) {
const result = xhr.status === 200
? JSON.parse(xhr.response)
: {
error: _.str.sprintf(_t("status code: %s, message: %s"), xhr.status, xhr.response)
};
if (result.error) {
this.displayNotification({ title: _t("Error"), message: result.error, sticky: true });
}
fileUploadMixin._onUploadLoad.apply(this, arguments);
},
});
export default MrpDocumentsControllerMixin;
@@ -1,15 +0,0 @@
/** @odoo-module **/
import DocumentViewer from '@mail/js/document_viewer';
/**
* This file defines the DocumentViewer for the MRP Documents Kanban view.
*/
const MrpDocumentsDocumentViewer = DocumentViewer.extend({
init(parent, attachments, activeAttachmentID) {
this._super(...arguments);
this.modelName = 'mrp.document';
},
});
export default MrpDocumentsDocumentViewer;
@@ -1,34 +0,0 @@
/** @odoo-module **/
/**
* This file defines the Controller for the MRP Documents Kanban view, which is an
* override of the KanbanController.
*/
import MrpDocumentsControllerMixin from '@mrp/js/mrp_documents_controller_mixin';
import KanbanController from 'web.KanbanController';
const MrpDocumentsKanbanController = KanbanController.extend(MrpDocumentsControllerMixin, {
events: Object.assign({}, KanbanController.prototype.events, MrpDocumentsControllerMixin.events),
custom_events: Object.assign({}, KanbanController.prototype.custom_events, MrpDocumentsControllerMixin.custom_events),
/**
* @override
*/
init() {
this._super(...arguments);
MrpDocumentsControllerMixin.init.apply(this, arguments);
},
/**
* Override to update the records selection.
*
* @override
*/
async reload() {
await this._super(...arguments);
await MrpDocumentsControllerMixin.reload.apply(this, arguments);
},
});
export default MrpDocumentsKanbanController;
@@ -1,47 +0,0 @@
/** @odoo-module **/
/**
* This file defines the KanbanRecord for the MRP Documents Kanban view.
*/
import KanbanRecord from 'web.KanbanRecord';
const MrpDocumentsKanbanRecord = KanbanRecord.extend({
events: Object.assign({}, KanbanRecord.prototype.events, {
'click .o_mrp_download': '_onDownload',
'click .o_kanban_previewer': '_onImageClicked',
}),
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handles the click on the download link to save the attachment locally.
*
* @private
* @param {MouseEvent} ev
*/
_onDownload(ev) {
ev.preventDefault();
window.location = `/web/content/${this.modelName}/${this.id}/datas?download=true`;
},
/**
* Handles the click on the preview image. Triggers up `_onKanbanPreview` to
* display `DocumentViewer`.
*
* @private
* @param {MouseEvent} ev
*/
_onImageClicked(ev) {
ev.preventDefault();
ev.stopPropagation();
this.trigger_up('kanban_image_clicked', {
recordList: [this.recordData],
recordID: this.recordData.id
});
},
});
export default MrpDocumentsKanbanRecord;
@@ -1,24 +0,0 @@
/** @odoo-module **/
/**
* This file defines the Renderer for the MRP Documents Kanban view, which is an
* override of the KanbanRenderer.
*/
import KanbanRenderer from 'web.KanbanRenderer';
import MrpDocumentsKanbanRecord from '@mrp/js/mrp_documents_kanban_record';
const MrpDocumentsKanbanRenderer = KanbanRenderer.extend({
config: Object.assign({}, KanbanRenderer.prototype.config, {
KanbanRecord: MrpDocumentsKanbanRecord,
}),
/**
* @override
*/
async start() {
this.$el.addClass('o_mrp_documents_kanban_view');
await this._super(...arguments);
},
});
export default MrpDocumentsKanbanRenderer;
@@ -0,0 +1,40 @@
/** @odoo-module **/
import { KanbanController } from "@web/views/kanban/kanban_controller";
import { useBus, useService } from "@web/core/utils/hooks";
const { useRef } = owl;
export class MrpDocumentsKanbanController extends KanbanController {
setup() {
super.setup();
this.uploadFileInputRef = useRef("uploadFileInput");
this.fileUploadService = useService("file_upload");
useBus(
this.fileUploadService.bus,
"FILE_UPLOAD_LOADED",
async () => {
await this.model.root.load();
this.model.notify();
},
);
}
async onFileInputChange(ev) {
if (!ev.target.files.length) {
return;
}
await this.fileUploadService.upload(
"/mrp/upload_attachment",
ev.target.files,
{
buildFormData: (formData) => {
formData.append("res_model", this.props.context.default_res_model);
formData.append("res_id", this.props.context.default_res_id);
},
},
);
// Reset the file input's value so that the same file may be uploaded twice.
ev.target.value = "";
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mrp.MrpDocumentsKanbanView.Buttons" t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary" owl="1">
<div role="toolbar" position="inside">
<input type="file" multiple="true" t-ref="uploadFileInput" class="o_input_file o_hidden" t-on-change.stop="onFileInputChange"/>
<button type="button" t-attf-class="btn btn-primary o_mrp_documents_kanban_upload"
t-on-click.stop.prevent="() => this.uploadFileInputRef.el.click()">
Upload
</button>
</div>
</t>
</templates>
@@ -0,0 +1,41 @@
/** @odoo-module **/
import { CANCEL_GLOBAL_CLICK, KanbanRecord } from "@web/views/kanban/kanban_record";
import { useService } from "@web/core/utils/hooks";
export class MrpDocumentsKanbanRecord extends KanbanRecord {
setup() {
super.setup();
this.messaging = useService("messaging");
}
/**
* @override
*
* Override to open the preview upon clicking the image, if compatible.
*/
onGlobalClick(ev) {
if (ev.target.closest(CANCEL_GLOBAL_CLICK) && !ev.target.classList.contains("o_mrp_download")) {
return;
}
if (ev.target.classList.contains("o_mrp_download")) {
window.location = `/web/content/mrp.document/${this.props.record.resId}/datas?download=true`;
return;
} else if (ev.target.closest(".o_kanban_previewer")) {
this.messaging.get().then((messaging) => {
const attachmentList = messaging.models["AttachmentList"].insert({
selectedAttachment: messaging.models["Attachment"].insert({
id: this.props.record.data.ir_attachment_id[0],
filename: this.props.record.data.name,
name: this.props.record.data.name,
mimetype: this.props.record.data.mimetype,
}),
});
this.dialog = messaging.models["Dialog"].insert({
attachmentListOwnerAsAttachmentView: attachmentList,
});
});
return;
}
return super.onGlobalClick(...arguments);
}
}
@@ -0,0 +1,22 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { MrpDocumentsKanbanRecord } from "@mrp/views/mrp_documents_kanban/mrp_documents_kanban_record";
import { FileUploadProgressContainer } from "@web/core/file_upload/file_upload_progress_container";
import { FileUploadProgressKanbanRecord } from "@web/core/file_upload/file_upload_progress_record";
export class MrpDocumentsKanbanRenderer extends KanbanRenderer {
setup() {
super.setup();
this.fileUploadService = useService("file_upload");
}
}
MrpDocumentsKanbanRenderer.components = {
...KanbanRenderer.components,
FileUploadProgressContainer,
FileUploadProgressKanbanRecord,
KanbanRecord: MrpDocumentsKanbanRecord,
};
MrpDocumentsKanbanRenderer.template = "mrp.MrpDocumentsKanbanRenderer";
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mrp.MrpDocumentsKanbanRenderer" owl="1" t-inherit-mode="primary" t-inherit="web.KanbanRenderer">
<!-- Before the first t-foreach -->
<xpath expr="//t[@t-key='groupOrRecord.key']" position="before">
<FileUploadProgressContainer fileUploads="fileUploadService.uploads" Component="constructor.components.FileUploadProgressKanbanRecord"/>
</xpath>
</t>
</templates>
@@ -0,0 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { MrpDocumentsKanbanController } from "@mrp/views/mrp_documents_kanban/mrp_documents_kanban_controller";
import { MrpDocumentsKanbanRenderer } from "@mrp/views/mrp_documents_kanban/mrp_documents_kanban_renderer";
export const mrpDocumentsKanbanView = {
...kanbanView,
Controller: MrpDocumentsKanbanController,
Renderer: MrpDocumentsKanbanRenderer,
buttonTemplate: "mrp.MrpDocumentsKanbanView.Buttons",
};
registry.category("views").add("mrp_documents_kanban", mrpDocumentsKanbanView);
@@ -1,18 +1,38 @@
/** @odoo-module **/
import MrpDocumentsKanbanView from '@mrp/js/mrp_document_kanban_view';
import MrpDocumentsKanbanController from '@mrp/js/mrp_documents_kanban_controller';
import testUtils from 'web.test_utils';
import { registry } from "@web/core/registry";
import {
click,
getFixture,
nextTick,
} from '@web/../tests/helpers/utils';
import { setupViewRegistries } from "@web/../tests/views/helpers";
import {
start,
startServer,
} from '@mail/../tests/helpers/test_utils';
const createView = testUtils.createView;
import { fileUploadService } from "@web/core/file_upload/file_upload_service";
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch([
'mrp.document',
]);
const serviceRegistry = registry.category("services");
let target;
let pyEnv;
QUnit.module('Views', {}, function () {
QUnit.module('MrpDocumentsKanbanView', {
beforeEach: function () {
this.ORIGINAL_CREATE_XHR = MrpDocumentsKanbanController.prototype._createXHR;
beforeEach: async function () {
serviceRegistry.add("file_upload", fileUploadService);
this.ORIGINAL_CREATE_XHR = fileUploadService.createXhr;
this.patchDocumentXHR = (mockedXHRs, customSend) => {
MrpDocumentsKanbanController.prototype._createXhr = () => {
fileUploadService.createXhr = () => {
const xhr = {
upload: new window.EventTarget(),
open() { },
@@ -22,54 +42,51 @@ QUnit.module('MrpDocumentsKanbanView', {
return xhr;
};
};
this.data = {
'mrp.document': {
fields: {
name: {string: "Name", type: 'char', default: ' '},
priority: {string: 'priority', type: 'selection',
selection: [['0', 'Normal'], ['1', 'Low'], ['2', 'High'], ['3', 'Very High']]},
},
records: [
{id: 1, name: 'test1', priority: 2},
{id: 4, name: 'test2', priority: 1},
{id: 3, name: 'test3', priority: 3},
],
},
};
pyEnv = await startServer();
const irAttachment = pyEnv['ir.attachment'].create({
mimetype: 'image/png',
name: 'test.png',
})
pyEnv['mrp.document'].create([
{name: 'test1', priority: 2, ir_attachment_id: irAttachment},
{name: 'test2', priority: 1},
{name: 'test3', priority: 3},
]);
target = getFixture();
setupViewRegistries();
},
afterEach() {
MrpDocumentsKanbanController.prototype._createXHR = this.ORIGINAL_CREATE_XHR;
fileUploadService.createXhr = this.ORIGINAL_CREATE_XHR;
},
}, function () {
QUnit.test('MRP documents kanban basic rendering', async function (assert) {
assert.expect(6);
assert.expect(4);
const kanban = await createView({
View: MrpDocumentsKanbanView,
model: 'mrp.document',
data: this.data,
arch: '<kanban><templates><t t-name="kanban-box">' +
'<div>' +
'<field name="name"/>' +
'</div>' +
'</t></templates></kanban>',
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
assert.ok(kanban, "kanban is created");
assert.ok(kanban.$buttons.find('.o_mrp_documents_kanban_upload'),
assert.ok(target.querySelector('.o_mrp_documents_kanban_upload'),
"should have upload button in kanban buttons");
assert.containsN(kanban, '.o_legacy_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3,
assert.containsN(target, '.o_kanban_renderer .o_kanban_record:not(.o_kanban_ghost)', 3,
"should have 3 records in the renderer");
// check view layout
assert.hasClass(kanban.$('.o_legacy_kanban_view'), 'o_mrp_documents_kanban_view',
"should have classname 'o_mrp_documents_kanban_view'");
// check control panel buttons
assert.containsN(kanban, '.o_cp_buttons .btn-primary', 1,
assert.containsN(target, '.o_cp_buttons .btn-primary', 1,
"should have only 1 primary button i.e. Upload button");
assert.strictEqual(kanban.$('.o_cp_buttons .btn-primary:first').text().trim(), 'Upload',
assert.equal(target.querySelector(".o_cp_buttons .btn-primary").innerText.trim().toUpperCase(), 'UPLOAD',
"should have a primary 'Upload' button");
kanban.destroy();
});
QUnit.test('mrp: upload multiple files', async function (assert) {
@@ -94,26 +111,36 @@ QUnit.module('MrpDocumentsKanbanView', {
const mockedXHRs = [];
this.patchDocumentXHR(mockedXHRs, data => assert.step('xhrSend'));
const kanban = await createView({
View: MrpDocumentsKanbanView,
model: 'mrp.document',
data: this.data,
arch: '<kanban><templates><t t-name="kanban-box">' +
'<div>' +
'<field name="name"/>' +
'</div>' +
'</t></templates></kanban>',
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
kanban.trigger_up('upload_file', {files: [file1]});
await testUtils.nextTick();
const fileInput = target.querySelector(".o_input_file");
let dataTransfer = new DataTransfer();
dataTransfer.items.add(file1);
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
assert.verifySteps(['xhrSend']);
kanban.trigger_up('upload_file', {files: [file2, file3]});
await testUtils.nextTick();
dataTransfer = new DataTransfer();
dataTransfer.items.add(file2);
dataTransfer.items.add(file3);
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
assert.verifySteps(['xhrSend']);
kanban.destroy();
});
QUnit.test('mrp: upload progress bars', async function (assert) {
@@ -128,19 +155,28 @@ QUnit.module('MrpDocumentsKanbanView', {
const mockedXHRs = [];
this.patchDocumentXHR(mockedXHRs, data => assert.step('xhrSend'));
const kanban = await createView({
View: MrpDocumentsKanbanView,
model: 'mrp.document',
data: this.data,
arch: '<kanban><templates><t t-name="kanban-box">' +
'<div>' +
'<field name="name"/>' +
'</div>' +
'</t></templates></kanban>',
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
kanban.trigger_up('upload_file', {files: [file1]});
await testUtils.nextTick();
const fileInput = target.querySelector(".o_input_file");
let dataTransfer = new DataTransfer();
dataTransfer.items.add(file1);
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
assert.verifySteps(['xhrSend']);
const progressEvent = new Event('progress', { bubbles: true });
@@ -148,21 +184,60 @@ QUnit.module('MrpDocumentsKanbanView', {
progressEvent.total = 500000000;
progressEvent.lengthComputable = true;
mockedXHRs[0].upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(
kanban.$('.o_file_upload_progress_text_left').text(),
target.querySelector('.o_file_upload_progress_text_left').innerText,
"Uploading... (50%)",
"the current upload progress should be at 50%"
);
progressEvent.loaded = 350000000;
mockedXHRs[0].upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(
kanban.$('.o_file_upload_progress_text_right').text(),
"(350/500Mb)",
target.querySelector('.o_file_upload_progress_text_right').innerText,
"(350/500MB)",
"the current upload progress should be at (350/500Mb)"
);
});
kanban.destroy();
QUnit.test("mrp: click on image opens attachment viewer", async function (assert) {
assert.expect(4);
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div class="o_kanban_image" t-if="record.ir_attachment_id.raw_value">
<div class="o_kanban_previewer">
<field name="ir_attachment_id" invisible="1"/>
<img t-attf-src="/web/image/#{record.ir_attachment_id.raw_value}" width="100" height="100" alt="Document" class="o_attachment_image"/>
</div>
</div>
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
assert.containsOnce(target, ".o_kanban_previewer");
await click(target.querySelector(".o_kanban_previewer"));
await nextTick();
assert.containsOnce(target, '.o_AttachmentViewer',
"should have a document preview");
assert.containsOnce(target, '.o_AttachmentViewer_headerItemButtonClose',
"should have a close button");
await click(target, '.o_AttachmentViewer_headerItemButtonClose');
assert.containsNone(target, '.o_AttachmentViewer',
"should not have a document preview");
});
});
+1 -1
View File
@@ -5,7 +5,7 @@
<field name="name">mrp.document kanban.mrp</field>
<field name="model">mrp.document</field>
<field name="arch" type="xml">
<kanban js_class="mrp_documents_kanban" create="false">
<kanban js_class="mrp_documents_kanban" class="o_mrp_documents_kanban_view" create="false">
<field name="ir_attachment_id"/>
<field name="mimetype"/>
<field name="type"/>
-3
View File
@@ -46,8 +46,6 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/xml/control_panel.xml',
'web/static/src/legacy/xml/chart.xml',
'web/static/src/legacy/xml/fields.xml',
'web/static/src/legacy/xml/file_upload_progress_bar.xml',
'web/static/src/legacy/xml/file_upload_progress_card.xml',
'web/static/src/legacy/xml/kanban.xml',
'web/static/src/legacy/xml/pivot.xml',
'web/static/src/legacy/xml/web_calendar.xml',
@@ -122,7 +120,6 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/scss/switch_company_menu.scss',
'web/static/src/legacy/scss/ace.scss',
'web/static/src/legacy/scss/fields.scss',
'web/static/src/legacy/scss/file_upload.scss',
'web/static/src/legacy/scss/views.scss',
'web/static/src/legacy/scss/form_view.scss',
'web/static/src/legacy/scss/list_view.scss',
@@ -0,0 +1,29 @@
/** @odoo-module **/
import { useService } from "../utils/hooks";
import { sprintf } from "../utils/strings";
import { ConfirmationDialog } from "../confirmation_dialog/confirmation_dialog";
const { Component } = owl;
export class FileUploadProgressBar extends Component {
setup() {
this.dialogService = useService("dialog");
}
onCancel() {
if (!this.props.fileUpload.xhr) {
return;
}
this.dialogService.add(ConfirmationDialog, {
body: sprintf(this.env._t("Do you really want to cancel the upload of %s?"), this.props.fileUpload.title),
confirm: () => {
this.props.fileUpload.xhr.abort();
},
});
}
}
FileUploadProgressBar.props = {
fileUpload: { type: Object },
};
FileUploadProgressBar.template = "web.FileUploadProgressBar";
@@ -0,0 +1,19 @@
.o-file-upload-progress-bar-value {
transition: width 0.1s;
border-right: 1px solid darken($o-brand-primary, 7.5%);
background-color: $o-brand-primary;
opacity: 0.5;
}
.o-file-upload-progress-bar-abort {
padding: 4px;
color: #963535;
font-size: 16px;
&:active {
opacity: 0.7;
}
}
div:not(:hover) .o-file-upload-progress-bar-abort {
display: none;
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="web.FileUploadProgressBar" owl="1">
<div class="position-absolute top-0 start-0 h-100 w-100">
<div class="o-file-upload-progress-bar-value h-100" t-ref="bar" t-att-style="'width: ' + (this.props.fileUpload.progress * 100) + '%;'"/>
<span class="position-absolute top-0 end-0 cursor-pointer o-file-upload-progress-bar-abort fa fa-times-circle" title="Cancel Upload" aria-label="Cancel Upload" t-on-click.stop.prevent="onCancel"/>
</div>
</t>
</templates>
@@ -0,0 +1,11 @@
/** @odoo-module **/
const { Component } = owl;
export class FileUploadProgressContainer extends Component {}
FileUploadProgressContainer.template = "web.FileUploadProgressContainer";
FileUploadProgressContainer.props = {
Component: { optional: false },
shouldDisplay: { type: Function, optional: true },
fileUploads: { type: Object },
};
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="web.FileUploadProgressContainer" owl="1">
<t t-foreach="Object.values(props.fileUploads)" t-as="fileUpload" t-key="fileUpload.id">
<t t-if="!props.shouldDisplay || props.shouldDisplay(fileUpload)" t-component="props.Component" fileUpload="fileUpload" selector="props.selector"/>
</t>
</t>
</templates>
@@ -0,0 +1,35 @@
/** @odoo-module **/
import { sprintf } from "../utils/strings";
import { FileUploadProgressBar } from "./file_upload_progress_bar";
const { Component } = owl;
export class FileUploadProgressRecord extends Component {
getProgressTexts() {
const fileUpload = this.props.fileUpload;
const percent = Math.round(fileUpload.progress * 100);
if (percent === 100) {
return {
left: this.env._t("Processing..."),
right: "",
};
} else {
const mbLoaded = Math.round(fileUpload.loaded / 1000000);
const mbTotal = Math.round(fileUpload.total / 1000000);
return {
left: sprintf(this.env._t("Uploading... (%s%)"), percent),
right: sprintf(this.env._t("(%s/%sMB)"), mbLoaded, mbTotal),
};
}
}
}
FileUploadProgressRecord.components = {
FileUploadProgressBar,
};
export class FileUploadProgressKanbanRecord extends FileUploadProgressRecord {}
FileUploadProgressKanbanRecord.template = "web.FileUploadProgressKanbanRecord";
export class FileUploadProgressDataRow extends FileUploadProgressRecord {}
FileUploadProgressDataRow.template = "web.FileUploadProgressDataRow";
@@ -0,0 +1,23 @@
.o_kanban_record {
.o_kanban_progress_card {
min-height: 80px;
.o_kanban_record_bottom {
color: $gray-900;
}
.o_kanban_image_wrapper {
opacity: 0.7;
}
}
}
.o_data_row.o_list_progress_card {
height: 25px;
border: 1px solid #dfdfdf;
.o_file_upload_upload_title {
color: $headings-color;
font-size: 13px;
font-weight: 500;
}
}
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="web.FileUploadProgressKanbanRecord" owl="1">
<t t-set="progressTexts" t-value="getProgressTexts()"/>
<div class="o_kanban_record d-flex flex-grow-1 flex-md-shrink-1 flex-shrink-0">
<div class="o_kanban_progress_card o_kanban_attachment position-relative p-0 cursor-pointer">
<div class="o_kanban_image">
<div class="o_kanban_image_wrapper">
<div class="o_image o_image_thumbnail" t-att-data-mimetype="props.fileUpload.type"/>
</div>
</div>
<div class="o_kanban_details">
<div class="o_kanban_details_wrapper">
<div t-att-title="props.fileUpload.title" t-att-aria-label="props.fileUpload.title" class="o_kanban_record_title">
<span t-esc="props.fileUpload.title"/>
</div>
<div class="o_kanban_record_body"/>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<div class="o_file_upload_progress_text_left" t-esc="progressTexts.left"/>
</div>
<div class="oe_kanban_bottom_right">
<span class="o_file_upload_progress_text_right" t-esc="progressTexts.right"/>
</div>
</div>
</div>
</div>
<FileUploadProgressBar fileUpload="props.fileUpload"/>
</div>
</div>
</t>
<t t-name="web.FileUploadProgressDataRow" owl="1">
<t t-set="progressTexts" t-value="getProgressTexts()"/>
<div class="o_data_row o_list_progress_card position-relative align-middle p-0">
<span class="o_file_upload_upload_title" t-esc="props.fileUpload.title"/>
<span class="o_file_upload_progress_text_left" t-esc="progressTexts.left"/>
<span class="o_file_upload_progress_text_right" t-esc="progressTexts.right"/>
<FileUploadProgressBar fileUpload="props.fileUpload"/>
</div>
</t>
</templates>
@@ -0,0 +1,104 @@
/** @odoo-module **/
import { registry } from "../registry";
import { sprintf } from "../utils/strings";
const { EventBus, reactive } = owl;
export const fileUploadService = {
dependencies: ["notification"],
/**
* Overridden during tests to return a mocked XHR.
*
* @private
* @returns {XMLHttpRequest}
*/
createXhr() {
return new window.XMLHttpRequest();
},
start(env, { notificationService }) {
const uploads = reactive({});
let nextId = 1;
const bus = new EventBus();
/**
* @param {string} route
* @param {FileList} files
* @param {Object} [params]
* @param {function(formData): void} [params.buildFormData]
* @param {Boolean} [params.displayErrorNotification]
* @returns {reactive} upload
* @returns {XMLHttpRequest} upload.xhr
* @returns {FormData} upload.data
* @returns {Number} upload.progress
* @returns {Number} upload.loaded
* @returns {Number} upload.total
* @returns {String} upload.title
* @returns {String||undefined} upload.type
*/
const upload = async (route, files, params = {}) => {
const xhr = this.createXhr();
xhr.open("POST", route);
const formData = new FormData();
formData.append("csrf_token", odoo.csrf_token);
for (const file of files) {
formData.append("ufile", file);
}
if (params.buildFormData) {
params.buildFormData(formData);
}
const upload = reactive({
id: nextId++,
xhr,
data: formData,
progress: 0,
loaded: 0,
total: 0,
state: "pending",
title: files.length === 1 ? files[0].name : sprintf(env._t("%s Files"), files.length),
type: files.length === 1 ? files[0].type : undefined,
});
uploads[upload.id] = upload;
// Progress listener
xhr.upload.addEventListener("progress", async (ev) => {
upload.progress = ev.loaded / ev.total;
upload.loaded = ev.loaded;
upload.total = ev.total;
upload.state = "loading";
});
// Load listener
xhr.upload.addEventListener("load", async () => {
delete uploads[upload.id];
upload.state = "loaded";
bus.trigger("FILE_UPLOAD_LOADED", { upload });
});
// Error listener
xhr.upload.addEventListener("error", async () => {
delete uploads[upload.id];
upload.state = "error";
// Disable this option if you need more explicit error handling.
if (params.displayErrorNotification !== undefined && params.displayErrorNotification) {
notificationService.add(env._t("An error occured while uploading."), {
title: env._t("Error"),
sticky: true,
});
}
bus.trigger("FILE_UPLOAD_ERROR", { upload });
});
// Abort listener, considered as error
xhr.upload.addEventListener("abort", async () => {
delete uploads[upload.id];
upload.state = "abort";
bus.trigger("FILE_UPLOAD_ERROR", { upload });
});
xhr.send(formData);
bus.trigger("FILE_UPLOAD_ADDED", { upload });
return upload;
};
return { bus, upload, uploads };
},
};
registry.category("services").add("file_upload", fileUploadService);
@@ -1,231 +0,0 @@
/** @odoo-module alias=web.fileUploadMixin **/
/**
* Mixin to be used in view Controllers to manage uploads and generate progress bars.
* supported views: kanban, list
*/
import { csrf_token, _t } from 'web.core';
import ProgressBar from 'web.ProgressBar';
import ProgressCard from 'web.ProgressCard';
const ProgressBarMixin = {
custom_events: {
progress_bar_abort: '_onProgressBarAbort',
},
init() {
/**
* Contains the uploads currently happening, used to attach progress bars.
* e.g: {'fileUploadId45': {progressBar, progressCard, ...params}}
*/
this._fileUploads = {};
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* used to use a mocked version of Xhr in the tests.
*
* @private
* @returns {XMLHttpRequest}
*/
_createXhr() {
return new window.XMLHttpRequest();
},
/**
* @private
*/
_getFileUploadRenderOptions() {
return {
predicate: () => true,
targetCallback: undefined,
};
},
/**
* @private
* @returns {string} upload route
*/
_getFileUploadRoute() {
return '/web/binary/upload_attachment';
},
/**
* @private
* @param {Object} params
* @param {Object[]} params.files
* @param {XMLHttpRequest} params.xhr
*/
_makeFileUpload(params) {
const { files, xhr } = params;
const fileUploadId = _.uniqueId('fileUploadId');
const formData = new FormData();
const formDataKeys = this._makeFileUploadFormDataKeys(Object.assign({ fileUploadId }, params));
formData.append('csrf_token', csrf_token);
for (const key in formDataKeys) {
if (formDataKeys[key] !== undefined) {
formData.append(key, formDataKeys[key]);
}
}
for (const file of files) {
formData.append('ufile', file);
}
return {
fileUploadId,
xhr,
title: files.length === 1
? files[0].name
: _.str.sprintf(_t("%s Files"), files.length),
type: files.length === 1 ? files[0].type : undefined,
formData,
};
},
/**
* @private
* @param {Object} param0
* @param {string} param0.fileUploadId
* @returns {Object} the list of the form entries of a file upload.
*/
_makeFileUploadFormDataKeys({ fileUploadId }) {
return {
callback: fileUploadId,
};
},
/**
* @private
* @param {integer} fileUploadId
*/
async _removeFileUpload(fileUploadId) {
const upload = this._fileUploads[fileUploadId];
upload.progressCard && upload.progressCard.destroy();
upload.progressBar && upload.progressBar.destroy();
delete this._fileUploads[fileUploadId];
await this.reload();
},
/**
* @private
*/
async _renderFileUploads() {
const { predicate, targetCallback } = this._getFileUploadRenderOptions();
for (const fileUploadId in this._fileUploads) {
const upload = this._fileUploads[fileUploadId];
if (!predicate(upload)) {
continue;
}
if (!upload.progressBar) {
if (!upload.recordId || this.viewType !== 'kanban') {
upload.progressCard = new ProgressCard(this, {
title: upload.title,
type: upload.type,
viewType: this.viewType,
});
}
upload.progressBar = new ProgressBar(this, {
xhr: upload.xhr,
title: upload.title,
fileUploadId,
});
}
let $targetCard;
if (upload.progressCard) {
await upload.progressCard.prependTo(this.renderer.$el);
$targetCard = upload.progressCard.$el;
} else if (targetCallback) {
$targetCard = targetCallback(upload);
}
await upload.progressBar.appendTo($targetCard);
}
},
/**
* @private
* @param {Object[]} files
* @param {Object} [params] optional additional data
*/
async _uploadFiles(files, params={}) {
if (!files || !files.length) { return; }
await new Promise(resolve => {
const xhr = this._createXhr();
xhr.open('POST', this._getFileUploadRoute());
const fileUploadData = this._makeFileUpload(Object.assign({ files, xhr }, params));
const { fileUploadId, formData } = fileUploadData;
this._fileUploads[fileUploadId] = fileUploadData;
xhr.upload.addEventListener("progress", ev => {
this._updateFileUploadProgress(fileUploadId, ev);
});
const progressPromise = this._onBeforeUpload();
xhr.onload = async () => {
await progressPromise;
resolve();
this._onUploadLoad({ fileUploadId, xhr });
};
xhr.onerror = async () => {
await progressPromise;
resolve();
this._onUploadError({ fileUploadId, xhr });
};
xhr.send(formData);
});
},
/**
* @private
* @param {string} fileUploadId
* @param {ProgressEvent} ev
*/
_updateFileUploadProgress(fileUploadId, ev) {
const { progressCard, progressBar } = this._fileUploads[fileUploadId];
progressCard && progressCard.update(ev.loaded, ev.total);
progressBar && progressBar.update(ev.loaded, ev.total);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Hook to customize the behaviour of _uploadFiles() before an upload is made.
*
* @private
*/
async _onBeforeUpload() {
await this._renderFileUploads();
},
/**
* @private
* @param {OdooEvent} ev
* @param {integer} ev.data.fileUploadId
*/
_onProgressBarAbort(ev) {
this._removeFileUpload(ev.data.fileUploadId);
},
/**
* Hook to customize the behaviour of the xhr.onload of an upload.
*
* @private
* @param {string} param0.fileUploadId
*/
_onUploadLoad({ fileUploadId }) {
this._removeFileUpload(fileUploadId);
},
/**
* Hook to customize the behaviour of the xhr.onerror of an upload.
*
* @private
* @param {string} param1.fileUploadId
* @param {XMLHttpRequest} param0.xhr
*/
_onUploadError({ fileUploadId, xhr }) {
this.displayNotification({ title: xhr.status, message: _.str.sprintf(_t('message: %s'), xhr.reponseText), sticky: true });
this._removeFileUpload(fileUploadId);
},
};
export default ProgressBarMixin;
@@ -1,73 +0,0 @@
/** @odoo-module alias=web.ProgressBar **/
import { _t } from 'web.core';
import Dialog from 'web.Dialog';
import Widget from 'web.Widget';
const ProgressBar = Widget.extend({
template: 'web.FileUploadProgressBar',
events: {
'click .o_upload_cross': '_onClickCross',
},
/**
* @override
* @param {Object} param1
* @param {String} param1.title
* @param {String} param1.fileUploadId
* @param {XMLHttpRequest} param2.xhr
*/
init(parent, { title, fileUploadId, xhr }) {
this._super(...arguments);
this.title = title;
this.fileUploadId = fileUploadId;
this.xhr = xhr;
},
/**
* @override
* @return {Promise}
*/
start() {
this.xhr.onabort = () => this.displayNotification({ message: _t("Upload cancelled") });
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {integer} loaded
* @param {integer} total
*/
update(loaded, total) {
if (!this.$el) {
return;
}
const percent = Math.round((loaded / total) * 100);
this.$('.o_file_upload_progress_bar_value').css("width", percent + "%");
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onClickCross(ev) {
ev.stopPropagation();
const promptText = _.str.sprintf(_t("Do you really want to cancel the upload of %s?"), _.escape(this.title));
Dialog.confirm(this, promptText, {
confirm_callback: () => {
this.xhr.abort();
this.trigger_up('progress_bar_abort', { fileUploadId: this.fileUploadId });
}
});
},
});
export default ProgressBar;
@@ -1,49 +0,0 @@
/** @odoo-module alias=web.ProgressCard **/
import { _t } from 'web.core';
import Widget from 'web.Widget';
const ProgressCard = Widget.extend({
template: 'web.ProgressCard',
/**
* @override
* @param {Object} param1
* @param {String} param1.title
* @param {String} param1.type file mimetype
* @param {String} param1.viewType
*/
init(parent, { title, type, viewType }) {
this._super(...arguments);
this.title = title;
this.type = type;
this.viewType = viewType;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {integer} loaded
* @param {integer} total
*/
update(loaded, total) {
if (!this.$el) {
return;
}
const percent = Math.round((loaded / total) * 100);
const $textDivLeft = this.$('.o_file_upload_progress_text_left');
const $textDivRight = this.$('.o_file_upload_progress_text_right');
if (percent === 100) {
$textDivLeft.text(_t('Processing...'));
} else {
const mbLoaded = Math.round(loaded/1000000);
const mbTotal = Math.round(total/1000000);
$textDivLeft.text(_.str.sprintf(_t("Uploading... (%s%%)"), percent));
$textDivRight.text(_.str.sprintf(_t("(%s/%sMb)"), mbLoaded, mbTotal));
}
},
});
export default ProgressCard;
@@ -1,58 +0,0 @@
.o_content {
.o_kanban_progress_card {
position: relative;
padding: 0;
min-height: 80px;
cursor: pointer;
.o_kanban_record_bottom {
color: $gray-900;
}
.o_kanban_image_wrapper {
opacity: 0.7;
}
}
.o_list_progress_card {
height: 25px;
border: 1px solid #dfdfdf;
position: relative;
vertical-align: middle;
padding: 0;
.o_file_upload_upload_title {
color: $headings-color;
font-size: 13px;
font-weight: 500;
}
}
.o_file_upload_progress_bar {
@include o-position-absolute($top: 0px, $left: 0px);
height: 100%;
width: 100%;
.o_file_upload_progress_bar_value {
transition: width 0.1s;
border-right: 1px solid darken($o-brand-primary, 7.5%);
height: 100%;
width: 100%;
background-color: $o-brand-primary;
opacity: 0.5;
}
.o_upload_cross {
@include o-position-absolute(0, 0);
padding: 4px;
color: #963535;
cursor: pointer;
font-size: 16px;
&:active {
opacity: 0.7;
}
}
&:not(:hover) .o_upload_cross {
display: none;
}
}
}
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<div t-name="web.FileUploadProgressBar" class="o_file_upload_progress_bar">
<div class="o_file_upload_progress_bar_value"/>
<span class="o_upload_cross fa fa-times-circle" title="Cancel Upload" aria-label="Cancel Upload"/>
</div>
</templates>
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="web.ProgressCard">
<t t-if="widget.viewType === 'kanban'">
<div class="o_kanban_progress_card o_kanban_record o_kanban_attachment">
<div class="o_kanban_image">
<div class="o_kanban_image_wrapper">
<div class="o_image o_image_thumbnail" t-att-data-mimetype="widget.type"/>
</div>
</div>
<div class="o_kanban_details">
<div class="o_kanban_details_wrapper">
<div t-att-title="widget.title" t-att-aria-label="widget.title" class="o_kanban_record_title">
<span t-esc="widget.title"/>
</div>
<div class="o_kanban_record_body"/>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<div class="o_file_upload_progress_text_left">Processing...</div>
</div>
<div class="oe_kanban_bottom_right">
<span class="o_file_upload_progress_text_right"/>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-if="widget.viewType === 'list'">
<div class="o_data_row o_list_progress_card">
<span class="o_file_upload_upload_title" t-esc="widget.title"/>
<span class="o_file_upload_progress_text_left">Processing...</span>
<span class="o_file_upload_progress_text_right"/>
</div>
</t>
</t>
</templates>
@@ -26,7 +26,7 @@ const { COLORS } = ColorList;
const formatters = registry.category("formatters");
// These classes determine whether a click on a record should open it.
const CANCEL_GLOBAL_CLICK = ["a", ".dropdown", ".oe_kanban_action"].join(",");
export const CANCEL_GLOBAL_CLICK = ["a", ".dropdown", ".oe_kanban_action"].join(",");
const ALLOW_GLOBAL_CLICK = [".oe_kanban_global_click", ".oe_kanban_global_click_edit"].join(",");
/**
@@ -0,0 +1,159 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { fileUploadService } from "@web/core/file_upload/file_upload_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { FileUploadProgressContainer } from "@web/core/file_upload/file_upload_progress_container";
import { FileUploadProgressRecord } from "@web/core/file_upload/file_upload_progress_record";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, mount, nextTick, patchWithCleanup } from "../helpers/utils";
const { Component, xml } = owl;
const serviceRegistry = registry.category("services");
class FileUploadProgressTestRecord extends FileUploadProgressRecord {}
FileUploadProgressTestRecord.template = xml`
<t t-set="progressTexts" t-value="getProgressTexts()"/>
<div class="file_upload">
<div class="file_upload_progress_text_left" t-esc="progressTexts.left"/>
<div class="file_upload_progress_text_right" t-esc="progressTexts.right"/>
<FileUploadProgressBar fileUpload="props.fileUpload"/>
</div>
`;
class Parent extends Component {
setup() {
this.fileUploadService = useService("file_upload");
this.FileUploadProgressTestRecord = FileUploadProgressTestRecord;
}
}
Parent.components = {
FileUploadProgressContainer,
};
Parent.template = xml`
<div class="parent">
<FileUploadProgressContainer fileUploads="fileUploadService.uploads" shouldDisplay="props.shouldDisplay" Component="FileUploadProgressTestRecord"/>
</div>
`;
let env;
let target;
let patchUpload;
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(async () => {
serviceRegistry.add("file_upload", fileUploadService);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("localization", makeFakeLocalizationService());
patchUpload = (customSend) => {
patchWithCleanup(fileUploadService, {
createXhr() {
const xhr = {
upload: new window.EventTarget(),
open() {},
send(data) {
customSend && customSend(data);
},
};
return xhr;
},
});
};
target = getFixture();
});
QUnit.module("FileUploadProgressContainer");
QUnit.test("can be rendered", async (assert) => {
env = await makeTestEnv();
await mount(Parent, target, { env });
assert.containsOnce(target, ".parent");
assert.containsNone(target, ".file_upload");
});
QUnit.test("upload renders new component(s)", async (assert) => {
env = await makeTestEnv();
await mount(Parent, target, { env });
patchUpload();
const fileUploadService = env.services.file_upload;
fileUploadService.upload("/test/", []);
await nextTick();
assert.containsOnce(target, ".file_upload");
fileUploadService.upload("/test/", []);
await nextTick();
assert.containsN(target, ".file_upload", 2);
});
QUnit.test("upload end removes component", async (assert) => {
env = await makeTestEnv();
await mount(Parent, target, { env });
patchUpload();
const fileUploadService = env.services.file_upload;
fileUploadService.upload("/test/", []);
await nextTick();
fileUploadService.uploads[1].xhr.upload.dispatchEvent(new Event("load"));
await nextTick();
assert.containsNone(target, ".file_upload");
});
QUnit.test("upload error removes component", async (assert) => {
env = await makeTestEnv();
await mount(Parent, target, { env });
patchUpload();
const fileUploadService = env.services.file_upload;
fileUploadService.upload("/test/", []);
await nextTick();
fileUploadService.uploads[1].xhr.upload.dispatchEvent(new Event("error"));
await nextTick();
assert.containsNone(target, ".file_upload");
});
QUnit.test("upload abort removes component", async (assert) => {
env = await makeTestEnv();
await mount(Parent, target, { env });
patchUpload();
const fileUploadService = env.services.file_upload;
fileUploadService.upload("/test/", []);
await nextTick();
fileUploadService.uploads[1].xhr.upload.dispatchEvent(new Event("abort"));
await nextTick();
assert.containsNone(target, ".file_upload");
});
QUnit.test("upload can be aborted by clicking on cross", async (assert) => {
env = await makeTestEnv();
await mount(Parent, target, { env });
patchUpload();
const fileUploadService = env.services.file_upload;
fileUploadService.upload("/test/", []);
await nextTick();
patchWithCleanup(env.services.dialog, {
add: () => {
fileUploadService.uploads[1].xhr.upload.dispatchEvent(new Event("abort"));
},
});
await click(target, ".o-file-upload-progress-bar-abort", true);
assert.containsNone(target, ".file_upload");
});
QUnit.test("upload updates on progress", async (assert) => {
env = await makeTestEnv();
await mount(Parent, target, { env });
patchUpload();
const fileUploadService = env.services.file_upload;
fileUploadService.upload("/test/", []);
await nextTick();
const progressEvent = new Event("progress", { bubbles: true });
progressEvent.loaded = 250000000;
progressEvent.total = 500000000;
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(target.querySelector(".file_upload_progress_text_left").textContent, "Uploading... (50%)");
progressEvent.loaded = 350000000;
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(target.querySelector(".file_upload_progress_text_right").textContent, "(350/500MB)");
});
});