From d0afa2310d78011ef90873618ca20fe58d888940 Mon Sep 17 00:00:00 2001 From: Quentin Mondot Date: Wed, 10 Jun 2026 17:23:31 +0200 Subject: [PATCH] [IMP] survey_extra_fields : handle file question on page navigation * Show a stored file (after navigating back) the same way as a freshly selected one: a "filename + Remove file" chip, input hidden until removal. * Keep the stored file when a page is re-submitted without a new selection (instead of overwriting it with a skipped answer). * "Remove file" now sends a sentinel to actually delete the stored file, so the removal persists across navigation. --- survey_extra_fields/i18n/fr.po | 9 +- .../models/survey_user_input.py | 15 ++- .../static/src/js/survey_form.js | 92 ++++++++++++++++++- survey_extra_fields/tests/test_survey_file.py | 23 ++++- .../views/survey_templates.xml | 21 ++++- 5 files changed, 146 insertions(+), 14 deletions(-) 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 @@