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 @@
+
+
+
+
+