diff --git a/survey_extra_fields/i18n/fr.po b/survey_extra_fields/i18n/fr.po index 8a6d53f..137c1ec 100644 --- a/survey_extra_fields/i18n/fr.po +++ b/survey_extra_fields/i18n/fr.po @@ -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 5ea52d2..cbe7821 100644 --- a/survey_extra_fields/models/survey_user_input.py +++ b/survey_extra_fields/models/survey_user_input.py @@ -23,14 +23,25 @@ class SurveyUserInput(models.Model): ("user_input_id", "=", self.id), ("question_id", "=", question.id), ]) + 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 20844b8..9219b95 100644 --- a/survey_extra_fields/static/src/js/survey_form.js +++ b/survey_extra_fields/static/src/js/survey_form.js @@ -6,6 +6,68 @@ odoo.define("survey_extra_fields.survey_form", function (require) { var survey_form = require("survey.form"); survey_form.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 () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.$el.on( + "change.surveyExtraFile", + 'input[data-question-type="file"]', + self._onFileInputChange.bind(self) + ); + self.$el.on( + "click.surveyExtraFile", + ".o_survey_file_clear", + self._onFileClearClick.bind(self) + ); + }); + }, + + /** + * 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) { + var input = ev.currentTarget; + var $container = $(input).closest(".o_survey_comment_container"); + if (!$container.length || !(input.files && input.files.length > 0)) { + return; + } + $container.find(".o_survey_file_name").text(input.files[0].name); + $container.find(".o_survey_file_selected").removeClass("d-none"); + delete input.dataset.fileCleared; + $(input).addClass("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(); + var $container = $(ev.currentTarget).closest(".o_survey_comment_container"); + if (!$container.length) { + return; + } + var $input = $container.find('input[data-question-type="file"]'); + if ($input.length) { + $input.val(""); + // Flag the explicit removal so the submit tells the server to + // drop any previously stored file (instead of preserving it). + $input[0].dataset.fileCleared = "1"; + $input.removeClass("d-none"); + } + $container.find(".o_survey_file_selected").addClass("d-none"); + }, + _readFileAsDataURL: function (file) { return new Promise(function (resolve, reject) { var reader = new FileReader(); @@ -19,18 +81,28 @@ odoo.define("survey_extra_fields.survey_form", function (require) { }); }, + /** + * @override + * The base implementation builds the submit params synchronously and + * fires the RPC immediately. File inputs need to be read asynchronously + * (FileReader), so when the current page contains file answers we + * replicate the submit flow here, injecting the base64 file payload + * before submitting. + */ _submitForm: function (options) { var self = this; var $fileInputs = this.$('input[data-question-type="file"]'); - var hasFiles = false; + // A file action is either a new selection or an explicit removal of + // a previously stored file (which must be communicated to the server). + var hasFileAction = false; $fileInputs.each(function () { - if (this.files && this.files.length > 0) { - hasFiles = true; + if ((this.files && this.files.length > 0) || this.dataset.fileCleared) { + hasFileAction = true; return false; } }); - if (!hasFiles || this.options.isStartScreen) { + if (!hasFileAction || this.options.isStartScreen) { return this._super(options); } @@ -51,7 +123,9 @@ odoo.define("survey_extra_fields.survey_form", function (require) { this._prepareSubmitValues(formData, params); - // Read all selected files as base64 + // 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. var filePromises = []; $fileInputs.each(function () { if (this.files && this.files.length > 0) { @@ -65,6 +139,8 @@ odoo.define("survey_extra_fields.survey_form", function (require) { }); }) ); + } else if (this.dataset.fileCleared) { + params[this.name] = JSON.stringify({ cleared: true }); } }); @@ -107,7 +183,16 @@ odoo.define("survey_extra_fields.survey_form", function (require) { $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. + var $chip = $(this) + .closest(".o_survey_comment_container") + .find(".o_survey_file_selected"); + var hasExistingFile = $chip.length && !$chip.hasClass("d-none"); + if (!hasExistingFile) { + errors[questionId] = constrErrorMsg; + } return; } if (this.files && this.files.length > 0) { @@ -143,4 +228,4 @@ odoo.define("survey_extra_fields.survey_form", function (require) { return result; }, }); -}); +}); \ No newline at end of file diff --git a/survey_extra_fields/tests/test_survey_file.py b/survey_extra_fields/tests/test_survey_file.py index 3529ac9..150cae8 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 26ac874..003cd1f 100644 --- a/survey_extra_fields/views/survey_templates.xml +++ b/survey_extra_fields/views/survey_templates.xml @@ -15,13 +15,26 @@