diff --git a/app/assets/stylesheets/attachment.scss b/app/assets/stylesheets/attachment.scss index 2584c972e..3688742e7 100644 --- a/app/assets/stylesheets/attachment.scss +++ b/app/assets/stylesheets/attachment.scss @@ -25,7 +25,8 @@ } } -.attachment-multiple:not(.fr-downloads-group) { +.attachment-multiple:not(.fr-downloads-group), +.attachment-multiple.fr-downloads-group[data-controller=replace-attachment] { ul { list-style-type: none; padding-inline-start: 0; @@ -42,6 +43,10 @@ position: relative; top: -1rem; } + + .fr-btn { + align-self: flex-start; + } } .attachment-multiple.fr-downloads-group.destroyable { diff --git a/app/components/attachment/edit_component.rb b/app/components/attachment/edit_component.rb index 7abb27f93..3cd253b22 100644 --- a/app/components/attachment/edit_component.rb +++ b/app/components/attachment/edit_component.rb @@ -4,12 +4,14 @@ class Attachment::EditComponent < ApplicationComponent attr_reader :attachment attr_reader :user_can_destroy alias user_can_destroy? user_can_destroy + attr_reader :user_can_replace + alias user_can_replace? user_can_replace attr_reader :as_multiple alias as_multiple? as_multiple EXTENSIONS_ORDER = ['jpeg', 'png', 'pdf', 'zip'].freeze - def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, **kwargs) + def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, **kwargs) @as_multiple = as_multiple @attached_file = attached_file @auto_attach_url = auto_attach_url @@ -18,6 +20,7 @@ class Attachment::EditComponent < ApplicationComponent @index = index @view_as = view_as @user_can_destroy = user_can_destroy + @user_can_replace = user_can_replace # attachment passed by kwarg because we don't want a default (nil) value. @attachment = if kwargs.key?(:attachment) @@ -64,13 +67,14 @@ class Attachment::EditComponent < ApplicationComponent def file_field_options track_issue_with_missing_validators if missing_validators? { - class: "fr-upload attachment-input #{attachment_input_class} #{persisted? ? 'hidden' : ''}", + class: class_names("fr-upload attachment-input": true, "#{attachment_input_class}": true, "hidden": persisted?, "fr-mt-2w": user_can_replace?), direct_upload: @direct_upload, id: input_id, aria: { describedby: champ&.describedby_id }, data: { auto_attach_url: }.merge(has_file_size_validator? ? { max_file_size: } : {}) + .merge(user_can_replace? ? { replace_attachment_target: "input" } : {}) }.merge(has_content_type_validator? ? { accept: accept_content_type } : {}) end @@ -103,6 +107,24 @@ class Attachment::EditComponent < ApplicationComponent } end + def replace_button_options + { + type: 'button', + data: { + action: "click->replace-attachment#open", + auto_attach_url: auto_attach_url + }.compact + } + end + + def replace_controller_attributes + return {} if !persisted? || !user_can_replace? || as_multiple? + + { + "data-controller": 'replace-attachment' + } + end + def retry_button_options { type: 'button', @@ -155,7 +177,9 @@ class Attachment::EditComponent < ApplicationComponent def auto_attach_url return @auto_attach_url if @auto_attach_url.present? - return helpers.auto_attach_url(@champ) if @champ.present? + params = { replace_attachment_id: @attachment.id } if user_can_replace? && @attachment.present? + + return helpers.auto_attach_url(@champ, params) if @champ.present? nil end diff --git a/app/components/attachment/edit_component/edit_component.en.yml b/app/components/attachment/edit_component/edit_component.en.yml index 33148c1be..7e2a20625 100644 --- a/app/components/attachment/edit_component/edit_component.en.yml +++ b/app/components/attachment/edit_component/edit_component.en.yml @@ -4,6 +4,9 @@ en: allowed_formats: "Supported formats : %{formats}" retry: Retry delete: Delete + delete_file: Delete file %{filename} + replace: Replace + replace_file: Replace file %{filename} errors: uploading: "An error occurred while sending the file." virus_infected: "Virus detected, please send another file." diff --git a/app/components/attachment/edit_component/edit_component.fr.yml b/app/components/attachment/edit_component/edit_component.fr.yml index fa4e86141..19ba08a3a 100644 --- a/app/components/attachment/edit_component/edit_component.fr.yml +++ b/app/components/attachment/edit_component/edit_component.fr.yml @@ -4,6 +4,9 @@ fr: allowed_formats: "Formats supportés : %{formats}" retry: Réessayer delete: Supprimer + delete_file: Supprimer le fichier %{filename} + replace: Remplacer + replace_file: Remplacer le fichier %{filename} errors: uploading: "Une erreur s’est produite pendant l’envoi du fichier." virus_infected: "Virus détecté, merci d’envoyer un autre fichier." diff --git a/app/components/attachment/edit_component/edit_component.html.haml b/app/components/attachment/edit_component/edit_component.html.haml index 90ddb9512..bc54ef9f4 100644 --- a/app/components/attachment/edit_component/edit_component.html.haml +++ b/app/components/attachment/edit_component/edit_component.html.haml @@ -1,14 +1,16 @@ -.attachment.fr-upload-group{ { id: attachment ? dom_id(attachment, :edit) : nil, class: class_names("fr-mb-2w": !(as_multiple? && downloadable?)) }.compact } +.attachment.fr-upload-group{ { id: attachment ? dom_id(attachment, :edit) : nil, class: class_names("fr-mb-2w": !(as_multiple? && downloadable?)) }.compact, **replace_controller_attributes } - if persisted? %div{ id: dom_id(attachment, :persisted_row) } .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } - if user_can_destroy? - = link_to(t('.delete'), destroy_attachment_path, **remove_button_options, class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: "Supprimer le fichier #{attachment.filename}") + = link_to(t('.delete'), destroy_attachment_path, **remove_button_options, class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)) + - elsif user_can_replace? + = button_tag t('.replace'), **replace_button_options, class: "fr-btn fr-btn--tertiary fr-btn--sm", title: t(".replace_file", filename: attachment.filename) - .fr-py-1v - - if downloadable? - = render Dsfr::DownloadComponent.new(attachment:) - - else + - if downloadable? + = render Dsfr::DownloadComponent.new(attachment:) + - else + .fr-py-1v %span.attachment-filename.fr-mr-1w= link_to_if(viewable?, attachment.filename.to_s, helpers.url_for(attachment.blob), title: "Ouvrir le fichier #{attachment.filename.to_s}", **helpers.external_link_attributes) = render Attachment::ProgressComponent.new(attachment: attachment) diff --git a/app/components/attachment/multiple_component.rb b/app/components/attachment/multiple_component.rb index c0229bf90..2a1baa3d7 100644 --- a/app/components/attachment/multiple_component.rb +++ b/app/components/attachment/multiple_component.rb @@ -12,15 +12,18 @@ class Attachment::MultipleComponent < ApplicationComponent attr_reader :view_as attr_reader :user_can_destroy alias user_can_destroy? user_can_destroy + attr_reader :user_can_replace + alias user_can_replace? user_can_replace delegate :count, :empty?, to: :attachments, prefix: true - def initialize(champ:, attached_file:, form_object_name: nil, view_as: :link, user_can_destroy: true, max: nil) + def initialize(champ:, attached_file:, form_object_name: nil, view_as: :link, user_can_destroy: true, user_can_replace: false, max: nil) @champ = champ @attached_file = attached_file @form_object_name = form_object_name @view_as = view_as @user_can_destroy = user_can_destroy + @user_can_replace = user_can_replace @max = max || DEFAULT_MAX_ATTACHMENTS @attachments = attached_file.attachments || [] @@ -48,4 +51,12 @@ class Attachment::MultipleComponent < ApplicationComponent nil end + + def replace_controller_attributes + return {} unless user_can_replace? + + { + "data-controller": 'replace-attachment' + } + end end diff --git a/app/components/attachment/multiple_component/multiple_component.html.haml b/app/components/attachment/multiple_component/multiple_component.html.haml index b7a9036e8..ecca7269f 100644 --- a/app/components/attachment/multiple_component/multiple_component.html.haml +++ b/app/components/attachment/multiple_component/multiple_component.html.haml @@ -1,13 +1,13 @@ -.fr-mb-4w.attachment-multiple{ class: class_names("fr-downloads-group": view_as == :download, "destroyable": user_can_destroy?) } +.fr-mb-4w.attachment-multiple{ class: class_names("fr-downloads-group": view_as == :download, "destroyable": user_can_destroy?), **replace_controller_attributes } = template %ul - each_attachment do |attachment, index| %li{ id: dom_id(attachment) } - = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, as_multiple: true, view_as:, user_can_destroy:, form_object_name:) + = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, as_multiple: true, view_as:, user_can_destroy:, user_can_replace:, form_object_name:) %div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?) } - = render Attachment::EditComponent.new(champ:, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:) + = render Attachment::EditComponent.new(champ:, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, user_can_replace:, form_object_name:) // single poll and refresh message for all attachments = render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:, context: poll_context) diff --git a/app/components/editable_champ/piece_justificative_component.rb b/app/components/editable_champ/piece_justificative_component.rb index 9a14d389f..3d1cfaf80 100644 --- a/app/components/editable_champ/piece_justificative_component.rb +++ b/app/components/editable_champ/piece_justificative_component.rb @@ -11,6 +11,10 @@ class EditableChamp::PieceJustificativeComponent < EditableChamp::EditableChampB !@champ.mandatory? || @champ.dossier.brouillon? end + def user_can_replace? + @champ.mandatory? && @champ.dossier.en_construction? + end + def max [true, nil].include?(@champ.procedure&.piece_justificative_multiple?) ? Attachment::MultipleComponent::DEFAULT_MAX_ATTACHMENTS : 1 end diff --git a/app/components/editable_champ/piece_justificative_component/piece_justificative_component.html.haml b/app/components/editable_champ/piece_justificative_component/piece_justificative_component.html.haml index 4d549cb22..322c029aa 100644 --- a/app/components/editable_champ/piece_justificative_component/piece_justificative_component.html.haml +++ b/app/components/editable_champ/piece_justificative_component/piece_justificative_component.html.haml @@ -1,4 +1,4 @@ -= render Attachment::MultipleComponent.new(champ: @champ, attached_file: @champ.piece_justificative_file, form_object_name: @form.object_name, view_as:, user_can_destroy: user_can_destroy?, max:) do |c| += render Attachment::MultipleComponent.new(champ: @champ, attached_file: @champ.piece_justificative_file, form_object_name: @form.object_name, view_as:, user_can_destroy: user_can_destroy?, user_can_replace: user_can_replace?, max:) do |c| - if @champ.type_de_champ.piece_justificative_template&.attached? - c.with_template do = render partial: "shared/piece_justificative_template", locals: { champ: @champ } diff --git a/app/components/editable_champ/titre_identite_component.rb b/app/components/editable_champ/titre_identite_component.rb index 7bb8536d0..cde3489ad 100644 --- a/app/components/editable_champ/titre_identite_component.rb +++ b/app/components/editable_champ/titre_identite_component.rb @@ -1,2 +1,9 @@ class EditableChamp::TitreIdentiteComponent < EditableChamp::EditableChampBaseComponent + def user_can_destroy? + !@champ.mandatory? || @champ.dossier.brouillon? + end + + def user_can_replace? + @champ.mandatory? && @champ.dossier.en_construction? + end end diff --git a/app/components/editable_champ/titre_identite_component/titre_identite_component.html.haml b/app/components/editable_champ/titre_identite_component/titre_identite_component.html.haml index 1a56c074d..778ce957c 100644 --- a/app/components/editable_champ/titre_identite_component/titre_identite_component.html.haml +++ b/app/components/editable_champ/titre_identite_component/titre_identite_component.html.haml @@ -1,5 +1,5 @@ -- user_can_destroy = !@champ.mandatory? || @champ.dossier.brouillon? - if @champ.type_de_champ.piece_justificative_template&.attached? = render partial: "shared/piece_justificative_template", locals: { champ: @champ } -= render Attachment::EditComponent.new(champ: @form.object, attached_file: @champ.piece_justificative_file, attachment: @champ.piece_justificative_file[0], form_object_name: @form.object_name, user_can_destroy:) += render Attachment::EditComponent.new(champ: @form.object, attached_file: @champ.piece_justificative_file, attachment: @champ.piece_justificative_file[0], form_object_name: @form.object_name, + user_can_destroy: user_can_destroy?, user_can_replace: user_can_replace?) diff --git a/app/controllers/champs/piece_justificative_controller.rb b/app/controllers/champs/piece_justificative_controller.rb index f95968b62..92e776370 100644 --- a/app/controllers/champs/piece_justificative_controller.rb +++ b/app/controllers/champs/piece_justificative_controller.rb @@ -28,9 +28,22 @@ class Champs::PieceJustificativeController < ApplicationController end def attach_piece_justificative - @champ.piece_justificative_file.attach(params[:blob_signed_id]) - save_succeed = @champ.save + save_succeed = nil + + ActiveStorage::Attachment.transaction do + if params.key?(:replace_attachment_id) + @champ.piece_justificative_file.attachments.find do + _1.id == params[:replace_attachment_id].to_i + end&.destroy + end + + @champ.piece_justificative_file.attach(params[:blob_signed_id]) + + save_succeed = @champ.save + end + @champ.dossier.update(last_champ_updated_at: Time.zone.now.utc) if save_succeed + save_succeed end diff --git a/app/helpers/champ_helper.rb b/app/helpers/champ_helper.rb index 4368730c7..2e7971a85 100644 --- a/app/helpers/champ_helper.rb +++ b/app/helpers/champ_helper.rb @@ -7,11 +7,11 @@ module ChampHelper simple_format(auto_linked_text, {}, sanitize: false) end - def auto_attach_url(object) + def auto_attach_url(object, params = {}) if object.is_a?(Champ) - champs_attach_piece_justificative_url(object.id) + champs_attach_piece_justificative_url(object.id, params) elsif object.is_a?(TypeDeChamp) - piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id: object.procedure.id) + piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id: object.procedure.id, **params) end end end diff --git a/app/javascript/controllers/replace_attachment_controller.ts b/app/javascript/controllers/replace_attachment_controller.ts new file mode 100644 index 000000000..07dda54f4 --- /dev/null +++ b/app/javascript/controllers/replace_attachment_controller.ts @@ -0,0 +1,30 @@ +import { ApplicationController } from './application_controller'; +import { show } from '@utils'; + +export class ReplaceAttachmentController extends ApplicationController { + static targets = ['input']; + + declare readonly inputTarget: HTMLInputElement; + + open(event: Event) { + show(this.inputTarget); + this.inputTarget.click(); // opens input prompt + + const target = event.currentTarget as HTMLButtonElement; + + if (target.dataset.autoAttachUrl) { + // set the auto attach url specific to this button to replace the related attachment + this.inputTarget.dataset.originalAutoAttachUrl = + this.inputTarget.dataset.autoAttachUrl; + + this.inputTarget.dataset.autoAttachUrl = target.dataset.autoAttachUrl; + + // reset autoAttachUrl which would add an attachment + // when replace is not finalized + this.inputTarget.addEventListener('cancel', () => { + this.inputTarget.dataset.autoAttachUrl = + this.inputTarget.dataset.originalAutoAttachUrl; + }); + } + } +} diff --git a/spec/components/attachment/multiple_component_spec.rb b/spec/components/attachment/multiple_component_spec.rb index b34ab17cb..2ac7396be 100644 --- a/spec/components/attachment/multiple_component_spec.rb +++ b/spec/components/attachment/multiple_component_spec.rb @@ -38,13 +38,7 @@ RSpec.describe Attachment::MultipleComponent, type: :component do context 'when there is an attachment' do before do - attached_file.attach( - io: StringIO.new("x" * 2), - filename: "me.jpg", - content_type: "image/jpeg", - metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } - ) - champ.save! + attach_to_champ(attached_file, champ) end it 'renders the filenames' do @@ -98,4 +92,26 @@ RSpec.describe Attachment::MultipleComponent, type: :component do expect(subject).to have_selector('[data-controller=turbo-poll]') end end + + context 'when user can replace' do + let(:kwargs) { { user_can_replace: true } } + + before do + attach_to_champ(attached_file, champ) + end + + it 'setup controller' do + expect(subject).to have_selector('[data-controller=replace-attachment]').once + end + end + + def attach_to_champ(attached_file, champ) + attached_file.attach( + io: StringIO.new("x" * 2), + filename: "me.jpg", + content_type: "image/jpeg", + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + ) + champ.save! + end end diff --git a/spec/controllers/champs/piece_justificative_controller_spec.rb b/spec/controllers/champs/piece_justificative_controller_spec.rb index 6deb1e060..ac09d58d6 100644 --- a/spec/controllers/champs/piece_justificative_controller_spec.rb +++ b/spec/controllers/champs/piece_justificative_controller_spec.rb @@ -5,6 +5,8 @@ describe Champs::PieceJustificativeController, type: :controller do let(:champ) { dossier.champs_public.first } describe '#update' do + let(:replace_attachment_id) { nil } + render_views before { sign_in user } @@ -12,8 +14,9 @@ describe Champs::PieceJustificativeController, type: :controller do put :update, params: { position: '1', champ_id: champ.id, - blob_signed_id: file - }, format: :turbo_stream + blob_signed_id: file, + replace_attachment_id: + }.compact, format: :turbo_stream end context 'when the file is valid' do @@ -65,6 +68,33 @@ describe Champs::PieceJustificativeController, type: :controller do expect(JSON.parse(response.body)).to eq({ 'errors' => ['La pièce justificative n’est pas d’un type accepté'] }) end end + + context 'replace an attachment' do + let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } + before { subject } + + context "attachment associated to dossier" do + let(:champ) { create(:champ, :with_piece_justificative_file, dossier:) } + let(:replace_attachment_id) { champ.piece_justificative_file.first.id } + + it "replaces an existing attachment" do + champ.reload + + expect(champ.piece_justificative_file.attachments.count).to eq(1) + expect(champ.piece_justificative_file.attachments.first.filename).to eq("piece_justificative_0.pdf") + end + end + + context "attachment not associated to dossier" do + let(:other_champ) { create(:champ, :with_piece_justificative_file) } + let(:replace_attachment_id) { other_champ.piece_justificative_file.first.id } + + it "add attachment, don't replace attachment" do + expect(champ.reload.piece_justificative_file.attachments.count).to eq(1) + expect(other_champ.reload.piece_justificative_file.attachments.count).to eq(1) + end + end + end end describe '#template' do diff --git a/spec/system/users/en_construction_spec.rb b/spec/system/users/en_construction_spec.rb new file mode 100644 index 000000000..51d3ded7c --- /dev/null +++ b/spec/system/users/en_construction_spec.rb @@ -0,0 +1,77 @@ +describe "Dossier en_construction" do + let(:user) { create(:user) } + let(:procedure) { create(:simple_procedure, :with_piece_justificative, :with_titre_identite) } + let(:dossier) { create(:dossier, :en_construction, :with_individual, :with_populated_champs, user:, procedure:) } + + let(:tdc) { + procedure.active_revision.types_de_champ_public.find { _1.type_champ == "piece_justificative" } + } + + let(:champ) { + dossier.champs_public.find { _1.type_de_champ_id == tdc.id } + } + + scenario 'delete a non mandatory piece justificative', js: true do + visit_dossier(dossier) + + expect(page).not_to have_button("Remplacer") + click_on "Supprimer le fichier toto.txt" + + expect(page).not_to have_text("toto.txt") + end + + context "with a mandatory piece justificative" do + before do + tdc.update_attribute(:mandatory, true) + end + + scenario 'remplace a mandatory piece justificative', js: true do + visit_dossier(dossier) + + click_on "Remplacer le fichier toto.txt" + + input_selector = "#attachment-multiple-empty-#{champ.id}" + expect(page).to have_selector(input_selector) + find(input_selector).attach_file(Rails.root.join('spec/fixtures/files/file.pdf')) + + expect(page).to have_text("file.pdf") + expect(page).not_to have_text("toto.txt") + end + end + + context "with a mandatory titre identite" do + let(:tdc) { + procedure.active_revision.types_de_champ_public.find { _1.type_champ == "titre_identite" } + } + + before do + tdc.update_attribute(:mandatory, true) + end + + scenario 'remplace a mandatory titre identite', js: true do + visit_dossier(dossier) + + click_on "Remplacer le fichier toto.png" + + input_selector = ".attachment-input-#{champ.piece_justificative_file.attachments.first.id}" + expect(page).to have_selector(input_selector) + find(input_selector).attach_file(Rails.root.join('spec/fixtures/files/file.pdf')) + + expect(page).to have_text("file.pdf") + expect(page).not_to have_text("toto.png") + end + end + + private + + def visit_dossier(dossier) + visit modifier_dossier_path(dossier) + + expect(page).to have_current_path(new_user_session_path) + fill_in 'user_email', with: user.email + fill_in 'user_password', with: user.password + click_on 'Se connecter' + + expect(page).to have_current_path(modifier_dossier_path(dossier)) + end +end