[IMP] website: manage better the files uploaded in a form

Before this commit, now that multiple files can be uploaded in a form,
we could observe that the file input is not convenient to manage this
situation:

- Clicking again on the input to choose other files replaces the
previously uploaded ones, we can therefore not upload files one by one.
- A file cannot be deleted in an obvious way: we need to cancel a new
upload, as it will replace the file by nothing and therefore delete it.
- When multiple files are uploaded, only the number of files is
displayed and not their name (they are only displayed on hover).

This commit improves these behaviors:

- Clicking again on the input adds additional files instead of replacing
them (unless the number of files allowed is 1);
- The button text content is modified after the upload of files,
displaying "Add Files"/"Replace File" (if multiple/single files are
allowed) to show its role better.
- When files are uploaded, blocks containing each files name and a
cross to delete them are displayed.

In order to make these improvements, the file input is hidden and
replaced by an input of type=`button` when files are uploaded. This was
done for two reasons:
- It is not possible to modify the content of an input of type=`file`
but we can change the value of a button.
- The input did not look good with the file blocks, because it has a
text displaying file informations, which was redundant with the blocks.

task-2798576

Part-of: odoo/odoo#89055
This commit is contained in:
Soukéina Bojabza
2023-05-15 12:55:58 +02:00
parent f676463dbb
commit fdfd5042f8
3 changed files with 198 additions and 4 deletions
@@ -41,6 +41,9 @@
selector: '.s_website_form form, form.s_website_form', // !compatibility
events: {
'click .s_website_form_send, .o_website_form_send': 'send', // !compatibility
"change input[type=file]": "_onFileChange",
"click input.o_add_files_button": "_onAddFilesButtonClick",
"click .o_file_delete": "_onFileDeleteClick",
},
/**
@@ -74,7 +77,7 @@
start: function () {
// Reset the form first, as it is still filled when coming back
// after a redirect.
this.el.reset();
this.resetForm();
// Prepare visibility data and update field visibilities
const visibilityFunctionsByFieldName = new Map();
@@ -196,6 +199,13 @@
this._disabledStates[inputEl] = inputEl.disabled;
}
// Add the files zones where the file blocks will be displayed.
this.el.querySelectorAll("input[type=file]").forEach(inputEl => {
const filesZoneEl = document.createElement("DIV");
filesZoneEl.classList.add("o_files_zone", "row", "gx-1");
inputEl.parentNode.insertBefore(filesZoneEl, inputEl);
});
return this._super(...arguments).then(() => this.__startResolve());
},
@@ -203,8 +213,8 @@
this._super.apply(this, arguments);
this.$el.find('button').off('click');
// Empty imputs
this.el.reset();
// Empty inputs
this.resetForm();
// Apply default values
const dateTimeFormat = time.getLangDatetimeFormat();
@@ -394,7 +404,7 @@
}
}
self.el.reset();
self.resetForm();
self.restoreBtnLoading();
}
})
@@ -406,6 +416,23 @@
});
},
/**
* Resets a form.
*/
resetForm() {
this.el.reset();
// For file inputs, remove the files zone, restore the file input
// and remove the files list.
this.el.querySelectorAll("input[type=file]").forEach(inputEl => {
const fieldEl = inputEl.closest(".s_website_form_field");
fieldEl.querySelectorAll(".o_files_zone").forEach(el => el.remove());
fieldEl.querySelectorAll(".o_add_files_button").forEach(el => el.remove());
inputEl.classList.remove("d-none");
delete inputEl.fileList;
});
},
check_error_fields: function (error_fields) {
var self = this;
var form_valid = true;
@@ -709,6 +736,35 @@
inputEl.disabled = !haveToBeVisible;
}
},
/**
* Creates a block containing the file name and a cross to delete it.
*
* @private
* @param {Object} fileDetails the details of the file being uploaded
* @param {HTMLElement} filesZoneEl the zone where the file blocks are
* displayed
*/
_createFileBlock(fileDetails, filesZoneEl) {
const fileBlockEl = qweb.render("website.file_block", {fileName: fileDetails.name});
filesZoneEl.insertAdjacentHTML("beforeend", fileBlockEl);
filesZoneEl.lastElementChild.fileDetails = fileDetails;
},
/**
* Creates the file upload button (= a button to replace the file input,
* in order to modify its text content more easily).
*
* @private
* @param {HTMLElement} inputEl the file input
*/
_createAddFilesButton(inputEl) {
const addFilesButtonEl = document.createElement("INPUT");
addFilesButtonEl.classList.add("o_add_files_button", "form-control");
addFilesButtonEl.type = "button";
addFilesButtonEl.value = inputEl.hasAttribute("multiple")
? _t("Add Files") : _t("Replace File");
inputEl.parentNode.insertBefore(addFilesButtonEl, inputEl);
inputEl.classList.add("d-none");
},
//----------------------------------------------------------------------
// Handlers
@@ -721,4 +777,96 @@
_onFieldInput() {
this._updateFieldsVisibility();
},
/**
* Called when files are uploaded: updates the button text content,
* displays the file blocks (containing the files name and a cross to
* delete them) and manages the files.
*
* @private
* @param {Event} ev
*/
_onFileChange(ev) {
const fileInputEl = ev.currentTarget;
const fieldEl = fileInputEl.closest(".s_website_form_field");
const uploadedFiles = fileInputEl.files;
const addFilesButtonEl = fieldEl.querySelector(".o_add_files_button");
// The zone where the file blocks are displayed.
let filesZoneEl = fieldEl.querySelector(".o_files_zone");
// Update the button text content.
if (!addFilesButtonEl) {
this._createAddFilesButton(fileInputEl);
}
// Create a list to keep track of the files.
if (!fileInputEl.fileList) {
fileInputEl.fileList = new DataTransfer();
}
// If only one file can be uploaded, delete the previous file.
if (!fileInputEl.hasAttribute("multiple") && uploadedFiles.length > 0) {
fileInputEl.fileList = new DataTransfer();
const fileBlockEl = fieldEl.querySelector(".o_file_block");
if (fileBlockEl) {
fileBlockEl.remove();
}
}
// Add the uploaded files if they are not already there.
for (const newFile of uploadedFiles) {
if (![...fileInputEl.fileList.files].some(file => newFile.name === file.name &&
newFile.size === file.size && newFile.type === file.type)) {
fileInputEl.fileList.items.add(newFile);
const fileDetails = {name: newFile.name, size: newFile.size, type: newFile.type};
this._createFileBlock(fileDetails, filesZoneEl);
}
}
// Update the input files.
fileInputEl.files = fileInputEl.fileList.files;
},
/**
* Called when a file is deleted by clicking on the cross on the block
* describing it.
*
* @private
* @param {Event} ev
*/
_onFileDeleteClick(ev) {
const fileBlockEl = ev.target.closest(".o_file_block");
const fieldEl = fileBlockEl.closest(".s_website_form_field");
const fileInputEl = fieldEl.querySelector("input[type=file]");
const fileDetails = fileBlockEl.fileDetails;
const addFilesButtonEl = fieldEl.querySelector(".o_add_files_button");
// Create a new file list containing the remaining files.
const newFileList = new DataTransfer();
for (const file of Object.values(fileInputEl.fileList.files)) {
if (file.name !== fileDetails.name || file.size !== fileDetails.size
|| file.type !== fileDetails.type) {
newFileList.items.add(file);
}
}
// Update the input lists and remove the file block.
Object.assign(fileInputEl, {fileList: newFileList, files: newFileList.files});
fileBlockEl.remove();
// Restore the file input if there are no files uploaded and update
// the fields visibility.
if (!newFileList.files.length) {
fileInputEl.classList.remove("d-none");
addFilesButtonEl.remove();
this._updateFieldsVisibility();
}
},
/**
* Detects when the fake input file button is clicked to simulate a
* click on the real input.
*
* @private
* @param {MouseEvent} ev
*/
_onAddFilesButtonClick(ev) {
const fileInputEl = ev.target.parentNode.querySelector("input[type=file]");
fileInputEl.click();
},
});
@@ -64,6 +64,42 @@
display: none;
}
}
// File blocks.
div.o_files_zone {
div.o_file_wrap {
border: 1px solid $o-gray-400;
border-radius: 0.25rem;
font-size: 0.9em;
line-height: normal;
div.o_file_name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
i.o_file_delete {
max-width: 25%;
padding: 2px 4px 3px 4px;
color: $primary;
cursor: pointer;
&:hover {
color: darken($primary, 7.5%);
}
}
}
}
.o_add_files_button {
background-color: $o-gray-200;
width: fit-content;
&:hover {
background-color: darken($o-gray-200, 4.5%);
}
}
}
body:not(.editor_enable) .s_website_form[data-vcss="001"] {
@@ -15,4 +15,14 @@
<t t-esc="message"/>
</span>
</t>
<!-- A block containing the file name and a cross to delete the file. -->
<t t-name="website.file_block">
<div class="o_file_block col-4">
<div class="o_file_wrap mb-1 px-2 py-1">
<div class="o_file_name d-inline-block w-75 pt-1" t-att-title="fileName" t-out="fileName"/>
<i class="o_file_delete fa fa-times mt-1 float-end"/>
</div>
</div>
</t>
</templates>