summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulien Mougenot <jum@odoo.com>2019-11-22 11:37:09 +0000
committerJulien Mougenot <jum@odoo.com>2020-01-06 14:37:16 +0000
commit3c5194a00b3c0525dfd84552233e9c8f85a150f4 (patch)
treeaa2057962f6aefe00418de4c037f74010c7eb879
parent34bbdd88c0bc354b76b254bb78c4eb786f3d08c0 (diff)
[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>
-rw-r--r--addons/web/controllers/main.py8
-rw-r--r--addons/web/static/src/js/components/custom_file_input.js88
-rw-r--r--addons/web/static/src/xml/base.xml15
-rw-r--r--addons/web/static/tests/components/custom_file_input_tests.js103
-rw-r--r--addons/web/views/webclient_templates.xml3
5 files changed, 213 insertions, 4 deletions
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index b1e86ff2643..90657b2e764 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -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',
diff --git a/addons/web/static/src/js/components/custom_file_input.js b/addons/web/static/src/js/components/custom_file_input.js
new file mode 100644
index 00000000000..eb9c4267cfc
--- /dev/null
+++ b/addons/web/static/src/js/components/custom_file_input.js
@@ -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;
+});
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 961c11cdc90..5470a721805 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -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">
diff --git a/addons/web/static/tests/components/custom_file_input_tests.js b/addons/web/static/tests/components/custom_file_input_tests.js
new file mode 100644
index 00000000000..0131213f6e9
--- /dev/null
+++ b/addons/web/static/tests/components/custom_file_input_tests.js
@@ -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();
+ });
+ });
+ });
+});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 5cff8683427..bd297e6b616 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -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"/>