feat(piece_justificative): supports multiple files

Closes #7924
This commit is contained in:
Colin Darie 2022-10-25 14:14:24 +02:00
parent ab1928dc33
commit b8296c6d4d
17 changed files with 242 additions and 78 deletions

View file

@ -1,16 +1,27 @@
# Display a widget for uploading, editing and deleting a file attachment # Display a widget for uploading, editing and deleting a file attachment
class Attachment::EditComponent < ApplicationComponent class Attachment::EditComponent < ApplicationComponent
def initialize(form:, attached_file:, template: nil, user_can_destroy: false, direct_upload: true, id: nil) attr_reader :template, :form, :attachment
delegate :persisted?, to: :attachment, allow_nil: true
def initialize(form:, attached_file:, attachment: nil, user_can_destroy: false, direct_upload: true, id: nil, index: 0)
@form = form @form = form
@attached_file = attached_file @attached_file = attached_file
@template = template
@attachment = if attachment
attachment
elsif attached_file.respond_to?(:attachment)
attached_file.attachment
else
# multiple attachments: attachment kwarg is expected, unless adding a new attachment
end
@user_can_destroy = user_can_destroy @user_can_destroy = user_can_destroy
@direct_upload = direct_upload @direct_upload = direct_upload
@id = id @id = id
@index = index
end end
attr_reader :template, :form
def max_file_size def max_file_size
file_size_validator.options[:less_than] file_size_validator.options[:less_than]
end end
@ -19,26 +30,18 @@ class Attachment::EditComponent < ApplicationComponent
@user_can_destroy @user_can_destroy
end end
def attachment
@attached_file.attachment
end
def attachment_path def attachment_path
helpers.attachment_path attachment.id, { signed_id: attachment.blob.signed_id } helpers.attachment_path attachment.id, { signed_id: attachment.blob.signed_id }
end end
def attachment_id def attachment_id
@attachment_id ||= attachment ? attachment.id : SecureRandom.uuid @attachment_id ||= (attachment&.id || SecureRandom.uuid)
end end
def attachment_input_class def attachment_input_class
"attachment-input-#{attachment_id}" "attachment-input-#{attachment_id}"
end end
def persisted?
attachment&.persisted?
end
def champ def champ
@form.object.is_a?(Champ) ? @form.object : nil @form.object.is_a?(Champ) ? @form.object : nil
end end
@ -51,13 +54,25 @@ class Attachment::EditComponent < ApplicationComponent
id: input_id(@id), id: input_id(@id),
aria: { describedby: champ&.describedby_id }, aria: { describedby: champ&.describedby_id },
data: { data: {
auto_attach_url: helpers.auto_attach_url(form.object) auto_attach_url:
}.merge(has_file_size_validator? ? { max_file_size: max_file_size } : {}) }.merge(has_file_size_validator? ? { max_file_size: } : {})
}.merge(has_content_type_validator? ? { accept: accept_content_type } : {}) }.merge(has_content_type_validator? ? { accept: accept_content_type } : {})
end end
def auto_attach_url
helpers.auto_attach_url(form.object, replace_attachment_id: persisted? ? attachment_id : nil)
end
def input_id(given_id) def input_id(given_id)
[given_id, champ&.input_id, file_field_name].reject(&:blank?).compact.first return given_id if given_id.present?
if champ.present?
# Single or first attachment input must match label "for" attribute. Others must remain unique.
return champ.input_id if @index.zero?
return "#{champ.input_id}_#{attachment_id}"
end
file_field_name
end end
def file_field_name def file_field_name

View file

@ -1,15 +1,4 @@
.attachment .fr-mb-2w
- if template&.attached?
%p.mb-1
Veuillez télécharger, remplir et joindre
= link_to(url_for(template), download: "", class: "fr-link fr-link--icon-right fr-icon-download-line") do
le modèle suivant
- if helpers.administrateur_signed_in?
%span.ml-2.fr-text--xs.fr-text-mention--grey.visible-on-previous-hover
%span.fr-text-action-high--blue-france.fr-icon-questionnaire-line{ "aria-hidden": "true" }
= t('shared.ephemeral_link')
- if persisted? - if persisted?
.attachment-actions{ id: dom_id(attachment, :actions) } .attachment-actions{ id: dom_id(attachment, :actions) }
.attachment-action .attachment-action
@ -30,8 +19,9 @@
%span.icon.retry %span.icon.retry
Ré-essayer Ré-essayer
- if !persisted?
%label.text-sm.font-weight-normal{ for: file_field_options[:id] } %label.text-sm.font-weight-normal{ for: file_field_options[:id] }
= t('.max_file_size', max_file_size: number_to_human_size(max_file_size)) = t('.max_file_size', max_file_size: number_to_human_size(max_file_size))
= form.file_field(file_field_name, **file_field_options) %p= form.file_field(file_field_name, **file_field_options)

View file

@ -0,0 +1,43 @@
# Display a widget for uploading, editing and deleting a file attachment
class Attachment::MultipleComponent < ApplicationComponent
renders_one :template
attr_reader :form
attr_reader :attached_file
attr_reader :direct_upload
attr_reader :id
attr_reader :user_can_destroy
delegate :count, :empty?, to: :attachments, prefix: true
def initialize(form:, attached_file:, user_can_destroy: false, direct_upload: true, id: nil)
@form = form
@attached_file = attached_file
@user_can_destroy = user_can_destroy
@direct_upload = direct_upload
@id = id
@attachments = attached_file.attachments || []
end
def each_attachment(&block)
@attachments.each_with_index(&block)
end
def can_attach_next?
return false if @attachments.empty?
return false if !@attachments.last.persisted?
true
end
def stimulus_controller_name
"attachment-multiple"
end
private
def attachments
@attachments
end
end

View file

@ -0,0 +1,13 @@
.fr-mb-4w{ data: { controller: stimulus_controller_name }}
= template
- each_attachment do |attachment, index|
%div{id: dom_id(attachment)}
= render Attachment::EditComponent.new(form:, attached_file:, attachment:, user_can_destroy:, direct_upload:, id:, index:)
%div{class: [attachments_empty? ? nil : "hidden"], data: { "#{stimulus_controller_name}-target": "empty" }}
= render Attachment::EditComponent.new(form:, attached_file:, user_can_destroy:, direct_upload:, id:, index: attachments_count)
- if can_attach_next?
%button.fr-btn.fr-btn--tertiary.fr-btn--sm{ data: { "#{stimulus_controller_name}-target": "buttonAdd", action: "click->attachment-multiple#add" }} Ajouter un fichier

View file

@ -1,2 +1,14 @@
- user_can_destroy = !@champ.mandatory? || @champ.dossier.brouillon? - user_can_destroy = !@champ.mandatory? || @champ.dossier.brouillon?
= render Attachment::EditComponent.new(form: @form, attached_file: @champ.piece_justificative_file, template: @champ.type_de_champ.piece_justificative_template, user_can_destroy: user_can_destroy) = render Attachment::MultipleComponent.new(form: @form, attached_file: @champ.piece_justificative_file, user_can_destroy: user_can_destroy) do |c|
- if @champ.type_de_champ.piece_justificative_template&.attached?
- c.with_template do
%p
Veuillez télécharger, remplir et joindre
= link_to(url_for(@champ.type_de_champ.piece_justificative_template), download: "", class: "fr-link fr-link--icon-right fr-icon-download-line") do
le modèle suivant
- if helpers.administrateur_signed_in?
%span.fr-ml-2w.fr-text--xs.fr-text-mention--grey.visible-on-previous-hover
%span.fr-text-action-high--blue-france.fr-icon-questionnaire-line{ "aria-hidden": "true" }
= t('shared.ephemeral_link')

View file

@ -13,6 +13,9 @@ class Champs::PieceJustificativeController < ApplicationController
def attach_piece_justificative def attach_piece_justificative
@champ = policy_scope(Champ).find(params[:champ_id]) @champ = policy_scope(Champ).find(params[:champ_id])
purge_replaced_attachment
@champ.piece_justificative_file.attach(params[:blob_signed_id]) @champ.piece_justificative_file.attach(params[:blob_signed_id])
save_succeed = @champ.save save_succeed = @champ.save
@champ.dossier.update(last_champ_updated_at: Time.zone.now.utc) if save_succeed @champ.dossier.update(last_champ_updated_at: Time.zone.now.utc) if save_succeed
@ -24,4 +27,11 @@ class Champs::PieceJustificativeController < ApplicationController
rescue ActiveRecord::StaleObjectError rescue ActiveRecord::StaleObjectError
attach_piece_justificative attach_piece_justificative
end end
def purge_replaced_attachment
return if params[:replace_attachment_id].blank?
attachment = @champ.piece_justificative_file.attachments.find { _1.id == params[:replace_attachment_id].to_i }
attachment&.purge_later
end
end end

View file

@ -7,9 +7,9 @@ module ChampHelper
simple_format(auto_linked_text, {}, sanitize: false) simple_format(auto_linked_text, {}, sanitize: false)
end end
def auto_attach_url(object) def auto_attach_url(object, url_options = {})
if object.is_a?(Champ) if object.is_a?(Champ)
champs_piece_justificative_url(object.id) champs_piece_justificative_url(object.id, url_options)
elsif object.is_a?(TypeDeChamp) 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)
end end

View file

@ -0,0 +1,50 @@
import {} from '@hotwired/stimulus';
import { show, hide } from '~/shared/utils';
import { ApplicationController } from './application_controller';
type AttachementDestroyedEvent = CustomEvent<{ target_id: string }>;
export class AttachmentMultipleController extends ApplicationController {
static targets = ['buttonAdd', 'empty'];
declare readonly emptyTarget: HTMLDivElement;
declare readonly buttonAddTarget: HTMLButtonElement;
connect() {
this.onGlobal('attachment:destroyed', (event: AttachementDestroyedEvent) =>
this.onAttachmentDestroy(event)
);
}
add(event: Event) {
event.preventDefault();
hide(this.buttonAddTarget);
show(this.emptyTarget);
const inputFile = this.emptyTarget.querySelector(
'input[type=file]'
) as HTMLInputElement;
inputFile.click();
}
onAttachmentDestroy(event: AttachementDestroyedEvent) {
const { detail } = event;
const attachmentWrapper = document.getElementById(detail.target_id);
// Remove this attachment row when there is at least another attachment.
if (attachmentWrapper && this.attachmentsCount() > 1) {
attachmentWrapper.parentNode?.removeChild(attachmentWrapper);
} else {
hide(this.buttonAddTarget);
}
}
attachmentsCount() {
// Don't count the hidden "empty" attachment
return this.element.querySelectorAll('.attachment-input').length - 1;
}
}

View file

@ -43,12 +43,16 @@ class Champs::PieceJustificativeChamp < Champ
end end
def for_export def for_export
piece_justificative_file.filename.to_s if piece_justificative_file.attached? piece_justificative_file.map { _1.filename.to_s }
end end
def for_api def for_api
if piece_justificative_file.attached? && (piece_justificative_file.virus_scanner.safe? || piece_justificative_file.virus_scanner.pending?) return nil unless piece_justificative_file.attached?
piece_justificative_file.service_url
piece_justificative_file.filter_map do |attachment|
if attachment.virus_scanner.safe? || attachment.virus_scanner.pending?
attachment.service_url
end
end end
end end
end end

View file

@ -1,2 +1,3 @@
= turbo_stream.remove dom_id(@attachment, :actions) = turbo_stream.remove dom_id(@attachment, :actions)
= turbo_stream.dispatch "attachment:destroyed", { target_id: dom_id(@attachment) }
= turbo_stream.show_all ".attachment-input-#{@attachment.id}" = turbo_stream.show_all ".attachment-input-#{@attachment.id}"

View file

@ -2,6 +2,6 @@
= turbo_stream.morph @champ.input_group_id do = turbo_stream.morph @champ.input_group_id do
= render EditableChamp::EditableChampComponent.new champ: @champ, form: form = render EditableChamp::EditableChampComponent.new champ: @champ, form: form
- if @champ.piece_justificative_file.attached?
- attachment = @champ.piece_justificative_file.attachment - @champ.piece_justificative_file.attachments.each do |attachment|
= turbo_stream.focus_all "button[data-toggle-target=\".attachment-input-#{attachment.id}\"]" = turbo_stream.focus_all "button[data-toggle-target=\".attachment-input-#{attachment.id}\"]"

View file

@ -0,0 +1,32 @@
describe EditableChamp::PieceJustificativeComponent, type: :component do
let(:champ) { build(:champ_piece_justificative, dossier: create(:dossier)) }
let(:component) {
described_class.new(form: instance_double(ActionView::Helpers::FormBuilder, object: champ.dossier, file_field: "<input type=\"file\" />"), champ:)
}
let(:subject) {
render_inline(component).to_html
}
context 'when there is a template' do
let(:template) { champ.type_de_champ.piece_justificative_template }
let(:profil) { :user }
before do
allow_any_instance_of(ApplicationController).to receive(:administrateur_signed_in?).and_return(profil == :administrateur)
end
it 'renders a link to template' do
expect(subject).to have_link('le modèle suivant')
expect(subject).not_to have_text("éphémère")
end
context 'as an administrator' do
let(:profil) { :administrateur }
it 'warn about ephemeral template url' do
expect(subject).to have_link('le modèle suivant')
expect(subject).to have_text("éphémère")
end
end
end
end

View file

@ -162,8 +162,13 @@ FactoryBot.define do
factory :champ_titre_identite, class: 'Champs::TitreIdentiteChamp' do factory :champ_titre_identite, class: 'Champs::TitreIdentiteChamp' do
type_de_champ { association :type_de_champ_titre_identite, procedure: dossier.procedure } type_de_champ { association :type_de_champ_titre_identite, procedure: dossier.procedure }
transient do
skip_default_attachment { false }
end
after(:build) do |champ, evaluator|
next if evaluator.skip_default_attachment
after(:build) do |champ, _evaluator|
champ.piece_justificative_file.attach( champ.piece_justificative_file.attach(
io: StringIO.new("toto"), io: StringIO.new("toto"),
filename: "toto.png", filename: "toto.png",

View file

@ -452,13 +452,13 @@ describe Champ do
end end
it 'marks the file as pending virus scan' do it 'marks the file as pending virus scan' do
expect(subject.piece_justificative_file.virus_scanner.started?).to be_truthy expect(subject.piece_justificative_file.first.virus_scanner.started?).to be_truthy
end end
it 'marks the file as safe once the scan completes' do it 'marks the file as safe once the scan completes' do
subject subject
perform_enqueued_jobs perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy expect(champ.reload.piece_justificative_file.first.virus_scanner.safe?).to be_truthy
end end
end end
end end
@ -467,7 +467,7 @@ describe Champ do
describe '#enqueue_watermark_job' do describe '#enqueue_watermark_job' do
context 'when type_champ is type_de_champ_titre_identite' do context 'when type_champ is type_de_champ_titre_identite' do
let(:type_de_champ) { create(:type_de_champ_titre_identite) } let(:type_de_champ) { create(:type_de_champ_titre_identite) }
let(:champ) { build(:champ_titre_identite, type_de_champ: type_de_champ) } let(:champ) { build(:champ_titre_identite, type_de_champ: type_de_champ, skip_default_attachment: true) }
before do before do
allow(ClamavService).to receive(:safe_file?).and_return(true) allow(ClamavService).to receive(:safe_file?).and_return(true)
@ -480,14 +480,14 @@ describe Champ do
end end
it 'marks the file as needing watermarking' do it 'marks the file as needing watermarking' do
expect(subject.piece_justificative_file.watermark_pending?).to be_truthy expect(subject.piece_justificative_file.first.watermark_pending?).to be_truthy
end end
it 'watermarks the file' do it 'watermarks the file' do
subject subject
perform_enqueued_jobs perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.watermark_pending?).to be_falsy expect(champ.reload.piece_justificative_file.first.watermark_pending?).to be_falsy
expect(champ.reload.piece_justificative_file.blob.watermark_done?).to be_truthy expect(champ.reload.piece_justificative_file.first.blob.watermark_done?).to be_truthy
end end
end end
end end

View file

@ -43,35 +43,35 @@ describe Champs::PieceJustificativeChamp do
let(:champ_pj) { create(:champ_piece_justificative) } let(:champ_pj) { create(:champ_piece_justificative) }
subject { champ_pj.for_export } subject { champ_pj.for_export }
it { is_expected.to eq('toto.txt') } it { is_expected.to match_array(['toto.txt']) }
context 'without attached file' do context 'without attached file' do
before { champ_pj.piece_justificative_file.purge } before { champ_pj.piece_justificative_file.purge }
it { is_expected.to eq(nil) } it { is_expected.to eq([]) }
end end
end end
describe '#for_api' do describe '#for_api' do
let(:champ_pj) { create(:champ_piece_justificative) } let(:champ_pj) { create(:champ_piece_justificative) }
let(:metadata) { champ_pj.piece_justificative_file.blob.metadata } let(:metadata) { champ_pj.piece_justificative_file.first.blob.metadata }
before { champ_pj.piece_justificative_file.blob.update(metadata: metadata.merge(virus_scan_result: status)) } before { champ_pj.piece_justificative_file.first.blob.update(metadata: metadata.merge(virus_scan_result: status)) }
subject { champ_pj.for_api } subject { champ_pj.for_api }
context 'when file is safe' do context 'when file is safe' do
let(:status) { ActiveStorage::VirusScanner::SAFE } let(:status) { ActiveStorage::VirusScanner::SAFE }
it { is_expected.to include("/rails/active_storage/disk/") } it { is_expected.to match_array([include("/rails/active_storage/disk/")]) }
end end
context 'when file is not scanned' do context 'when file is not scanned' do
let(:status) { ActiveStorage::VirusScanner::PENDING } let(:status) { ActiveStorage::VirusScanner::PENDING }
it { is_expected.to include("/rails/active_storage/disk/") } it { is_expected.to match_array([include("/rails/active_storage/disk/")]) }
end end
context 'when file is infected' do context 'when file is infected' do
let(:status) { ActiveStorage::VirusScanner::INFECTED } let(:status) { ActiveStorage::VirusScanner::INFECTED }
it { is_expected.to be_nil } it { is_expected.to eq([]) }
end end
end end
end end

View file

@ -20,7 +20,18 @@ describe PiecesJustificativesService do
attach_file_to_champ(pj_champ.call(witness)) attach_file_to_champ(pj_champ.call(witness))
end end
it { expect(subject).to match_array([pj_champ.call(dossier).piece_justificative_file.attachment]) } context 'with a single attachment' do
it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) }
end
context 'with a multiple attachments' do
before do
attach_file_to_champ(pj_champ.call(dossier))
end
it { expect(subject.count).to eq(2) }
it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) }
end
end end
context 'with a pj not safe on a champ' do context 'with a pj not safe on a champ' do
@ -46,7 +57,7 @@ describe PiecesJustificativesService do
attach_file_to_champ(private_pj_champ.call(witness)) attach_file_to_champ(private_pj_champ.call(witness))
end end
it { expect(subject).to match_array([private_pj_champ.call(dossier).piece_justificative_file.attachment]) } it { expect(subject).to match_array(private_pj_champ.call(dossier).piece_justificative_file.attachments) }
context 'for expert' do context 'for expert' do
let(:for_expert) { true } let(:for_expert) { true }

View file

@ -6,7 +6,7 @@ describe 'shared/attachment/_update.html.haml', type: :view do
subject do subject do
form_for(champ.dossier) do |form| form_for(champ.dossier) do |form|
view.render Attachment::EditComponent.new(form: form, attached_file: attached_file, user_can_destroy: true, direct_upload: true, template:) view.render Attachment::EditComponent.new(form: form, attached_file: attached_file, user_can_destroy: true, direct_upload: true)
end end
end end
@ -65,26 +65,4 @@ describe 'shared/attachment/_update.html.haml', type: :view do
is_expected.not_to have_link('Supprimer') is_expected.not_to have_link('Supprimer')
end end
end end
context 'when champ has a template' do
let(:profil) { :user }
let(:template) { champ.type_de_champ.piece_justificative_template }
before do
allow_any_instance_of(ActionView::Base).to receive(:administrateur_signed_in?).and_return(profil == :administrateur)
end
it 'renders a link to template' do
expect(subject).to have_link('le modèle suivant')
expect(subject).not_to have_text("éphémère")
end
context 'as an administrator' do
let(:profil) { :administrateur }
it 'warn about ephemeral template url' do
expect(subject).to have_link('le modèle suivant')
expect(subject).to have_text("éphémère")
end
end
end
end end