Merge pull request #9274 from demarches-simplifiees/bach-operation-all-instruction-actions

[Actions multiples] Ajouter la possibilité pour les instructeurs de classer sans suite et refuser
This commit is contained in:
Lisa Durand 2023-07-12 09:43:29 +00:00 committed by GitHub
commit 3e30834644
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 406 additions and 88 deletions

View file

@ -218,6 +218,8 @@
ul.dropdown-items { ul.dropdown-items {
padding-inline-start: 0; padding-inline-start: 0;
list-style: none; list-style: none;
margin-top: 0;
margin-bottom: 0;
} }
.dropdown-items { .dropdown-items {

View file

@ -2,56 +2,74 @@ en:
archiver: archiver:
finish: finish:
text_success: text_success:
one: 1/1 file has been archived one: "%{success_count}/1 file has been archived"
other: "%{success_count}/%{count} files have been archived" other: "%{success_count}/%{count} files have been archived"
in_progress: in_progress:
text_success: text_success:
one: 1/1 is being archived one: "1 is being archived"
other: "%{success_count}/%{count} files have been archived" other: "%{success_count}/%{count} files have been archived"
passer_en_instruction: passer_en_instruction:
finish: finish:
text_success: text_success:
one: 1/1 file has been changed to instructing one: "%{success_count}/1 file has been changed to instructing"
other: "%{success_count}/%{count} files have been changed to instructing" other: "%{success_count}/%{count} files have been changed to instructing"
in_progress: in_progress:
text_success: text_success:
one: 1/1 is being changed to instructing one: "1 is being changed to instructing"
other: "%{success_count}/%{count} files have been changed to instructing" other: "%{success_count}/%{count} files have been changed to instructing"
accepter: accepter:
finish: finish:
text_success: text_success:
one: 1/1 file has been accepted one: "%{success_count}/1 file has been accepted"
other: "%{success_count}/%{count} files have been accepted" other: "%{success_count}/%{count} files have been accepted"
in_progress: in_progress:
text_success: text_success:
one: 1/1 is being accepted one: "1 is being accepted"
other: "%{success_count}/%{count} files have been accepted" other: "%{success_count}/%{count} files have been accepted"
refuser:
finish:
text_success:
one: "%{success_count}/1 file has been refused"
other: "%{success_count}/%{count} files have been refused"
in_progress:
text_success:
one: "1 is being refused"
other: "%{success_count}/%{count} files have been refused"
classer_sans_suite:
finish:
text_success:
one: "%{success_count}/1 file has been closed without continuation"
other: "%{success_count}/%{count} files have been closed without continuation"
in_progress:
text_success:
one: "1 is being closed without continuation"
other: "%{success_count}/%{count} files have been closed without continuation"
follow: follow:
finish: finish:
text_success: text_success:
one: 1/1 file has been followed one: "%{success_count}/1 file has been followed"
other: "%{success_count}/%{count} files have been followed" other: "%{success_count}/%{count} files have been followed"
in_progress: in_progress:
text_success: text_success:
one: 1/1 is being followed one: "1 is being followed"
other: "%{success_count}/%{count} files have been followed" other: "%{success_count}/%{count} files have been followed"
unfollow: unfollow:
finish: finish:
text_success: text_success:
one: 1/1 file has been unfollowed one: "%{success_count}/1 file has been unfollowed"
other: "%{success_count}/%{count} files have been unfollowed" other: "%{success_count}/%{count} files have been unfollowed"
in_progress: in_progress:
text_success: text_success:
one: 1/1 is being unfollowed one: "1 is being unfollowed"
other: "%{success_count}/%{count} files have been unfollowed" other: "%{success_count}/%{count} files have been unfollowed"
repasser_en_construction: repasser_en_construction:
finish: finish:
text_success: text_success:
one: 1/1 file has been changed to in progress one: "%{success_count}/1 file has been changed to in progress"
other: "%{success_count}/%{count} files have been changed to in progress" other: "%{success_count}/%{count} files have been changed to in progress"
in_progress: in_progress:
text_success: text_success:
one: 1/1 is being changed to in progress one: "1 is being changed to in progress"
other: "%{success_count}/%{count} files have been changed to in progress" other: "%{success_count}/%{count} files have been changed to in progress"
title: title:
finish: The bulk action is finished finish: The bulk action is finished

View file

@ -2,56 +2,74 @@ fr:
archiver: archiver:
finish: finish:
text_success: text_success:
one: 1 dossier a été archivé one: "%{success_count}/1 dossier a été archivé"
other: "%{success_count}/%{count} dossiers ont été archivés" other: "%{success_count}/%{count} dossiers ont été archivés"
in_progress: in_progress:
text_success: text_success:
one: 1 dossier sera archivé one: "1 dossier est en cours d'archivage"
other: "%{success_count}/%{count} dossiers ont été archivés" other: "%{success_count}/%{count} dossiers ont été archivés"
passer_en_instruction: passer_en_instruction:
finish: finish:
text_success: text_success:
one: 1 dossier a été passé en instruction one: "%{success_count}/1 dossier a été passé en instruction"
other: "%{success_count}/%{count} dossiers ont été passés en instruction" other: "%{success_count}/%{count} dossiers ont été passés en instruction"
in_progress: in_progress:
text_success: text_success:
one: 1 dossier sera passé en instruction one: "1 dossier est en cours d'instruction"
other: "%{success_count}/%{count} dossiers ont été passés en instruction" other: "%{success_count}/%{count} dossiers ont été passés en instruction"
accepter: accepter:
finish: finish:
text_success: text_success:
one: 1 dossier a été accepté one: "%{success_count}/1 dossier a été accepté"
other: "%{success_count}/%{count} dossiers ont été acceptés" other: "%{success_count}/%{count} dossiers ont été acceptés"
in_progress: in_progress:
text_success: text_success:
one: 1 dossier sera accepté one: "1 dossier est en cours d'acceptation"
other: "%{success_count}/%{count} dossiers ont été acceptés" other: "%{success_count}/%{count} dossiers ont été acceptés"
refuser:
finish:
text_success:
one: "%{success_count}/1 dossier a été refusé"
other: "%{success_count}/%{count} dossiers ont été refusés"
in_progress:
text_success:
one: "1 dossier est en cours de refus"
other: "%{success_count}/%{count} dossiers ont été refusés"
classer_sans_suite:
finish:
text_success:
one: "%{success_count}/1 dossier a été classé sans suite"
other: "%{success_count}/%{count} dossiers ont été classés sans suite"
in_progress:
text_success:
one: "1 dossier est en cours de classement sans suite"
other: "%{success_count}/%{count} dossiers ont été classés sans suite"
follow: follow:
finish: finish:
text_success: text_success:
one: 1 dossier a été suivi one: "%{success_count}/1 dossier a été suivi"
other: "%{success_count}/%{count} dossiers ont été suivis" other: "%{success_count}/%{count} dossiers ont été suivis"
in_progress: in_progress:
text_success: text_success:
one: 1 dossier sera suivi one: "1 dossier est en cours d'être suivi"
other: "%{success_count}/%{count} dossiers ont été suivis" other: "%{success_count}/%{count} dossiers ont été suivis"
unfollow: unfollow:
finish: finish:
text_success: text_success:
one: 1 dossier n'est plus suivi one: "%{success_count}/1 dossier n'est plus suivi"
other: "%{success_count}/%{count} dossiers ne sont plus suivis" other: "%{success_count}/%{count} dossiers ne sont plus suivis"
in_progress: in_progress:
text_success: text_success:
one: 1 dossier ne sera plus suivi one: "1 dossier est en cours de ne plus être suivi"
other: "%{success_count}/%{count} dossiers ne sont plus suivis" other: "%{success_count}/%{count} dossiers ne sont plus suivis"
repasser_en_construction: repasser_en_construction:
finish: finish:
text_success: text_success:
one: 1 dossier a été repassé en construction one: "%{success_count}/1 dossier a été repassé en construction"
other: "%{success_count}/%{count} dossiers ont été repassés en construction" other: "%{success_count}/%{count} dossiers ont été repassés en construction"
in_progress: in_progress:
text_success: text_success:
one: 1 dossier sera repassé en construction one: "1 dossier est en cours d'être repassé en construction"
other: "%{success_count}/%{count} dossiers ont été repassés en construction" other: "%{success_count}/%{count} dossiers ont été repassés en construction"
title: title:
finish: Laction de masse est terminée finish: Laction de masse est terminée

View file

@ -15,7 +15,7 @@ class Dossiers::BatchOperationComponent < ApplicationComponent
when Dossier.states.fetch(:en_construction) when Dossier.states.fetch(:en_construction)
[BatchOperation.operations.fetch(:passer_en_instruction)] [BatchOperation.operations.fetch(:passer_en_instruction)]
when Dossier.states.fetch(:en_instruction) when Dossier.states.fetch(:en_instruction)
[BatchOperation.operations.fetch(:accepter), BatchOperation.operations.fetch(:repasser_en_construction)] [BatchOperation.operations.fetch(:accepter), BatchOperation.operations.fetch(:refuser), BatchOperation.operations.fetch(:classer_sans_suite), BatchOperation.operations.fetch(:repasser_en_construction)]
when Dossier.states.fetch(:accepte), Dossier.states.fetch(:refuse), Dossier.states.fetch(:sans_suite) when Dossier.states.fetch(:accepte), Dossier.states.fetch(:refuse), Dossier.states.fetch(:sans_suite)
[BatchOperation.operations.fetch(:archiver)] [BatchOperation.operations.fetch(:archiver)]
else else
@ -58,10 +58,33 @@ class Dossiers::BatchOperationComponent < ApplicationComponent
}, },
{ {
label: t(".operations.accepter"), instruction:
operation: BatchOperation.operations.fetch(:accepter) [
}, {
label: t(".operations.accepter"),
operation_description: t(".operations.accepter_description"),
operation: BatchOperation.operations.fetch(:accepter),
operation_class_name: 'accept',
placeholder: t(".placeholders.accepter")
},
{
label: t(".operations.refuser"),
operation_description: t(".operations.refuser_description"),
operation: BatchOperation.operations.fetch(:refuser),
operation_class_name: 'refuse',
placeholder: t(".placeholders.refuser")
},
{
label: t(".operations.classer_sans_suite"),
operation_description: t(".operations.classer_sans_suite_description"),
operation: BatchOperation.operations.fetch(:classer_sans_suite),
operation_class_name: 'without-continuation',
placeholder: t(".placeholders.classer_sans_suite")
}
]
},
{ {
label: t(".operations.unfollow"), label: t(".operations.unfollow"),
operation: BatchOperation.operations.fetch(:unfollow) operation: BatchOperation.operations.fetch(:unfollow)

View file

@ -2,7 +2,19 @@ fr:
operations: operations:
archiver: 'Archive selected files' archiver: 'Archive selected files'
passer_en_instruction: 'Change selected files to instructing' passer_en_instruction: 'Change selected files to instructing'
instruction: Instructing files
accepter: 'Accept seleted files' accepter: 'Accept seleted files'
accepter_description: Users will be notified that their file has been accepted
refuser: 'Refuse seleted files'
refuser_description: Users will be notified that their file has been refused
without_continuation: 'Close without continuation seleted files'
without_continuation_description: Users will be notified that their file has been closed without continuation
follow: 'Follow seleted files' follow: 'Follow seleted files'
unfollow: 'Unfollow seleted files' unfollow: 'Unfollow seleted files'
repasser_en_construction: 'Change selected files to in progress' repasser_en_construction: 'Change selected files to in progress'
other: Other batch operations
confirm: Do you confirm the batch operation for selected files ?
placeholders:
accepter: "Explain to users why their file is accepted (optional)"
refuser: "Explain to users why their file is refused (mandatory)"
classer_sans_suite: "Explain to users why their file is closed without continuation (mandatory)"

View file

@ -2,7 +2,19 @@ fr:
operations: operations:
archiver: 'Archiver les dossiers' archiver: 'Archiver les dossiers'
passer_en_instruction: 'Passer les dossiers en instruction' passer_en_instruction: 'Passer les dossiers en instruction'
instruction: Instruire les dossiers
accepter: 'Accepter les dossiers' accepter: 'Accepter les dossiers'
accepter_description: Les usagers seront informés que leur dossier a été accepté
refuser: 'Refuser les dossiers'
refuser_description: Les usagers seront informés que leur dossier a été refusé
classer_sans_suite: 'Classer sans suite les dossiers'
classer_sans_suite_description: Les usagers seront informés que leur dossier a été classé sans suite
follow: 'Suivre les dossiers' follow: 'Suivre les dossiers'
unfollow: 'Ne plus suivre les dossiers' unfollow: 'Ne plus suivre les dossiers'
repasser_en_construction: 'Repasser les dossiers en construction' repasser_en_construction: 'Repasser les dossiers en construction'
other: Autres actions multiples
confirm: Confirmez-vous le traitement multiple des dossiers sélectionnés ?
placeholders:
accepter: "Expliquez aux demandeurs pourquoi leur dossier est accepté (facultatif)"
refuser: "Expliquez aux demandeurs pourquoi leur dossier est accepté (obligatoire)"
classer_sans_suite: "Expliquez aux demandeurs pourquoi leur dossier est accepté (obligatoire)"

View file

@ -1,20 +1,20 @@
.batch-operation.fr-ml-auto.flex .batch-operation.fr-ml-auto.flex
- if available_operations[:options].count.between?(1,3) - if available_operations[:options].count.between?(1,3)
= form_for(BatchOperation.new, url: instructeur_batch_operations_path(procedure_id: procedure.id), method: :post, html: { class: 'form', id: dom_id(BatchOperation.new) }) do |form| = form_for(BatchOperation.new, url: instructeur_batch_operations_path(procedure_id: procedure.id), method: :post, html: { class: 'form', id: dom_id(BatchOperation.new) }, data: { turbo: true, turbo_confirm: t('.operations.confirm') }) do |form|
- available_operations[:options].each do |opt| - available_operations[:options].each do |opt|
= render Dossiers::BatchOperationInlineButtonsComponent.new(opt:, icons:, form:) = render Dossiers::BatchOperationInlineButtonsComponent.new(opt:, icons:, form:)
- else - else
= form_for(BatchOperation.new, url: instructeur_batch_operations_path(procedure_id: procedure.id), method: :post, html: { class: 'form flex', id: dom_id(BatchOperation.new) }) do |form| = form_for(BatchOperation.new, url: instructeur_batch_operations_path(procedure_id: procedure.id), method: :post, html: { class: 'form flex', id: dom_id(BatchOperation.new) }, data: { turbo: true, turbo_confirm: t('.operations.confirm') }) do |form|
- available_operations[:options][0,2].each do |opt| - available_operations[:options][0,2].each do |opt|
= render Dossiers::BatchOperationInlineButtonsComponent.new(opt:, icons:, form:) = render Dossiers::BatchOperationInlineButtonsComponent.new(opt:, icons:, form:)
.dropdown{ data: { controller: 'menu-button', popover: 'true' } } .dropdown{ data: { controller: 'menu-button', popover: 'true' } }
-# Dropdown button title -# Dropdown button title
%button.fr-btn.fr-btn--sm.fr-btn--secondary.fr-ml-1w.dropdown-button{ disabled: true, data: { menu_button_target: 'button', batch_operation_target: 'menu' } } %button#batch_operation_others.fr-btn.fr-btn--sm.fr-btn--secondary.fr-ml-1w.dropdown-button{ disabled: true, data: { menu_button_target: 'button', batch_operation_target: 'dropdown' } }
Autres actions multiples = t('.operations.other')
#state-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } } #state-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' }, "aria-labelledby" => "batch_operation_others" }
%ul.dropdown-items %ul.dropdown-items
- available_operations[:options][2, available_operations[:options].count].each do |opt| - available_operations[:options][2, available_operations[:options].count].each do |opt|
%li{ 'data-turbo': 'true' } %li{ 'data-turbo': 'true' }

View file

@ -0,0 +1,3 @@
en:
labels:
instruction: Instruct files

View file

@ -0,0 +1,3 @@
fr:
labels:
instruction: Instruire les dossiers

View file

@ -1,31 +1,17 @@
- if opt[:operation] == 'accepter' - if opt.keys.include?(:instruction)
.dropdown{ data: { controller: 'menu-button', popover: 'true', operation: opt[:operation] }, id: 'dropdown_batch' } = render Dropdown::MenuComponent.new(wrapper: :div, wrapper_options: { data: {controller: 'menu-button', popover: 'true', operation: opt[:operation]} }, menu_options: { id: "dropdown_batch" }, button_options: { disabled: true, data: { batch_operation_target: "dropdown" }, class: "fr-btn fr-btn--sm fr-ml-1w"}, role: :region ) do |menu|
-# Dropdown button title - menu.with_button_inner_html do
%button{ disabled: true, class: ['fr-btn fr-btn--sm fr-btn--icon-left fr-ml-1w', icons[opt[:operation].to_sym]], disabled: true, name: "#{form.object_name}[operation]" , data: { menu_button_target: 'button' } } = t(".labels.instruction")
= opt[:label]
#state-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } } - opt[:instruction].each do |opt|
%ul.dropdown-items - menu.with_item do
%li.inactive{ 'data-turbo': 'true' } = link_to('#', onclick: "DS.showMotivation(event, '#{opt[:operation_class_name]}');", role: 'menuitem') do
- if opt[:operation] == 'accepter' %span{ class: "icon #{opt[:operation_class_name]}" }
.wrapper .dropdown-description
.dropdown-items-link %h4= opt[:label]
%span{ class: icons[opt[:operation].to_sym] } = opt[:operation_description]
.dropdown-description
%h4= opt[:label]
.motivation.accept
= form.text_area :motivation, class: 'fr-input'
#justificatif_motivation_suggest_accept.optional-justificatif
%button.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-attachment-line.fr-ml-0{ type: 'button', onclick: "DS.showImportJustificatif('accept');" } Ajouter un justificatif (optionnel)
#justificatif_motivation_import_accept.hidden
= form.file_field :justificatif_motivation, direct_upload: true, id: "dossier_justificatif_motivation_accept", onchange: "DS.showDeleteJustificatif('accept');"
.hidden.js_delete_motivation{ id: "delete_motivation_import_accept" }
%button.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-delete-line.fr-ml-0.fr-mt-1w{ type: 'button', onclick: "DS.deleteJustificatif('accept');" } Supprimer le justificatif
= button_tag "Annuler", type: :reset, class: 'fr-btn fr-btn--sm fr-btn--secondary', onclick: 'DS.motivationCancelBatch();'
= form.button "Valider la décision", class: ['fr-btn fr-btn--sm fr-mt-2w'], disabled: true, name: "#{form.object_name}[operation]", value: opt[:operation]
- menu.with_item(class: "hidden inactive form-inside fr-pt-1v") do
= render partial: 'instructeurs/dossiers/instruction_button_motivation_batch', locals: { instruction_operation: opt[:operation_class_name], form:, opt: }
- else - else
= form.button opt[:label], class: ['fr-btn fr-btn--sm fr-btn--icon-left fr-ml-1w', icons[opt[:operation].to_sym]], disabled: true, name: "#{form.object_name}[operation]", value: opt[:operation], data: { operation: opt[:operation] } = form.button opt[:label], class: ['fr-btn fr-btn--sm fr-btn--icon-left fr-ml-1w', icons[opt[:operation].to_sym]], disabled: true, name: "#{form.object_name}[operation]", value: opt[:operation], data: { operation: opt[:operation] }

View file

@ -51,4 +51,12 @@ class Dropdown::MenuComponent < ApplicationComponent
def button_class_names def button_class_names
['fr-btn', 'dropdown-button'] + Array(@button_options[:class]) ['fr-btn', 'dropdown-button'] + Array(@button_options[:class])
end end
def disabled?
@button_options[:disabled] == true
end
def data
{ menu_button_target: 'button' }.deep_merge(@button_options[:data].to_h)
end
end end

View file

@ -1,5 +1,5 @@
= content_tag(@wrapper, wrapper_options) do = content_tag(@wrapper, wrapper_options) do
%button{ class: button_class_names, id: button_id, data: { menu_button_target: 'button' }, "aria-expanded": "false", 'aria-haspopup': 'true', 'aria-controls': menu_id } %button{ class: button_class_names, id: button_id, disabled: disabled?, data: data, "aria-expanded": "false", 'aria-haspopup': 'true', 'aria-controls': menu_id }
= button_inner_html = button_inner_html
%div{ data: { menu_button_target: 'menu' }, id: menu_id, 'aria-labelledby': button_id, role: menu_role, 'tab-index': -1, class: menu_class_names } %div{ data: { menu_button_target: 'menu' }, id: menu_id, 'aria-labelledby': button_id, role: menu_role, 'tab-index': -1, class: menu_class_names }
@ -7,7 +7,7 @@
-# the dropdown can be a menu with a list of item -# the dropdown can be a menu with a list of item
- if items? - if items?
%ul.dropdown-items.fr-pl-0{ role: 'none' } %ul.dropdown-items{ role: 'none' }
- items.each do |dropdown_item| - items.each do |dropdown_item|
= dropdown_item = dropdown_item
-# the dropdown can be a menu with forms -# the dropdown can be a menu with forms

View file

@ -1,3 +1,7 @@
--- ---
en: en:
instruct: Instruct the file instruct: Instruct the file
placeholders:
accepter: "Explain to the user why this file is accepted (optional)"
refuser: "Explain to the user why this file is refused (mandatory)"
classer_sans_suite: "Explain to the user why this file is closed without continuation (mandatory)"

View file

@ -1,3 +1,7 @@
--- ---
fr: fr:
instruct: Instruire le dossier instruct: Instruire le dossier
placeholders:
accepter: "Expliquez au demandeur pourquoi ce dossier est accepté (facultatif)"
refuser: "Expliquez au demandeur pourquoi ce dossier est refusé (obligatoire)"
classer_sans_suite: "Expliquez au demandeur pourquoi ce dossier est classé sans suite (obligatoire)"

View file

@ -3,11 +3,11 @@ import { disable, enable, show, hide } from '@utils';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';
export class BatchOperationController extends ApplicationController { export class BatchOperationController extends ApplicationController {
static targets = ['menu', 'input']; static targets = ['menu', 'input', 'dropdown'];
declare readonly menuTarget: HTMLButtonElement; declare readonly menuTargets: HTMLButtonElement[];
declare readonly hasMenuTarget: boolean;
declare readonly inputTargets: HTMLInputElement[]; declare readonly inputTargets: HTMLInputElement[];
declare readonly dropdownTargets: HTMLButtonElement[];
onCheckOne() { onCheckOne() {
this.toggleSubmitButtonWhenNeeded(); this.toggleSubmitButtonWhenNeeded();
@ -58,6 +58,37 @@ export class BatchOperationController extends ApplicationController {
} }
} }
onSubmitInstruction(event: { srcElement: HTMLInputElement }) {
const field_refuse = document.querySelector<HTMLInputElement>(
'.js_batch_operation_motivation_refuse'
);
const field_without_continuation = document.querySelector<HTMLInputElement>(
'.js_batch_operation_motivation_without-continuation'
);
if (field_refuse != null) {
if (event.srcElement.value == 'refuser' && field_refuse.value == '') {
field_refuse.setCustomValidity('La motivation doit être remplie');
} else {
field_refuse.setCustomValidity('');
}
}
if (field_without_continuation != null) {
if (
event.srcElement.value == 'classer_sans_suite' &&
field_without_continuation.value == ''
) {
field_without_continuation.setCustomValidity(
'La motivation doit être remplie'
);
} else {
field_without_continuation.setCustomValidity('');
}
}
}
onDeleteSelection(event: { preventDefault: () => void }) { onDeleteSelection(event: { preventDefault: () => void }) {
event.preventDefault(); event.preventDefault();
emptyCheckboxes(); emptyCheckboxes();
@ -78,18 +109,38 @@ export class BatchOperationController extends ApplicationController {
switchButton(button, available); switchButton(button, available);
return available; return available;
}); });
if (this.hasMenuTarget) {
if (this.menuTargets.length) {
if (available.length) { if (available.length) {
enable(this.menuTarget); this.menuTargets.forEach((e) => enable(e));
} else { } else {
disable(this.menuTarget); this.menuTargets.forEach((e) => disable(e));
} }
} }
this.dropdownTargets.forEach((dropdown) => {
const buttons = Array.from(
document.querySelectorAll<HTMLButtonElement>(
`[aria-labelledby='${dropdown.id}'] button[data-operation]`
)
);
const disabled = buttons.every((button) => button.disabled);
if (disabled) {
disable(dropdown);
} else {
enable(dropdown);
}
});
// pour chaque chaque dropdown, on va chercher tous les boutons
// si tous les boutons sont disabled, on disable le dropdown
} else { } else {
if (this.hasMenuTarget) { this.menuTargets.forEach((e) => disable(e));
disable(this.menuTarget);
}
buttons.forEach((button) => switchButton(button, false)); buttons.forEach((button) => switchButton(button, false));
this.dropdownTargets.forEach((e) => disable(e));
} }
} }
} }

View file

@ -18,6 +18,8 @@
class BatchOperation < ApplicationRecord class BatchOperation < ApplicationRecord
enum operation: { enum operation: {
accepter: 'accepter', accepter: 'accepter',
refuser: 'refuser',
classer_sans_suite: 'classer_sans_suite',
archiver: 'archiver', archiver: 'archiver',
follow: 'follow', follow: 'follow',
passer_en_instruction: 'passer_en_instruction', passer_en_instruction: 'passer_en_instruction',
@ -61,6 +63,10 @@ class BatchOperation < ApplicationRecord
query.state_en_construction query.state_en_construction
when BatchOperation.operations.fetch(:accepter) then when BatchOperation.operations.fetch(:accepter) then
query.state_en_instruction query.state_en_instruction
when BatchOperation.operations.fetch(:refuser) then
query.state_en_instruction
when BatchOperation.operations.fetch(:classer_sans_suite) then
query.state_en_instruction
when BatchOperation.operations.fetch(:follow) then when BatchOperation.operations.fetch(:follow) then
query.without_followers.en_cours query.without_followers.en_cours
when BatchOperation.operations.fetch(:repasser_en_construction) then when BatchOperation.operations.fetch(:repasser_en_construction) then
@ -83,6 +89,10 @@ class BatchOperation < ApplicationRecord
dossier.passer_en_instruction(instructeur: instructeur) dossier.passer_en_instruction(instructeur: instructeur)
when BatchOperation.operations.fetch(:accepter) when BatchOperation.operations.fetch(:accepter)
dossier.accepter(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) dossier.accepter(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation)
when BatchOperation.operations.fetch(:refuser)
dossier.refuser(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation)
when BatchOperation.operations.fetch(:classer_sans_suite)
dossier.classer_sans_suite(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation)
when BatchOperation.operations.fetch(:follow) when BatchOperation.operations.fetch(:follow)
instructeur.follow(dossier) instructeur.follow(dossier)
when BatchOperation.operations.fetch(:repasser_en_construction) when BatchOperation.operations.fetch(:repasser_en_construction)

View file

@ -12,7 +12,7 @@
Lusager sera informé que son dossier a été accepté Lusager sera informé que son dossier a été accepté
- menu.with_item(class: "hidden inactive form-inside") do - menu.with_item(class: "hidden inactive form-inside") do
= render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est accepté (facultatif)', popup_class: 'accept', process_action: 'accepter', title: 'Accepter', confirm: "Confirmez-vous l'acceptation ce dossier ?" } = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: placeholder, popup_class: 'accept', process_action: 'accepter', title: 'Accepter', confirm: "Confirmez-vous l'acceptation ce dossier ?" }
- menu.with_item do - menu.with_item do
@ -23,7 +23,7 @@
Lusager sera informé que son dossier a été classé sans suite Lusager sera informé que son dossier a été classé sans suite
- menu.with_item(class: "hidden inactive form-inside") do - menu.with_item(class: "hidden inactive form-inside") do
= render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est classé sans suite (obligatoire)', popup_class: 'without-continuation', process_action: 'classer_sans_suite', title: 'Classer sans suite', confirm: 'Confirmez-vous le classement sans suite de ce dossier ?' } = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: placeholder, popup_class: 'without-continuation', process_action: 'classer_sans_suite', title: 'Classer sans suite', confirm: 'Confirmez-vous le classement sans suite de ce dossier ?' }
- menu.with_item do - menu.with_item do
= link_to('#', onclick: "DS.showMotivation(event, 'refuse');", role: 'menuitem') do = link_to('#', onclick: "DS.showMotivation(event, 'refuse');", role: 'menuitem') do
@ -33,7 +33,7 @@
Lusager sera informé que son dossier a été refusé Lusager sera informé que son dossier a été refusé
- menu.with_item(class: "hidden inactive form-inside") do - menu.with_item(class: "hidden inactive form-inside") do
= render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est refusé (obligatoire)', popup_class: 'refuse', process_action: 'refuser', title: 'Refuser', confirm: 'Confirmez-vous le refus de ce dossier ?' } = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: placeholder, popup_class: 'refuse', process_action: 'refuser', title: 'Refuser', confirm: 'Confirmez-vous le refus de ce dossier ?' }
- if dossier.may_flag_as_pending_correction? - if dossier.may_flag_as_pending_correction?
- menu.with_item do - menu.with_item do

View file

@ -0,0 +1,13 @@
%div{ class: "motivation #{instruction_operation}" }
= form.text_area :motivation, class: "fr-input js_batch_operation_motivation_#{instruction_operation}", placeholder: opt[:placeholder]
.optional-justificatif{ id: "justificatif_motivation_suggest_#{instruction_operation}" }
%button.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-attachment-line.fr-ml-0{ type: 'button', onclick: "DS.showImportJustificatif('#{instruction_operation}');" } Ajouter un justificatif (optionnel)
.hidden{ id: "justificatif_motivation_import_#{instruction_operation}" }
= form.file_field :justificatif_motivation, direct_upload: true, id: "dossier_justificatif_motivation_#{instruction_operation}", onchange: "DS.showDeleteJustificatif('#{instruction_operation}');"
.hidden.js_delete_motivation{ id: "delete_motivation_import_#{instruction_operation}" }
%button.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-delete-line.fr-ml-0.fr-mt-1w{ type: 'button', onclick: "DS.deleteJustificatif('#{instruction_operation}');" } Supprimer le justificatif
= button_tag "Annuler", type: :reset, class: 'fr-btn fr-btn--sm fr-btn--secondary', onclick: 'DS.motivationCancelBatch();'
= form.button "Valider la décision", class: ['fr-btn fr-btn--sm fr-mt-2w'], disabled: true, name: "#{form.object_name}[operation]", value: opt[:operation], data: { operation: opt[:operation], action: "batch-operation#onSubmitInstruction" }

View file

@ -166,6 +166,78 @@ RSpec.describe Dossiers::BatchAlertComponent, type: :component do
end end
end end
describe 'refuser' do
let(:component) do
described_class.new(
batch: batch_operation,
procedure: procedure
)
end
let!(:dossier) { create(:dossier, :en_instruction, procedure: procedure) }
let!(:dossier_2) { create(:dossier, :en_instruction, procedure: procedure) }
let!(:batch_operation) { create(:batch_operation, operation: :refuser, dossiers: [dossier, dossier_2], instructeur: instructeur) }
context 'in_progress' do
before {
batch_operation.track_processed_dossier(true, dossier)
batch_operation.reload
}
it { is_expected.to have_selector('.fr-alert--info') }
it { is_expected.to have_text("Une action de masse est en cours") }
it { is_expected.to have_text("1/2 dossiers ont été refusés") }
end
context 'finished and success' do
before {
batch_operation.track_processed_dossier(true, dossier)
batch_operation.track_processed_dossier(true, dossier_2)
batch_operation.reload
}
it { is_expected.to have_selector('.fr-alert--success') }
it { is_expected.to have_text("Laction de masse est terminée") }
it { is_expected.to have_text("2 dossiers ont été refusés") }
it { expect(batch_operation.seen_at).to eq(nil) }
end
end
describe 'classer_sans_suite' do
let(:component) do
described_class.new(
batch: batch_operation,
procedure: procedure
)
end
let!(:dossier) { create(:dossier, :en_instruction, procedure: procedure) }
let!(:dossier_2) { create(:dossier, :en_instruction, procedure: procedure) }
let!(:batch_operation) { create(:batch_operation, operation: :classer_sans_suite, dossiers: [dossier, dossier_2], instructeur: instructeur) }
context 'in_progress' do
before {
batch_operation.track_processed_dossier(true, dossier)
batch_operation.reload
}
it { is_expected.to have_selector('.fr-alert--info') }
it { is_expected.to have_text("Une action de masse est en cours") }
it { is_expected.to have_text("1/2 dossiers ont été classés sans suite") }
end
context 'finished and success' do
before {
batch_operation.track_processed_dossier(true, dossier)
batch_operation.track_processed_dossier(true, dossier_2)
batch_operation.reload
}
it { is_expected.to have_selector('.fr-alert--success') }
it { is_expected.to have_text("Laction de masse est terminée") }
it { is_expected.to have_text("2 dossiers ont été classés sans suite") }
it { expect(batch_operation.seen_at).to eq(nil) }
end
end
describe 'follow' do describe 'follow' do
let(:component) do let(:component) do
described_class.new( described_class.new(

View file

@ -21,7 +21,7 @@ RSpec.describe Dossiers::BatchOperationComponent, type: :component do
context 'statut suivis' do context 'statut suivis' do
let(:statut) { 'suivis' } let(:statut) { 'suivis' }
it { is_expected.to have_button('Passer les dossiers en instruction', disabled: true) } it { is_expected.to have_button('Passer les dossiers en instruction', disabled: true) }
it { is_expected.to have_button('Accepter les dossiers', disabled: true) } it { is_expected.to have_button('Instruire les dossiers', disabled: true) }
it { is_expected.to have_button('Autres actions multiples', disabled: true) } it { is_expected.to have_button('Autres actions multiples', disabled: true) }
it { is_expected.to have_button('Repasser les dossiers en construction', disabled: true) } it { is_expected.to have_button('Repasser les dossiers en construction', disabled: true) }
it { is_expected.to have_button('Ne plus suivre les dossiers', disabled: true) } it { is_expected.to have_button('Ne plus suivre les dossiers', disabled: true) }

View file

@ -40,6 +40,28 @@ FactoryBot.define do
end end
end end
trait :refuser do
operation { BatchOperation.operations.fetch(:refuser) }
after(:build) do |batch_operation, evaluator|
procedure = create(:simple_procedure, :published, instructeurs: [evaluator.invalid_instructeur.presence || batch_operation.instructeur], administrateurs: [create(:administrateur)])
batch_operation.dossiers = [
create(:dossier, :with_individual, :en_instruction, procedure: procedure),
create(:dossier, :with_individual, :en_instruction, procedure: procedure)
]
end
end
trait :classer_sans_suite do
operation { BatchOperation.operations.fetch(:classer_sans_suite) }
after(:build) do |batch_operation, evaluator|
procedure = create(:simple_procedure, :published, instructeurs: [evaluator.invalid_instructeur.presence || batch_operation.instructeur], administrateurs: [create(:administrateur)])
batch_operation.dossiers = [
create(:dossier, :with_individual, :en_instruction, procedure: procedure),
create(:dossier, :with_individual, :en_instruction, procedure: procedure)
]
end
end
trait :follow do trait :follow do
operation { BatchOperation.operations.fetch(:follow) } operation { BatchOperation.operations.fetch(:follow) }
after(:build) do |batch_operation, evaluator| after(:build) do |batch_operation, evaluator|

View file

@ -154,6 +154,48 @@ describe BatchOperationProcessOneJob, type: :job do
end end
end end
context 'when operation is "refuser"' do
let(:batch_operation) do
create(:batch_operation, :refuser,
options.merge(instructeur: create(:instructeur), motivation: 'motivation'))
end
it 'refuses the dossier in the batch' do
expect { subject.perform_now }
.to change { dossier_job.reload.refuse? }
.from(false)
.to(true)
end
it 'refuses the dossier in the batch with a motivation' do
expect { subject.perform_now }
.to change { dossier_job.reload.motivation }
.from(nil)
.to('motivation')
end
end
context 'when operation is "classer_sans_suite"' do
let(:batch_operation) do
create(:batch_operation, :classer_sans_suite,
options.merge(instructeur: create(:instructeur), motivation: 'motivation'))
end
it 'closes without continuation the dossier in the batch' do
expect { subject.perform_now }
.to change { dossier_job.reload.sans_suite? }
.from(false)
.to(true)
end
it 'closes without continuation the dossier in the batch with a motivation' do
expect { subject.perform_now }
.to change { dossier_job.reload.motivation }
.from(nil)
.to('motivation')
end
end
context 'when the dossier is out of sync (ie: someone applied a transition somewhere we do not know)' do context 'when the dossier is out of sync (ie: someone applied a transition somewhere we do not know)' do
let(:instructeur) { create(:instructeur) } let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:simple_procedure, instructeurs: [instructeur]) } let(:procedure) { create(:simple_procedure, instructeurs: [instructeur]) }

View file

@ -31,18 +31,21 @@ describe 'BatchOperation a dossier:', js: true do
expect(page).to have_button("Archiver les dossiers") expect(page).to have_button("Archiver les dossiers")
# ensure batch is created # ensure batch is created
expect { click_on "Archiver les dossiers" }
.to change { BatchOperation.count } page.accept_alert do
.from(0).to(1) click_on "Archiver les dossiers"
end
# ensure batched dossier is disabled # ensure batched dossier is disabled
expect(page).to have_selector("##{checkbox_id}[disabled]") expect(page).to have_selector("##{checkbox_id}[disabled]")
# ensure Batch is created
expect(BatchOperation.count).to eq(1)
# check a11y with disabled checkbox # check a11y with disabled checkbox
expect(page).to be_axe_clean expect(page).to be_axe_clean
# ensure alert is present # ensure alert is present
expect(page).to have_content("Information : Une action de masse est en cours") expect(page).to have_content("Information : Une action de masse est en cours")
expect(page).to have_content("1 dossier sera archivé") expect(page).to have_content("1 dossier est en cours d'archivage")
# ensure jobs are queued # ensure jobs are queued
perform_enqueued_jobs(only: [BatchOperationEnqueueAllJob]) perform_enqueued_jobs(only: [BatchOperationEnqueueAllJob])
@ -71,10 +74,14 @@ describe 'BatchOperation a dossier:', js: true do
end end
# submit checkall # submit checkall
expect { click_on "Archiver les dossiers" } page.accept_alert do
.to change { BatchOperation.count } click_on "Archiver les dossiers"
.from(1).to(2) end
# reload
visit instructeur_procedure_path(procedure, statut: 'traites')
expect(BatchOperation.count).to eq(2)
expect(BatchOperation.last.dossiers).to match_array([dossier_2, dossier_3]) expect(BatchOperation.last.dossiers).to match_array([dossier_2, dossier_3])
end end
@ -109,10 +116,14 @@ describe 'BatchOperation a dossier:', js: true do
find("##{dom_id(BatchOperation.new, :checkbox_all)}").check find("##{dom_id(BatchOperation.new, :checkbox_all)}").check
click_on("Sélectionner tous les 3 dossiers") click_on("Sélectionner tous les 3 dossiers")
expect { click_on "Suivre les dossiers" } accept_alert do
.to change { BatchOperation.count } click_on "Suivre les dossiers"
.from(0).to(1) end
# reload
visit instructeur_procedure_path(procedure, statut: 'a-suivre')
expect(BatchOperation.count).to eq(1)
expect(BatchOperation.last.dossiers).to match_array([dossier_1, dossier_2, dossier_3]) expect(BatchOperation.last.dossiers).to match_array([dossier_1, dossier_2, dossier_3])
end end
@ -138,10 +149,14 @@ describe 'BatchOperation a dossier:', js: true do
expect(find_field("batch_operation[dossier_ids][]", type: :hidden).value).to eq "#{dossier_4.id},#{dossier_3.id},#{dossier_2.id}" expect(find_field("batch_operation[dossier_ids][]", type: :hidden).value).to eq "#{dossier_4.id},#{dossier_3.id},#{dossier_2.id}"
# create batch # create batch
expect { click_on "Suivre les dossiers" } accept_alert do
.to change { BatchOperation.count } click_on "Suivre les dossiers"
.from(0).to(1) end
# reload
visit instructeur_procedure_path(procedure, statut: 'a-suivre')
expect(BatchOperation.count).to eq(1)
expect(BatchOperation.last.dossiers).to match_array([dossier_2, dossier_3, dossier_4]) expect(BatchOperation.last.dossiers).to match_array([dossier_2, dossier_3, dossier_4])
end end
end end