Merge pull request #4918 from betagouv/auto-upload

Usager : envoi automatique des pièces jointes au dossier (désactivé pour l'instant)
This commit is contained in:
Pierre de La Morinerie 2020-03-31 13:30:18 +02:00 committed by GitHub
commit 8dcbe5f47c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 485 additions and 10 deletions

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><switch transform="translate(0 -101)"><g fill="#0069cc"><path d="M21.406 118.453a1.422 1.422 0 0 0-.56-1.888c-.69-.372-1.298.088-1.67.778-.836 1.592-2.266 2.919-4.049 3.76-3.765 1.769-8.693.51-11.063-2.9-2.87-4.143-1.409-10.218 2.871-12.7 3.98-2.302 9.506-.862 11.925 3.05l-1.96 1.162c-.508.302-.502 1.049.036 1.323l5.798 2.966a.755.755 0 0 0 1.11-.659l.156-6.473c.016-.604-.637-.967-1.146-.664l-1.78 1.057c-3.384-5.589-10.81-7.243-16.272-3.434-4.541 3.2-6.107 9.354-3.633 14.358 2.977 5.956 10.364 8.071 16.004 4.72a11.39 11.39 0 0 0 4.233-4.456z"/></g></switch></svg>

After

Width:  |  Height:  |  Size: 632 B

View file

@ -10,6 +10,7 @@ $dark-red: #A10005;
$medium-red: rgba(161, 0, 5, 0.9);
$light-red: #ED1C24;
$lighter-red: #F52A2A;
$background-red: #FFDFDF;
$green: #15AD70;
$lighter-green: lighten($green, 30%);
$light-green: lighten($green, 25%);

View file

@ -1,3 +1,4 @@
@import "colors";
@import "constants";
.attachment-actions {
@ -13,6 +14,38 @@
}
}
.attachment-error {
display: flex;
width: max-content;
max-width: 100%;
align-items: center;
margin-bottom: $default-padding;
padding: $default-padding;
background: $background-red;
&.hidden {
display: none;
}
}
.attachment-error-message {
display: inline-block;
margin-right: $default-padding;
color: $medium-red;
}
.attachment-error-title {
font-weight: bold;
}
.attachment-error-retry {
white-space: nowrap;
&.hidden {
display: none;
}
}
.attachment-input.hidden {
display: none;
}

View file

@ -33,6 +33,7 @@
border-color: red;
}
input[type=file][data-direct-upload-url][disabled] {
input[type=file][data-direct-upload-url][disabled],
input[type=file][data-auto-attach-url][disabled] {
display: none;
}

View file

@ -67,6 +67,10 @@
background-image: image-url("icons/preview.svg");
}
&.retry {
background-image: image-url("icons/retry.svg");
}
&.download {
background-image: image-url("icons/download.svg");
}

View file

@ -0,0 +1,21 @@
class Champs::PieceJustificativeController < ApplicationController
before_action :authenticate_logged_user!
def update
@champ = policy_scope(Champ).find(params[:champ_id])
@champ.piece_justificative_file.attach(params[:blob_signed_id])
if @champ.save
render :show
else
errors = @champ.errors.full_messages
# Before Rails 6, the attachment was persisted to database
# by 'attach', even before calling save.
# So until we're on Rails 6, we need to purge the file explicitely.
@champ.piece_justificative_file.purge_later
render :json => { errors: errors }, :status => 422
end
end
end

View file

@ -31,4 +31,10 @@ module ChampHelper
"desc-#{champ.type_de_champ.id}-#{champ.row}"
end
end
def auto_attach_url(form, object)
if feature_enabled?(:autoupload_dossier_attachments) && object.is_a?(Champ) && object.public?
champs_piece_justificative_url(form.index)
end
end
end

View file

@ -0,0 +1,141 @@
import Uploader from '../../shared/activestorage/uploader';
import ProgressBar from '../../shared/activestorage/progress-bar';
import { ajax, show, hide, toggle } from '@utils';
// Given a file input in a champ with a selected file, upload a file,
// then attach it to the dossier.
//
// On success, the champ is replaced by an HTML fragment describing the attachment.
// On error, a error message is displayed above the input.
export default class AutoUploadController {
constructor(input, file) {
this.input = input;
this.file = file;
}
async start() {
try {
this._begin();
// Sanity checks
const autoAttachUrl = this.input.dataset.autoAttachUrl;
if (!autoAttachUrl) {
throw new Error('Lattribut "data-auto-attach-url" est manquant');
}
const champ = this.input.closest('.editable-champ[data-champ-id]');
if (!champ) {
throw new Error('Impossible de trouver lélément ".editable-champ"');
}
const champId = champ.dataset.champId;
// Upload the file (using Direct Upload)
let blobSignedId = await this._upload();
// Attach the blob to the champ
// (The request responds with Javascript, which displays the attachment HTML fragment).
await this._attach(champId, blobSignedId, autoAttachUrl);
// Everything good: clear the original file input value
this.input.value = null;
} catch (error) {
this._failed(error);
throw error;
} finally {
this._done();
}
}
_begin() {
this.input.disabled = true;
this._hideErrorMessage();
}
async _upload() {
const uploader = new Uploader(
this.input,
this.file,
this.input.dataset.directUploadUrl
);
return await uploader.start();
}
async _attach(champId, blobSignedId, autoAttachUrl) {
// Now that the upload is done, display a new progress bar
// to show that the attachment request is still pending.
const progressBar = new ProgressBar(this.input, champId, this.file);
progressBar.progress(100);
progressBar.end();
const attachmentRequest = {
url: autoAttachUrl,
type: 'PUT',
data: `champ_id=${champId}&blob_signed_id=${blobSignedId}`
};
await ajax(attachmentRequest);
// The progress bar has been destroyed by the attachment HTML fragment that replaced the input,
// so no further cleanup is needed.
}
_failed(error) {
if (!document.body.contains(this.input)) {
return;
}
let progressBar = this.input.parentElement.querySelector('.direct-upload');
if (progressBar) {
progressBar.remove();
}
this._displayErrorMessage(error);
}
_done() {
this.input.disabled = false;
}
_messageFromError(error) {
if (
error.xhr &&
error.xhr.status == 422 &&
error.response &&
error.response.errors &&
error.response.errors[0]
) {
return {
title: error.response.errors[0],
description: '',
retry: false
};
} else {
return {
title: 'Une erreur sest produite pendant lenvoi du fichier.',
description: error.message || error.toString(),
retry: true
};
}
}
_displayErrorMessage(error) {
let errorNode = this.input.parentElement.querySelector('.attachment-error');
if (errorNode) {
show(errorNode);
let message = this._messageFromError(error);
errorNode.querySelector('.attachment-error-title').textContent =
message.title || '';
errorNode.querySelector('.attachment-error-description').textContent =
message.description || '';
toggle(errorNode.querySelector('.attachment-error-retry'), message.retry);
}
}
_hideErrorMessage() {
let errorElement = this.input.parentElement.querySelector(
'.attachment-error'
);
if (errorElement) {
hide(errorElement);
}
}
}

View file

@ -0,0 +1,23 @@
import AutoUploadsControllers from './auto-uploads-controllers.js';
import { delegate } from '@utils';
// Create a controller responsible for managing several concurrent uploads.
const autoUploadsControllers = new AutoUploadsControllers();
function startUpload(input) {
Array.from(input.files).forEach(file => {
autoUploadsControllers.upload(input, file);
});
}
const fileInputSelector = `input[type=file][data-direct-upload-url][data-auto-attach-url]:not([disabled])`;
delegate('change', fileInputSelector, event => {
startUpload(event.target);
});
const retryButtonSelector = `button.attachment-error-retry`;
delegate('click', retryButtonSelector, event => {
const inputSelector = event.target.dataset.inputTarget;
const input = document.querySelector(inputSelector);
startUpload(input);
});

View file

@ -0,0 +1,46 @@
import Rails from '@rails/ujs';
import AutoUploadController from './auto-upload-controller.js';
// Manage multiple concurrent uploads.
//
// When the first upload starts, all the form "Submit" buttons are disabled.
// They are enabled again when the last upload ends.
export default class AutoUploadsControllers {
constructor() {
this.inFlightUploadsCount = 0;
}
async upload(input, file) {
let form = input.form;
this._incrementInFlightUploads(form);
try {
let controller = new AutoUploadController(input, file);
await controller.start();
} finally {
this._decrementInFlightUploads(form);
}
}
_incrementInFlightUploads(form) {
this.inFlightUploadsCount += 1;
if (form) {
form
.querySelectorAll('button[type=submit]')
.forEach(submitButton => Rails.disableElement(submitButton));
}
}
_decrementInFlightUploads(form) {
if (this.inFlightUploadsCount > 0) {
this.inFlightUploadsCount -= 1;
}
if (this.inFlightUploadsCount == 0 && form) {
form
.querySelectorAll('button[type=submit]')
.forEach(submitButton => Rails.enableElement(submitButton));
}
}
}

View file

@ -23,6 +23,7 @@ import '../new_design/select2';
import '../new_design/spinner';
import '../new_design/support';
import '../new_design/dossiers/auto-save';
import '../new_design/dossiers/auto-upload';
import '../new_design/champs/carte';
import '../new_design/champs/linked-drop-down-list';

View file

@ -41,7 +41,7 @@ export default class ProgressBar {
}
static render(id, filename) {
return `<div id="direct-upload-${id}" class="direct-upload ${PENDING_CLASS}">
return `<div id="direct-upload-${id}" class="direct-upload ${PENDING_CLASS}" data-direct-upload-id="${id}">
<div class="direct-upload__progress" style="width: 0%"></div>
<span class="direct-upload__filename">${filename}</span>
</div>`;

View file

@ -27,6 +27,8 @@ addUploadEventListener(INITIALIZE_EVENT, ({ target, detail: { id, file } }) => {
addUploadEventListener(START_EVENT, ({ target, detail: { id } }) => {
ProgressBar.start(id);
// At the end of the upload, the form will be submitted again.
// Avoid the confirm dialog to be presented again then.
const button = target.form.querySelector('button.primary');
if (button) {
button.removeAttribute('data-confirm');

View file

@ -3,7 +3,7 @@ import ProgressBar from './progress-bar';
/**
Uploader class is a delegate for DirectUpload instance
used to track lifecycle and progress of un upload.
used to track lifecycle and progress of an upload.
*/
export default class Uploader {
constructor(input, file, directUploadUrl) {

View file

@ -29,6 +29,13 @@ delegate('click', '[data-attachment-refresh]', event => {
attachementPoller.check();
});
// Periodically check the state of a set of URLs.
//
// Each time the given URL is requested, the matching `show.js.erb` view is rendered,
// causing the state to be refreshed.
//
// This is used mainly to refresh attachments during the anti-virus check,
// but also to refresh the state of a pending spreadsheet export.
class RemotePoller {
urls = new Set();
timeout;

View file

@ -13,8 +13,14 @@ export function hide(el) {
el && el.classList.add('hidden');
}
export function toggle(el) {
el && el.classList.toggle('hidden');
export function toggle(el, force) {
if (force == undefined) {
el & el.classList.toggle('hidden');
} else if (force) {
el && el.classList.remove('hidden');
} else {
el && el.classList.add('hidden');
}
}
export function enable(el) {

View file

@ -0,0 +1,17 @@
<% dossier = @champ.dossier %>
<%= fields_for dossier do |form| %>
<%= form.fields_for :champs, dossier.champs.where(id: @champ.id), include_id: false do |champ_form| %>
<% render_to_element(".editable-champ[data-champ-id=\"#{@champ.id}\"]",
partial: 'shared/dossiers/editable_champs/editable_champ',
locals: {
champ: @champ,
form: champ_form
}) %>
<% end %>
<% end %>
<% attachment = @champ.piece_justificative_file.attachment %>
<% if attachment.virus_scanner.pending? %>
<%= fire_event('attachment:update', { url: attachment_url(attachment.id, { signed_id: attachment.blob.signed_id, user_can_upload: true }) }.to_json ) %>
<% end %>

View file

@ -32,6 +32,7 @@
%span.icon.phone
%span.icon.clock
%span.icon.preview
%span.icon.retry
%span.icon.download
%span.icon.download-white
%span.icon.move-handle

View file

@ -22,7 +22,17 @@
.attachment-action
= button_tag 'Remplacer', type: 'button', class: 'button small', data: { 'toggle-target': ".attachment-input-#{attachment_id}" }
.attachment-error.hidden
.attachment-error-message
%p.attachment-error-title
Une erreur sest produite pendant lenvoi du fichier.
%p.attachment-error-description
= button_tag type: 'button', class: 'button attachment-error-retry', data: { 'input-target': ".attachment-input-#{attachment_id}" } do
%span.icon.retry
Ré-essayer
= form.file_field attached_file.name,
class: "attachment-input attachment-input-#{attachment_id} #{'hidden' if persisted}",
accept: accept,
direct_upload: true
direct_upload: true,
data: { 'auto-attach-url': auto_attach_url(form, form.object) }

View file

@ -1,4 +1,4 @@
.editable-champ{ class: "editable-champ-#{champ.type_champ}" }
.editable-champ{ class: "editable-champ-#{champ.type_champ}", data: { 'champ-id': champ.id } }
- if champ.repetition?
%h3.header-subsection= champ.libelle
- if champ.description.present?

View file

@ -31,6 +31,7 @@ features = [
:insee_api_v3,
:instructeur_bypass_email_login_token,
:autosave_dossier_draft,
:autoupload_dossier_attachments,
:maintenance_mode,
:mini_profiler,
:operation_log_serialize_subject,

View file

@ -119,6 +119,7 @@ Rails.application.routes.draw do
get ':position/dossier_link', to: 'dossier_link#show', as: :dossier_link
post ':position/carte', to: 'carte#show', as: :carte
post ':position/repetition', to: 'repetition#show', as: :repetition
put ':position/piece_justificative', to: 'piece_justificative#update', as: :piece_justificative
end
get 'attachments/:id', to: 'attachments#show', as: :attachment

View file

@ -0,0 +1,65 @@
describe Champs::PieceJustificativeController, type: :controller do
let(:user) { create(:user) }
let(:procedure) { create(:procedure, :published, :with_piece_justificative) }
let(:dossier) { create(:dossier, user: user, procedure: procedure) }
let(:champ) { dossier.champs.first }
describe '#update' do
render_views
before { sign_in user }
subject do
put :update, params: {
position: '1',
champ_id: champ.id,
blob_signed_id: file
}, format: 'js'
end
context 'when the file is valid' do
let(:file) { Rack::Test::UploadedFile.new('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') }
it 'attach the file' do
subject
champ.reload
expect(champ.piece_justificative_file.attached?).to be true
expect(champ.piece_justificative_file.filename).to eq('piece_justificative_0.pdf')
end
it 'renders the attachment template as Javascript' do
subject
expect(response.status).to eq(200)
expect(response.body).to include("editable-champ[data-champ-id=\"#{champ.id}\"]")
end
end
context 'when the file is invalid' do
let(:file) { Rack::Test::UploadedFile.new('spec/fixtures/files/invalid_file_format.json', 'application/json') }
# TODO: for now there are no validators on the champ piece_justificative_file,
# so we have to mock a failing validation.
# Once the validators will be enabled, remove those mocks, and let the usual
# validation fail naturally.
#
# See https://github.com/betagouv/demarches-simplifiees.fr/issues/4926
before do
champ
expect_any_instance_of(Champs::PieceJustificativeChamp).to receive(:save).and_return(false)
expect_any_instance_of(Champs::PieceJustificativeChamp).to receive(:errors)
.and_return(double(full_messages: ['La pièce justificative nest pas dun type accepté']))
end
it 'doesnt attach the file' do
subject
expect(champ.reload.piece_justificative_file.attached?).to be false
end
it 'renders an error' do
subject
expect(response.status).to eq(422)
expect(response.header['Content-Type']).to include('application/json')
expect(JSON.parse(response.body)).to eq({ 'errors' => ['La pièce justificative nest pas dun type accepté'] })
end
end
end
end

View file

@ -5,9 +5,6 @@ feature 'The user' do
let!(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs_mandatory) }
let(:user_dossier) { user.dossiers.first }
# TODO: check
# the order
# there are no extraneous input
scenario 'fill a dossier', js: true, vcr: { cassette_name: 'api_geo_departements_regions_et_communes' } do
log_in(user, procedure)
@ -160,6 +157,14 @@ feature 'The user' do
create(:procedure, :published, :for_individual, types_de_champ: tdcs)
end
let(:procedure_with_pjs) do
tdcs = [
create(:type_de_champ_piece_justificative, mandatory: true, libelle: 'Pièce justificative 1', order_place: 1),
create(:type_de_champ_piece_justificative, mandatory: true, libelle: 'Pièce justificative 2', order_place: 2)
]
create(:procedure, :published, :for_individual, types_de_champ: tdcs)
end
scenario 'adding, replacing and removing attachments', js: true do
log_in(user, procedure_with_pj)
fill_individual
@ -191,6 +196,85 @@ feature 'The user' do
expect(page).to have_no_text('RIB.pdf')
end
context 'when the auto-uploads of attachments is enabled' do
before do
Flipper.enable_actor(:autoupload_dossier_attachments, user)
end
scenario 'add an attachment', js: true do
log_in(user, procedure_with_pjs)
fill_individual
# Add attachments
find_field('Pièce justificative 1').attach_file(Rails.root + 'spec/fixtures/files/file.pdf')
find_field('Pièce justificative 2').attach_file(Rails.root + 'spec/fixtures/files/RIB.pdf')
# Expect the files to be uploaded immediately
expect(page).to have_text('analyse antivirus en cours', count: 2)
expect(page).to have_text('file.pdf')
expect(page).to have_text('RIB.pdf')
# Expect the submit buttons to be enabled
expect(page).to have_button('Enregistrer le brouillon', disabled: false)
expect(page).to have_button('Déposer le dossier', disabled: false)
# Reload the current page
visit current_path
# Expect the files to have been saved on the dossier
expect(page).to have_text('file.pdf')
expect(page).to have_text('RIB.pdf')
end
# TODO: once we're running on Rails 6, re-enable the validator on PieceJustificativeChamp,
# and unmark this spec as pending.
#
# See piece_justificative_champ.rb
# See https://github.com/betagouv/demarches-simplifiees.fr/issues/4926
scenario 'add an invalid attachment', js: true, pending: true do
log_in(user, procedure_with_pjs)
fill_individual
# Test invalid file type
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/invalid_file_format.json')
expect(page).to have_text('La pièce justificative nest pas dun type accepté')
expect(page).to have_no_button('Ré-essayer', visible: true)
# Replace the file by another with a valid type
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/piece_justificative_0.pdf')
expect(page).to have_no_text('La pièce justificative nest pas dun type accepté')
expect(page).to have_text('analyse antivirus en cours')
expect(page).to have_text('piece_justificative_0.pdf')
end
scenario 'retry on transcient upload error', js: true do
log_in(user, procedure_with_pjs)
fill_individual
# Test auto-upload failure
logout(:user) # Make the subsequent auto-upload request fail
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/file.pdf')
expect(page).to have_text('Une erreur sest produite pendant lenvoi du fichier')
expect(page).to have_button('Ré-essayer', visible: true)
expect(page).to have_button('Enregistrer le brouillon', disabled: false)
expect(page).to have_button('Déposer le dossier', disabled: false)
# Test that retrying after a failure works
login_as(user, scope: :user) # Make the auto-upload request work again
click_on('Ré-essayer', visible: true)
expect(page).to have_text('analyse antivirus en cours')
expect(page).to have_text('file.pdf')
expect(page).to have_button('Enregistrer le brouillon', disabled: false)
expect(page).to have_button('Déposer le dossier', disabled: false)
# Reload the current page
visit current_path
# Expect the file to have been saved on the dossier
expect(page).to have_text('file.pdf')
end
end
context 'when the draft autosave is enabled' do
before do
Flipper.enable_actor(:autosave_dossier_draft, user)

View file

@ -0,0 +1,3 @@
{
"text": "The format of this attachment is rejected by most uploaders."
}