[ADD] web: Custom File Input

New component: the custom file input. Its purpose is to define a input of type file
with a custom trigger (button, link, etc.).

Its construction is the following:
- attributes: behaviour of the actual input (route to call, multifile, allowed
    extensions etc.)
- inner template: the trigger that will fire the upload prompt when clicked
Once a file has been uploaded, the component will emit a 'uploaded' custom event
containing the files.

closes odoo/odoo#38423

Related: odoo/enterprise#6031
Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
This commit is contained in:
Julien Mougenot
2020-01-06 14:37:16 +00:00
parent 34bbdd88c0
commit 3c5194a00b
5 changed files with 213 additions and 4 deletions
+4 -4
View File
@@ -1469,7 +1469,7 @@ class Binary(http.Controller):
@http.route('/web/binary/upload', type='http', auth="user")
@serialize_exception
def upload(self, callback, ufile):
def upload(self, ufile, callback=None):
# TODO: might be useful to have a configuration flag for max-length file uploads
out = """<script language="javascript" type="text/javascript">
var win = window.top.window;
@@ -1481,11 +1481,11 @@ class Binary(http.Controller):
ufile.content_type, base64.b64encode(data)]
except Exception as e:
args = [False, str(e)]
return out % (json.dumps(callback), json.dumps(args))
return out % (json.dumps(callback), json.dumps(args)) if callback else json.dumps(args)
@http.route('/web/binary/upload_attachment', type='http', auth="user")
@serialize_exception
def upload_attachment(self, callback, model, id, ufile):
def upload_attachment(self, model, id, ufile, callback=None):
files = request.httprequest.files.getlist('ufile')
Model = request.env['ir.attachment']
out = """<script language="javascript" type="text/javascript">
@@ -1519,7 +1519,7 @@ class Binary(http.Controller):
'id': attachment.id,
'size': attachment.file_size
})
return out % (json.dumps(callback), json.dumps(args))
return out % (json.dumps(callback), json.dumps(args)) if callback else json.dumps(args)
@http.route([
'/web/binary/company_logo',
@@ -0,0 +1,88 @@
odoo.define('web.CustomFileInput', function (require) {
"use strict";
const { Component, hooks } = owl;
const { useRef } = hooks;
/**
* Custom file input
*
* Component representing a customized input of type file. It takes a sub-template
* in its default t-slot and uses it as the trigger to open the file upload
* prompt.
* @extends Component
*/
class CustomFileInput extends Component {
/**
* @param {Object} [props]
* @param {string} [props.accepted_file_extensions='*'] Comma-separated
* list of authorized file extensions (default to all).
* @param {string} [props.action='/web/binary/upload'] Route called when
* a file is uploaded in the input.
* @param {string} [props.id]
* @param {string} [props.model]
* @param {string} [props.multi_upload=false] Whether the input should allow
* to upload multiple files at once.
*/
constructor() {
super(...arguments);
this.fileInputRef = useRef('file-input');
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Upload an attachment to the given action with the given parameters:
* - ufile: list of files contained in the file input
* - csrf_token: CSRF token provided by the odoo global object
* - model: a specific model which will be given when creating the attachment
* - id: the id of the model target instance
* @private
*/
async _onFileInputChange() {
const { action, model, id } = this.props;
const params = {
csrf_token: odoo.csrf_token,
ufile: [...this.fileInputRef.el.files],
};
if (model) {
params.model = model;
}
if (id) {
params.id = id;
}
const fileData = await this.env.services.httpRequest(action, params, 'text');
const parsedFileData = JSON.parse(fileData);
if (parsedFileData.error) {
throw new Error(parsedFileData.error);
}
this.trigger('uploaded', { files: parsedFileData });
}
/**
* Redirect clicks from the trigger element to the input.
* @private
*/
_onTriggerClicked() {
this.fileInputRef.el.click();
}
}
CustomFileInput.defaultProps = {
accepted_file_extensions: '*',
action: '/web/binary/upload',
multi_upload: false,
};
CustomFileInput.props = {
accepted_file_extensions: { type: String, optional: 1 },
action: { type: String, optional: 1 },
id: { type: Number, optional: 1 },
model: { type: String, optional: 1 },
multi_upload: { type: Boolean, optional: 1 },
};
CustomFileInput.template = 'CustomFileInput';
return CustomFileInput;
});
+15
View File
@@ -3,6 +3,21 @@
<!-- Owl Templates -->
<t t-name="CustomFileInput" owl="1">
<span class="o_file_input" aria-atomic="true">
<span class="o_file_input_trigger" t-on-click.prevent="_onTriggerClicked">
<t t-slot="default">
<button class="btn btn-primary">Choose File</button>
</t>
</span>
<input type="file" name="ufile" class="o_input_file d-none"
t-att="{multiple: props.multi_upload, accept: props.accepted_file_extensions}"
t-ref="file-input"
t-on-change="_onFileInputChange"
/>
</span>
</t>
<t t-name="DialogButton.tooltip" owl="1">
<div class="oe_tooltip_string" role="tooltip">
<div class="tooltip-inner">
@@ -0,0 +1,103 @@
odoo.define('web.custom_file_input_tests', function (require) {
"use strict";
const CustomFileInput = require('web.CustomFileInput');
const makeTestEnvironment = require('web.test_env');
const testUtils = require('web.test_utils');
const { Component, tags, useState } = owl;
const { xml } = tags;
QUnit.module('Components', {}, function () {
// This module cannot be tested as thoroughly as we want it to be:
// browsers do not let scripts programmatically assign values to inputs
// of type file
QUnit.module('CustomFileInput', {}, function () {
QUnit.test("Rendering of all props", async function (assert) {
assert.expect(12);
class Parent extends Component {
constructor() {
super(...arguments);
this.state = useState({ innerContent: '<a href="#">Trigger</a>' });
}
// Handlers
_onFileUploaded(ev) {
assert.strictEqual(ev.detail.files.length, 0,
"'files' property should be an empty array");
}
}
Parent.components = { CustomFileInput };
Parent.env = makeTestEnvironment({
services: {
httpRequest: (route, params) => {
if (route === '/web/binary/upload') {
assert.deepEqual(params, {
csrf_token: odoo.csrf_token,
ufile: [],
});
assert.step(route);
return Promise.resolve('[]');
} else if (route === '/web/binary/upload_attachment') {
assert.deepEqual(params, {
id: 5,
model: 'res.model',
csrf_token: odoo.csrf_token,
ufile: [],
});
assert.step(route);
return Promise.resolve('[]');
}
},
},
});
Parent.template = xml`
<CustomFileInput
accepted_file_extensions="state.accepted_file_extensions"
action="state.action"
id="state.id"
model="state.model"
multi_upload="state.multi_upload"
t-on-uploaded="_onFileUploaded"
>
<t t-raw="state.innerContent"/>
</CustomFileInput>`;
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
const fileInput = parent.el;
const input = fileInput.querySelector('input');
// Default props
assert.strictEqual(fileInput.innerText.trim(), "Trigger",
"File input total text should match its given inner element's text");
assert.strictEqual(input.accept, '*',
"Input should accept all files by default");
await testUtils.dom.triggerEvent(input, 'change');
assert.notOk(input.multiple,
"'multiple' attribute should not be set");
// Change props
Object.assign(parent.state, {
accepted_file_extensions: '.png',
action: '/web/binary/upload_attachment',
id: 5,
model: 'res.model',
multi_upload: true,
});
await testUtils.nextTick();
assert.strictEqual(input.accept, '.png',
"Input should now only accept pngs");
await testUtils.dom.triggerEvent(input, 'change');
assert.ok(input.multiple,
"'multiple' attribute should be set");
assert.verifySteps(['/web/binary/upload', '/web/binary/upload_attachment']);
parent.destroy();
});
});
});
});
+3
View File
@@ -288,6 +288,7 @@
<script type="text/javascript" src="/web/static/src/js/widgets/pie_chart.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/ribbon.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/signature.js"></script>
<script type="text/javascript" src="/web/static/src/js/components/custom_file_input.js"></script>
<script type="text/javascript" src="/web/static/src/js/apps.js"></script>
<script type="text/javascript" src="/web/static/src/js/fields/abstract_field.js"></script>
@@ -715,6 +716,8 @@
<script type="text/javascript" src="/web/static/tests/chrome/user_menu_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/chrome/systray_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/components/custom_file_input_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/widgets/company_switcher_tests.js"/>
<script type="text/javascript" src="/web/static/tests/widgets/data_export_tests.js"/>
<script type="text/javascript" src="/web/static/tests/widgets/domain_selector_tests.js"/>