[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:
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user