diff --git a/addons/website/static/src/snippets/s_website_form/000.js b/addons/website/static/src/snippets/s_website_form/000.js index ea5dedb5e6c..5716d9a2983 100644 --- a/addons/website/static/src/snippets/s_website_form/000.js +++ b/addons/website/static/src/snippets/s_website_form/000.js @@ -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(); + }, }); diff --git a/addons/website/static/src/snippets/s_website_form/001.scss b/addons/website/static/src/snippets/s_website_form/001.scss index dcd5accd916..df2db1ff133 100644 --- a/addons/website/static/src/snippets/s_website_form/001.scss +++ b/addons/website/static/src/snippets/s_website_form/001.scss @@ -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"] { diff --git a/addons/website/static/src/xml/website_form.xml b/addons/website/static/src/xml/website_form.xml index b8397688e50..4596493684c 100644 --- a/addons/website/static/src/xml/website_form.xml +++ b/addons/website/static/src/xml/website_form.xml @@ -15,4 +15,14 @@ + + + +
+
+
+ +
+
+