diff options
| author | Julien Mougenot <jum@odoo.com> | 2019-11-22 11:37:09 +0000 |
|---|---|---|
| committer | Julien Mougenot <jum@odoo.com> | 2020-01-06 14:37:16 +0000 |
| commit | 3c5194a00b3c0525dfd84552233e9c8f85a150f4 (patch) | |
| tree | aa2057962f6aefe00418de4c037f74010c7eb879 | |
| parent | 34bbdd88c0bc354b76b254bb78c4eb786f3d08c0 (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.py | 8 | ||||
| -rw-r--r-- | addons/web/static/src/js/components/custom_file_input.js | 88 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 15 | ||||
| -rw-r--r-- | addons/web/static/tests/components/custom_file_input_tests.js | 103 | ||||
| -rw-r--r-- | addons/web/views/webclient_templates.xml | 3 |
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"/> |
