diff --git a/survey_extra_fields/i18n/fr.po b/survey_extra_fields/i18n/fr.po
index ee74c48..955f592 100644
--- a/survey_extra_fields/i18n/fr.po
+++ b/survey_extra_fields/i18n/fr.po
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-06-10 14:31+0000\n"
-"PO-Revision-Date: 2026-06-10 14:31+0000\n"
+"POT-Creation-Date: 2026-06-10 15:03+0000\n"
+"PO-Revision-Date: 2026-06-10 15:03+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -20,6 +20,11 @@ msgstr ""
msgid ".pdf,.docx,.xlsx"
msgstr ""
+#. module: survey_extra_fields
+#: model_terms:ir.ui.view,arch_db:survey_extra_fields.question_file
+msgid "Remove file"
+msgstr "Supprimer le fichier"
+
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__allowed_extensions
msgid "Allowed Extensions"
diff --git a/survey_extra_fields/models/survey_user_input.py b/survey_extra_fields/models/survey_user_input.py
index 35683b8..27de879 100644
--- a/survey_extra_fields/models/survey_user_input.py
+++ b/survey_extra_fields/models/survey_user_input.py
@@ -31,14 +31,25 @@ class SurveyUserInput(models.Model):
])
if old_answers and not overwrite_existing:
raise UserError(_("This answer cannot be overwritten."))
+ if not answer and any(line.value_file for line in old_answers):
+ # No new file was submitted: a file input cannot be pre-filled
+ # by the browser when navigating back to a previous page, so an
+ # empty answer here does not mean the user removed their file.
+ # Keep the previously uploaded file instead of overwriting it
+ # with a skipped answer.
+ return
vals = {
"user_input_id": self.id,
"question_id": question.id,
"skipped": False,
"answer_type": "file",
}
- if answer:
- file_data = json.loads(answer)
+ file_data = json.loads(answer) if answer else {}
+ if file_data.get("cleared"):
+ # The user explicitly removed the file: drop the stored data and
+ # mark the line as skipped.
+ vals.update(answer_type=None, skipped=True, value_file=False, value_file_fname=False)
+ elif file_data:
file_b64 = file_data.get("data", "")
file_name = file_data.get("name", "")
self._check_file_constraints(question, file_b64, file_name)
diff --git a/survey_extra_fields/static/src/js/survey_form.js b/survey_extra_fields/static/src/js/survey_form.js
index a84d148..f2f94fc 100644
--- a/survey_extra_fields/static/src/js/survey_form.js
+++ b/survey_extra_fields/static/src/js/survey_form.js
@@ -5,6 +5,76 @@ import { _t } from "@web/core/l10n/translation";
import { rpc } from "@web/core/network/rpc";
publicWidget.registry.SurveyFormWidget.include({
+ /**
+ * @override
+ * Bind delegated listeners on the form root so they keep working after each
+ * page is re-rendered (the inner content is replaced on navigation, but the
+ * root element persists). They let the user clear a selected file before
+ * submitting the form.
+ */
+ start: function () {
+ return this._super.apply(this, arguments).then(() => {
+ this.$el.on(
+ "change.surveyExtraFile",
+ 'input[data-question-type="file"]',
+ this._onFileInputChange.bind(this)
+ );
+ this.$el.on(
+ "click.surveyExtraFile",
+ ".o_survey_file_clear",
+ this._onFileClearClick.bind(this)
+ );
+ });
+ },
+
+ /**
+ * On selection, show the file "chip" (filename + remove button) and hide the
+ * raw input, so a freshly selected file looks exactly like an already stored
+ * one (rendered server-side when navigating back).
+ */
+ _onFileInputChange: function (ev) {
+ const input = ev.currentTarget;
+ const container = input.closest(".o_survey_comment_container");
+ if (!container || !(input.files && input.files.length > 0)) {
+ return;
+ }
+ const chip = container.querySelector(".o_survey_file_selected");
+ const nameEl = container.querySelector(".o_survey_file_name");
+ if (nameEl) {
+ nameEl.textContent = input.files[0].name;
+ }
+ if (chip) {
+ chip.classList.remove("d-none");
+ }
+ delete input.dataset.fileCleared;
+ input.classList.add("d-none");
+ },
+
+ /**
+ * Discard the current file: hide the chip and bring back the input so the
+ * user can pick a new one. A file already stored server-side is only really
+ * replaced once a new file is submitted (see _save_lines).
+ */
+ _onFileClearClick: function (ev) {
+ ev.preventDefault();
+ const container = ev.currentTarget.closest(".o_survey_comment_container");
+ if (!container) {
+ return;
+ }
+ const input = container.querySelector('input[data-question-type="file"]');
+ const chip = container.querySelector(".o_survey_file_selected");
+ if (input) {
+ input.value = "";
+ // Flag the explicit removal so the submit tells the server to drop
+ // any previously stored file (instead of preserving it).
+ input.dataset.fileCleared = "1";
+ input.classList.remove("d-none");
+ }
+ if (chip) {
+ chip.classList.add("d-none");
+ }
+ },
+
_readFileAsDataURL: function (file) {
return new Promise(function (resolve, reject) {
const reader = new FileReader();
@@ -27,11 +97,13 @@ publicWidget.registry.SurveyFormWidget.include({
*/
_submitForm: async function (options) {
const fileInputs = this.el.querySelectorAll('input[data-question-type="file"]');
- const hasFiles = Array.from(fileInputs).some(
- (input) => input.files && input.files.length > 0
+ // A file action is either a new selection or an explicit removal of a
+ // previously stored file (which must be communicated to the server).
+ const hasFileAction = Array.from(fileInputs).some(
+ (input) => (input.files && input.files.length > 0) || input.dataset.fileCleared
);
- if (!hasFiles || this.options.isStartScreen) {
+ if (!hasFileAction || this.options.isStartScreen) {
return this._super(options);
}
@@ -53,6 +125,8 @@ publicWidget.registry.SurveyFormWidget.include({
this._prepareSubmitValues(formData, params);
// Read all selected files as base64 and add them to the submit params.
+ // Explicitly cleared inputs (no new file) send a "cleared" sentinel so
+ // the server removes the previously stored file.
const filePromises = [];
for (const input of fileInputs) {
if (input.files && input.files.length > 0) {
@@ -66,6 +140,8 @@ publicWidget.registry.SurveyFormWidget.include({
});
})
);
+ } else if (input.dataset.fileCleared) {
+ params[input.name] = JSON.stringify({ cleared: true });
}
}
@@ -108,7 +184,15 @@ publicWidget.registry.SurveyFormWidget.include({
$questionWrapper.data("constrErrorMsg") ||
_t("This question requires an answer.");
if (questionRequired && !(this.files && this.files.length > 0)) {
- errors[questionId] = constrErrorMsg;
+ // A file may already be stored server-side (e.g. uploaded then
+ // navigating back): the chip is visible even though the input is
+ // empty. Treat that as a valid answer.
+ const container = this.closest(".o_survey_comment_container");
+ const chip = container && container.querySelector(".o_survey_file_selected");
+ const hasExistingFile = chip && !chip.classList.contains("d-none");
+ if (!hasExistingFile) {
+ errors[questionId] = constrErrorMsg;
+ }
return;
}
if (this.files && this.files.length > 0) {
diff --git a/survey_extra_fields/tests/test_survey_file.py b/survey_extra_fields/tests/test_survey_file.py
index 47886c1..2d5efc1 100644
--- a/survey_extra_fields/tests/test_survey_file.py
+++ b/survey_extra_fields/tests/test_survey_file.py
@@ -94,19 +94,38 @@ class TestSurveyFileSaveLines(TestSurveyFileCommon):
self.assertEqual(lines.value_file, new_b64.encode())
self.assertEqual(lines.value_file_fname, "second.pdf")
- def test_save_file_then_skip(self):
- """Uploading a file then submitting empty marks line as skipped."""
+ def test_save_file_then_empty_keeps_file(self):
+ """Submitting empty after a file keeps it (file inputs cannot be
+ pre-filled when navigating back, so an empty answer must not erase it)."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer._save_lines(self.question_file, file_json)
answer._save_lines(self.question_file, "")
+ line = answer.user_input_line_ids.filtered(
+ lambda l: l.question_id == self.question_file
+ )
+ self.assertEqual(len(line), 1)
+ self.assertFalse(line.skipped)
+ self.assertEqual(line.value_file, self.file_b64.encode())
+ self.assertEqual(line.value_file_fname, self.file_name)
+
+ def test_save_file_explicitly_cleared(self):
+ """Submitting the 'cleared' sentinel after a file removes it."""
+ answer = self._add_answer(self.survey, self.survey_manager.partner_id)
+ file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
+ answer._save_lines(self.question_file, file_json)
+
+ answer._save_lines(self.question_file, json.dumps({"cleared": True}))
+
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertTrue(line.skipped)
+ self.assertFalse(line.value_file)
+ self.assertFalse(line.value_file_fname)
class TestSurveyFileConstraints(TestSurveyFileCommon):
diff --git a/survey_extra_fields/views/survey_templates.xml b/survey_extra_fields/views/survey_templates.xml
index f2aaa09..20956f3 100644
--- a/survey_extra_fields/views/survey_templates.xml
+++ b/survey_extra_fields/views/survey_templates.xml
@@ -15,13 +15,26 @@
++
+