diff --git a/app/assets/images/icons/arrow-down.svg b/app/assets/images/icons/arrow-down.svg new file mode 100644 index 000000000..24b325e9e --- /dev/null +++ b/app/assets/images/icons/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/arrow-up.svg b/app/assets/images/icons/arrow-up.svg new file mode 100644 index 000000000..3fbf0134b --- /dev/null +++ b/app/assets/images/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index 734799e9b..8bf4fb12d 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -87,6 +87,14 @@ background-image: image-url("icons/lock.svg"); } + &.arrow-up { + background-image: image-url("icons/arrow-up.svg"); + } + + &.arrow-down { + background-image: image-url("icons/arrow-down.svg"); + } + &.add { background-image: image-url("icons/add.svg"); margin-left: -5px; diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index e4c4b7b4f..93beefd4a 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -1,129 +1,127 @@ @import "colors"; @import "constants"; +@import "placeholders"; -.type-de-champ { - width: 100%; - background-color: #FAFDFF; - border: 1px solid $border-grey; - border-radius: 5px; - margin-bottom: $default-padding * 2; - box-shadow: 0px 2px 4px -4px; - overflow: hidden; - - .handle.icon { - width: 32px; - height: 32px; - background-size: 32px; - margin-left: 7px; - margin-right: 16px; - align-self: center; - cursor: grab; - opacity: 0.8; - - &:hover { - opacity: 0.4; - } +.types-de-champ-editor { + > .types-de-champ-block { + padding-bottom: 50px; } - .move { - height: 44px; - border-radius: 25px; - margin-right: 10px; + .type-de-champ { + width: 100%; + background-color: #FAFDFF; + border: 1px solid $border-grey; + border-radius: 5px; + margin-bottom: $default-padding * 2; + box-shadow: 0px 2px 4px -4px; + overflow: hidden; - &:first-of-type { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-bottom: -1px; - } + .handle.icon { + width: 32px; + height: 32px; + background-size: 32px; + margin-left: 7px; + margin-right: 16px; + align-self: center; + cursor: grab; + opacity: 0.8; - &:last-of-type { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - } - - .head { - background-color: #D9ECFF; - - select { - margin-bottom: 0px; - } - } - - &.type-header-section { - &, - .head { - background-color: $blue-france-500; - } - - .head .icon { - filter: contrast(0%) brightness(200%); - opacity: 0.9; - } - - label { - color: $light-grey; - } - } - - .flex { - &.section { - padding: 10px 10px 0 10px; - margin-bottom: 8px; - } - - &.hr { - border-bottom: 1px solid $border-grey; - - &.head { - border-bottom: 1px solid #D4E5F5; - padding-bottom: 10px; + &:hover { + opacity: 0.4; } } - &.shift-left { - margin-left: 55px; - } - - &.delete { + .delete { flex-grow: 1; display: flex; justify-content: flex-end; } - } - .cell { - margin-right: 20px; + .move-up, + .move-down { + @extend %outline; - &.small { - width: 90px; + display: inline-block; + width: 30px; + padding-bottom: 5px; + border-radius: 5px; + border: 1px solid $border-grey; + font-family: "Muli"; + background-color: #FFFFFF; + color: $black; + text-align: center; + -webkit-appearance: none; + + &:hover:not(:disabled) { + cursor: pointer; + background: $light-grey; + text-decoration: none; + } } - &.libelle { - width: 300px; + &.first .move-up { + display: none; } - label { - margin-bottom: 8px; - text-transform: uppercase; - font-size: 12px; + &.last .move-down { + display: none; } - } - .carte-options { - label { - font-weight: initial; + .head { + background-color: #D9ECFF; + + select { + margin-bottom: 0px; + } } - } - .inline { - display: inline; - } -} + &.type-header-section { + &, + .head { + background-color: $blue-france-500; + } -.champs-editor { - .footer { - height: 50px; + .handle.icon { + filter: contrast(0%) brightness(200%); + opacity: 0.9; + } + + label { + color: $light-grey; + } + } + + .flex { + &.section { + padding: 10px 10px 0 10px; + margin-bottom: 8px; + } + + &.hr { + border-bottom: 1px solid $border-grey; + + &.head { + border-bottom: 1px solid #D4E5F5; + padding-bottom: 10px; + } + } + } + + .cell { + margin-right: $default-padding; + + label { + margin-bottom: 8px; + text-transform: uppercase; + font-size: 12px; + } + } + + .carte-options { + label { + font-weight: initial; + } + } } .buttons { diff --git a/app/assets/stylesheets/utils.scss b/app/assets/stylesheets/utils.scss index 14e90df81..649bfcc9f 100644 --- a/app/assets/stylesheets/utils.scss +++ b/app/assets/stylesheets/utils.scss @@ -14,6 +14,9 @@ clear: both; } +.inline { + display: inline; +} // text .text-center, @@ -68,6 +71,10 @@ width: 100%; } +.width-33 { + width: 33.33%; +} + // who known .highlighted { background: $orange-bg; diff --git a/app/components/application_component.rb b/app/components/application_component.rb index 5235b0900..aa9c5eb56 100644 --- a/app/components/application_component.rb +++ b/app/components/application_component.rb @@ -1,3 +1,7 @@ class ApplicationComponent < ViewComponent::Base include ViewComponent::Translatable + + def class_names(class_names) + class_names.to_a.filter_map { |(class_name, flag)| class_name if flag }.join(' ') + end end diff --git a/app/components/attachment/edit_component.rb b/app/components/attachment/edit_component.rb index b8116772b..82f999afc 100644 --- a/app/components/attachment/edit_component.rb +++ b/app/components/attachment/edit_component.rb @@ -1,12 +1,13 @@ # Display a widget for uploading, editing and deleting a file attachment class Attachment::EditComponent < ApplicationComponent - def initialize(form:, attached_file:, accept: nil, template: nil, user_can_destroy: false, direct_upload: true) + def initialize(form:, attached_file:, accept: nil, template: nil, user_can_destroy: false, direct_upload: true, id: nil) @form = form @attached_file = attached_file @accept = accept @template = template @user_can_destroy = user_can_destroy @direct_upload = direct_upload + @id = id end attr_reader :template, :form @@ -56,7 +57,7 @@ class Attachment::EditComponent < ApplicationComponent class: "attachment-input #{attachment_input_class} #{'hidden' if persisted?}", accept: @accept, direct_upload: @direct_upload, - id: champ&.input_id, + id: champ&.input_id || @id, aria: { describedby: champ&.describedby_id }, data: { auto_attach_url: helpers.auto_attach_url(form.object) } } diff --git a/app/components/dossiers/message_component.rb b/app/components/dossiers/message_component.rb index 23c959bcb..a09f0ef78 100644 --- a/app/components/dossiers/message_component.rb +++ b/app/components/dossiers/message_component.rb @@ -64,8 +64,6 @@ class Dossiers::MessageComponent < ApplicationComponent end end - private - def highlight? commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at) end diff --git a/app/components/types_de_champ_editor/add_champ_button_component.rb b/app/components/types_de_champ_editor/add_champ_button_component.rb new file mode 100644 index 000000000..b162ecbdd --- /dev/null +++ b/app/components/types_de_champ_editor/add_champ_button_component.rb @@ -0,0 +1,50 @@ +class TypesDeChampEditor::AddChampButtonComponent < ApplicationComponent + def initialize(revision:, parent: nil, is_annotation: false) + @revision = revision + @parent = parent + @is_annotation = is_annotation + end + + private + + def annotations? + @is_annotation + end + + def procedure + @revision.procedure + end + + def button_title + if annotations? + "Ajouter une annotation" + else + "Ajouter un champ" + end + end + + def button_options + { + class: "button", + form: { class: @parent ? "add-to-block" : "add-to-root" }, + method: :post, + params: { + type_de_champ: { + libelle: champ_libelle, + type_champ: TypeDeChamp.type_champs.fetch(:text), + private: annotations? ? true : nil, + parent_id: @parent&.stable_id, + after_id: '' + }.compact + } + } + end + + def champ_libelle + if annotations? + "Nouvelle annotation" + else + "Nouveau champ" + end + end +end diff --git a/app/components/types_de_champ_editor/add_champ_button_component/add_champ_button_component.html.haml b/app/components/types_de_champ_editor/add_champ_button_component/add_champ_button_component.html.haml new file mode 100644 index 000000000..41d3eb4fa --- /dev/null +++ b/app/components/types_de_champ_editor/add_champ_button_component/add_champ_button_component.html.haml @@ -0,0 +1 @@ += button_to(button_title, admin_procedure_types_de_champ_path(procedure), button_options) diff --git a/app/components/types_de_champ_editor/block_component.rb b/app/components/types_de_champ_editor/block_component.rb new file mode 100644 index 000000000..22cf4bdaa --- /dev/null +++ b/app/components/types_de_champ_editor/block_component.rb @@ -0,0 +1,20 @@ +class TypesDeChampEditor::BlockComponent < ApplicationComponent + def initialize(block:, coordinates:) + @block = block + @coordinates = coordinates + end + + private + + def sortable_options + { + controller: 'sortable', + sortable_handle_value: '.handle', + sortable_group_value: block_id + } + end + + def block_id + dom_id(@block, :types_de_champ_editor_block) + end +end diff --git a/app/components/types_de_champ_editor/block_component/block_component.html.haml b/app/components/types_de_champ_editor/block_component/block_component.html.haml new file mode 100644 index 000000000..a1b9a7eed --- /dev/null +++ b/app/components/types_de_champ_editor/block_component/block_component.html.haml @@ -0,0 +1,3 @@ +%ul.types-de-champ-block{ id: block_id, data: sortable_options } + - @coordinates.each do |coordinate| + = render TypesDeChampEditor::ChampComponent.new(coordinate: coordinate) diff --git a/app/components/types_de_champ_editor/champ_component.rb b/app/components/types_de_champ_editor/champ_component.rb new file mode 100644 index 000000000..1c23a50dd --- /dev/null +++ b/app/components/types_de_champ_editor/champ_component.rb @@ -0,0 +1,111 @@ +class TypesDeChampEditor::ChampComponent < ApplicationComponent + def initialize(coordinate:, focused: false) + @coordinate = coordinate + @focused = focused + end + + private + + attr_reader :coordinate + delegate :type_de_champ, :revision, :procedure, to: :coordinate + + def can_be_mandatory? + type_de_champ.public? && !type_de_champ.non_fillable? + end + + def type_de_champ_path + admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id) + end + + def html_options + { + id: dom_id(coordinate, :type_de_champ_editor), + class: class_names('type-header-section': type_de_champ.header_section?, + first: coordinate.first?, + last: coordinate.last?), + data: { + controller: 'type-de-champ-editor', + type_de_champ_editor_move_url_value: move_admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id), + type_de_champ_editor_move_up_url_value: move_up_admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id), + type_de_champ_editor_move_down_url_value: move_down_admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id), + type_de_champ_editor_type_de_champ_id_value: coordinate.stable_id + } + } + end + + def form_options + { + url: type_de_champ_path, + multipart: true, + html: { id: nil, class: 'form width-100' } + } + end + + def move_button_options(direction) + { + type: 'button', + data: { action: 'type-de-champ-editor#onMoveButtonClick', type_de_champ_editor_direction_param: direction }, + title: direction == :up ? 'Déplacer le champ vers le haut' : 'Déplacer le champ vers le bas' + } + end + + def input_autofocus + @focused ? { controller: 'autofocus' } : nil + end + + def types_of_type_de_champ + TypeDeChamp.type_champs + .keys + .filter(&method(:filter_type_champ)) + .filter(&method(:filter_featured_type_champ)) + .filter(&method(:filter_block_type_champ)) + .map { |type_champ| [t("activerecord.attributes.type_de_champ.type_champs.#{type_champ}"), type_champ] } + .sort_by(&:first) + end + + def piece_justificative_options(form) + { + form: form, + attached_file: type_de_champ.piece_justificative_template, + user_can_destroy: true, + id: dom_id(type_de_champ, :piece_justificative_template) + } + end + + EXCLUDE_FROM_BLOCK = [ + TypeDeChamp.type_champs.fetch(:carte), + TypeDeChamp.type_champs.fetch(:dossier_link), + TypeDeChamp.type_champs.fetch(:repetition), + TypeDeChamp.type_champs.fetch(:siret) + ] + + def filter_block_type_champ(type_champ) + !coordinate.child? || !EXCLUDE_FROM_BLOCK.include?(type_champ) + end + + def filter_featured_type_champ(type_champ) + feature_name = TypeDeChamp::FEATURE_FLAGS[type_champ] + feature_name.blank? || Flipper.enabled?(feature_name, helpers.current_user) + end + + def filter_type_champ(type_champ) + case type_champ + when TypeDeChamp.type_champs.fetch(:number) + has_legacy_number? + when TypeDeChamp.type_champs.fetch(:cnaf) + procedure.cnaf_enabled? + when TypeDeChamp.type_champs.fetch(:dgfip) + procedure.dgfip_enabled? + when TypeDeChamp.type_champs.fetch(:pole_emploi) + procedure.pole_emploi_enabled? + when TypeDeChamp.type_champs.fetch(:mesri) + procedure.mesri_enabled? + else + true + end + end + + def has_legacy_number? + revision.types_de_champ.any?(&:legacy_number?) + end +end diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.fr.yml b/app/components/types_de_champ_editor/champ_component/champ_component.fr.yml new file mode 100644 index 000000000..8ba08f260 --- /dev/null +++ b/app/components/types_de_champ_editor/champ_component/champ_component.fr.yml @@ -0,0 +1,12 @@ +fr: + layers: + cadastres: Cadastres + unesco: UNESCO + arretes_protection: Arrêtés de protection + conservatoire_littoral: Conservatoire du Littoral + reserves_chasse_faune_sauvage: Réserves nationales de chasse et de faune sauvage + reserves_biologiques: Réserves biologiques + reserves_naturelles: Réserves naturelles + natura_2000: Natura 2000 + zones_humides: Zones humides d’importance internationale + znieff: ZNIEFF diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml new file mode 100644 index 000000000..b2b838d46 --- /dev/null +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -0,0 +1,72 @@ +%li.type-de-champ.flex.column.justify-start{ html_options } + .flex.justify-start.section.head{ class: type_de_champ.header_section? ? '' : 'hr'} + .handle.small.icon-only.icon.move-handle{ title: "Déplacer le champ vers le haut ou vers le bas" } + .flex.justify-start.delete + = button_to type_de_champ_path, class: 'button small icon-only danger', method: :delete, form: { data: { turbo_confirm: 'Êtes vous sûr de vouloir supprimer ce champ ?' } } do + .icon.delete + %span.sr-only Supprimer + + .flex.justify-start.section.ml-1 + = form_for(type_de_champ, form_options) do |form| + .flex.justify-start + .flex.justify-start.width-33 + .flex.justify-start.column + %button.move-up.cell.mb-1{ move_button_options(:up) } + .icon.arrow-up.small + %span.sr-only Déplacer le champ vers le haut + %button.move-down.cell{ move_button_options(:down) } + .icon.arrow-down.small + %span.sr-only Déplacer le champ vers le bas + .cell.flex.justify-start.column.flex-grow + = form.label :type_champ, "Type de champ", for: dom_id(type_de_champ, :type_champ) + = form.select :type_champ, types_of_type_de_champ, {}, class: 'small-margin small inline width-100', id: dom_id(type_de_champ, :type_champ) + .flex.column.justify-start.flex-grow + .cell + .flex.align-center + = form.label :libelle, "Libellé du champ", class: 'flex-grow', for: dom_id(type_de_champ, :libelle) + - if can_be_mandatory? + .cell.flex.align-center + = form.check_box :mandatory, class: 'small-margin small', id: dom_id(type_de_champ, :mandatory) + = form.label :mandatory, "Champ obligatoire", for: dom_id(type_de_champ, :mandatory) + = form.text_field :libelle, class: 'small-margin small width-100', id: dom_id(type_de_champ, :libelle), data: input_autofocus + - if !type_de_champ.header_section? && !type_de_champ.titre_identite? + .cell.mt-1 + = form.label :description, "Description du champ (optionnel)", for: dom_id(type_de_champ, :description) + = form.text_area :description, class: 'small-margin small width-100', rows: 3, id: dom_id(type_de_champ, :description) + + .flex.justify-start.mt-1 + - if type_de_champ.drop_down_list? + .flex.column.justify-start.width-33 + .cell + = form.label :drop_down_list_value, "Options de la liste", for: dom_id(type_de_champ, :drop_down_list_value) + = form.text_area :drop_down_list_value, class: 'small-margin small width-100', rows: 7, id: dom_id(type_de_champ, :drop_down_list_value) + - if type_de_champ.linked_drop_down_list? + .flex.column.justify-start.flex-grow + .cell + = form.label :drop_down_secondary_libelle, "Libellé du champ secondaire", class: 'flex-grow', for: dom_id(type_de_champ, :drop_down_secondary_libelle) + = form.text_field :drop_down_secondary_libelle, class: 'small-margin small width-100', id: dom_id(type_de_champ, :drop_down_secondary_libelle) + .cell.mt-1 + = form.label :drop_down_secondary_description, "Description du champ secondaire (optionnel)", for: dom_id(type_de_champ, :drop_down_secondary_description) + = form.text_area :drop_down_secondary_description, class: 'small-margin small width-100', rows: 3, id: dom_id(type_de_champ, :drop_down_secondary_description) + - if type_de_champ.piece_justificative? + .cell + = form.label :piece_justificative_template, "Modèle", for: dom_id(type_de_champ, :piece_justificative_template) + = render Attachment::EditComponent.new(**piece_justificative_options(form)) + - if type_de_champ.titre_identite? + .cell + %p + Dans le cadre de la RGPD, le titre d’identité sera supprimé lors de l’acceptation du dossier + - if type_de_champ.carte? + - type_de_champ.editable_options.each do |slice| + .cell + .carte-options + = form.fields_for :editable_options do |form| + - slice.each do |(name, checked)| + = form.label name, for: dom_id(type_de_champ, "layer_#{name}") do + = form.check_box name, checked: checked, class: 'small-margin small', id: dom_id(type_de_champ, "layer_#{name}") + = t(".layers.#{name}") + - if type_de_champ.repetition? + .flex.justify-start.section.ml-1 + .editor-block.flex-grow.cell + = render TypesDeChampEditor::BlockComponent.new(block: coordinate, coordinates: coordinate.revision_types_de_champ) + = render TypesDeChampEditor::AddChampButtonComponent.new(revision: coordinate.revision, parent: coordinate, is_annotation: coordinate.private?) diff --git a/app/components/types_de_champ_editor/editor_component.rb b/app/components/types_de_champ_editor/editor_component.rb new file mode 100644 index 000000000..0ea2c76cb --- /dev/null +++ b/app/components/types_de_champ_editor/editor_component.rb @@ -0,0 +1,20 @@ +class TypesDeChampEditor::EditorComponent < ApplicationComponent + def initialize(revision:, is_annotation: false) + @revision = revision + @is_annotation = is_annotation + end + + private + + def annotations? + @is_annotation + end + + def coordinates + if annotations? + @revision.revision_types_de_champ_private + else + @revision.revision_types_de_champ_public + end + end +end diff --git a/app/components/types_de_champ_editor/editor_component/editor_component.html.haml b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml new file mode 100644 index 000000000..7ae3d98da --- /dev/null +++ b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml @@ -0,0 +1,5 @@ +.types-de-champ-editor.editor-root{ 'data-turbo': 'true', id: dom_id(@revision, :types_de_champ_editor) } + = render TypesDeChampEditor::BlockComponent.new(block: @revision, coordinates: coordinates) + .buttons + = render TypesDeChampEditor::AddChampButtonComponent.new(revision: @revision, is_annotation: annotations?) + = render TypesDeChampEditor::EstimatedFillDurationComponent.new(revision: @revision, is_annotation: annotations?) diff --git a/app/components/types_de_champ_editor/estimated_fill_duration_component.rb b/app/components/types_de_champ_editor/estimated_fill_duration_component.rb new file mode 100644 index 000000000..54501d755 --- /dev/null +++ b/app/components/types_de_champ_editor/estimated_fill_duration_component.rb @@ -0,0 +1,22 @@ +class TypesDeChampEditor::EstimatedFillDurationComponent < ApplicationComponent + def initialize(revision:, is_annotation: false) + @revision = revision + @is_annotation = is_annotation + end + + private + + def annotations? + @is_annotation + end + + def show? + !annotations? && @revision.types_de_champ_public.present? + end + + def estimated_fill_duration_minutes + seconds = @revision.estimated_fill_duration + minutes = (seconds / 60.0).round + [1, minutes].max + end +end diff --git a/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.en.yml b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.en.yml new file mode 100644 index 000000000..7b9aa565c --- /dev/null +++ b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.en.yml @@ -0,0 +1,3 @@ +en: + estimated_fill_duration: "Estimated fill time:" + estimated_fill_minutes: "%{estimated_minutes} mn" diff --git a/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.fr.yml b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.fr.yml new file mode 100644 index 000000000..5d277706f --- /dev/null +++ b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.fr.yml @@ -0,0 +1,3 @@ +fr: + estimated_fill_duration: "Durée de remplissage estimée :" + estimated_fill_minutes: "%{estimated_minutes} mn" diff --git a/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.html.haml b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.html.haml new file mode 100644 index 000000000..f9a977c4b --- /dev/null +++ b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.html.haml @@ -0,0 +1,5 @@ +%span.fill-duration{ id: dom_id(@revision, :estimated_fill_duration) } + - if show? + = t('.estimated_fill_duration') + = link_to "https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur#g.-estimation-de-la-duree-de-remplissage", target: "_blank", rel: "noopener noreferrer" do + = t('.estimated_fill_minutes', estimated_minutes: estimated_fill_duration_minutes) diff --git a/app/javascript/controllers/autofocus_controller.ts b/app/javascript/controllers/autofocus_controller.ts new file mode 100644 index 000000000..289185948 --- /dev/null +++ b/app/javascript/controllers/autofocus_controller.ts @@ -0,0 +1,9 @@ +import { Controller } from '@hotwired/stimulus'; + +export class AutofocusController extends Controller { + connect() { + const element = this.element as HTMLInputElement; + element.focus(); + element.setSelectionRange(0, element.value.length); + } +} diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index d5a98251d..66669144e 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -1,5 +1,6 @@ import { Application } from '@hotwired/stimulus'; +import { AutofocusController } from './autofocus_controller'; import { AutosaveController } from './autosave_controller'; import { AutosaveStatusController } from './autosave_status_controller'; import { GeoAreaController } from './geo_area_controller'; @@ -7,11 +8,14 @@ import { MenuButtonController } from './menu_button_controller'; import { PersistedFormController } from './persisted_form_controller'; import { ReactController } from './react_controller'; import { ScrollToController } from './scroll_to_controller'; +import { SortableController } from './sortable_controller'; import { TurboEventController } from './turbo_event_controller'; import { TurboInputController } from './turbo_input_controller'; import { TurboPollController } from './turbo_poll_controller'; +import { TypeDeChampEditorController } from './type_de_champ_editor_controller'; const Stimulus = Application.start(); +Stimulus.register('autofocus', AutofocusController); Stimulus.register('autosave-status', AutosaveStatusController); Stimulus.register('autosave', AutosaveController); Stimulus.register('geo-area', GeoAreaController); @@ -19,6 +23,8 @@ Stimulus.register('menu-button', MenuButtonController); Stimulus.register('persisted-form', PersistedFormController); Stimulus.register('react', ReactController); Stimulus.register('scroll-to', ScrollToController); +Stimulus.register('sortable', SortableController); Stimulus.register('turbo-event', TurboEventController); Stimulus.register('turbo-input', TurboInputController); Stimulus.register('turbo-poll', TurboPollController); +Stimulus.register('type-de-champ-editor', TypeDeChampEditorController); diff --git a/app/javascript/controllers/sortable_controller.ts b/app/javascript/controllers/sortable_controller.ts new file mode 100644 index 000000000..d2db295cc --- /dev/null +++ b/app/javascript/controllers/sortable_controller.ts @@ -0,0 +1,68 @@ +import Sortable from 'sortablejs'; + +import { ApplicationController } from './application_controller'; + +export class SortableController extends ApplicationController { + declare readonly animationValue: number; + declare readonly handleValue: string; + declare readonly groupValue: string; + + #sortable?: Sortable; + + static values = { + animation: Number, + handle: String, + group: String + }; + + connect() { + this.#sortable = new Sortable(this.element as HTMLElement, { + ...this.defaultOptions, + ...this.options + }); + this.onGlobal('sortable:sort', () => this.setEdgeClassNames()); + } + + disconnect() { + this.#sortable?.destroy(); + } + + private onEnd({ item, newIndex }: { item: HTMLElement; newIndex?: number }) { + if (newIndex == null) return; + + this.dispatch('end', { + target: item, + detail: { position: newIndex } + }); + this.setEdgeClassNames(); + } + + setEdgeClassNames() { + const items = this.element.children; + for (const item of items) { + item.classList.remove('first', 'last'); + } + if (items.length > 1) { + const first = items[0]; + const last = items[items.length - 1]; + first?.classList.add('first'); + last?.classList.add('last'); + } + } + + get options(): Sortable.Options { + return { + animation: this.animationValue || this.defaultOptions.animation || 150, + handle: this.handleValue || this.defaultOptions.handle || undefined, + group: this.groupValue || this.defaultOptions.group || undefined, + onEnd: (event) => this.onEnd(event) + }; + } + + get defaultOptions(): Sortable.Options { + return { + fallbackOnBody: true, + swapThreshold: 0.65 + }; + } +} diff --git a/app/javascript/controllers/type_de_champ_editor_controller.ts b/app/javascript/controllers/type_de_champ_editor_controller.ts new file mode 100644 index 000000000..e9e472533 --- /dev/null +++ b/app/javascript/controllers/type_de_champ_editor_controller.ts @@ -0,0 +1,194 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { ActionEvent } from '@hotwired/stimulus'; +import { httpRequest } from '@utils'; +import { useIntersection } from 'stimulus-use'; + +import { ApplicationController } from './application_controller'; + +export class TypeDeChampEditorController extends ApplicationController { + static values = { + typeDeChampId: String, + moveUrl: String, + moveUpUrl: String, + moveDownUrl: String + }; + + declare readonly moveUrlValue: string; + declare readonly moveUpUrlValue: string; + declare readonly moveDownUrlValue: string; + declare readonly typeDeChampIdValue: string; + declare readonly isVisible: boolean; + + #latestPromise = Promise.resolve(); + #dirtyForms: Set = new Set(); + #inFlightForms: Map = new Map(); + + connect() { + useIntersection(this, { threshold: 0.6 }); + + this.#latestPromise = Promise.resolve(); + this.on('change', (event) => this.onChange(event)); + this.on('input', (event) => this.onInput(event)); + this.on('sortable:end', (event) => + this.onSortableEnd(event as CustomEvent) + ); + } + + disconnect() { + this.#latestPromise = Promise.resolve(); + for (const [form] of this.#inFlightForms) { + this.abortForm(form); + } + this.#inFlightForms.clear(); + } + + onMoveButtonClick(event: ActionEvent) { + const { direction } = event.params; + const action = + direction == 'up' ? this.moveUpUrlValue : this.moveDownUrlValue; + const form = createForm(action, 'patch'); + this.requestSubmitForm(form); + } + + appear() { + this.updateAfterId(); + } + + private onChange(event: Event) { + const target = event.target as HTMLElement & { form?: HTMLFormElement }; + + if ( + target.form && + (isSelectElement(target) || isCheckboxOrRadioInputElement(target)) + ) { + this.save(target.form); + } + } + + private onInput(event: Event) { + const target = event.target as HTMLElement & { form?: HTMLFormElement }; + + // mark input as touched so we know to not overwrite it's value with next re-render + target.setAttribute('data-touched', 'true'); + + if (target.form && isTextInputElement(target)) { + this.#dirtyForms.add(target.form); + this.debounce(this.save, 600); + } + } + + private onSortableEnd(event: CustomEvent<{ position: number }>) { + const position = event.detail.position; + if (event.target == this.element) { + const form = createForm(this.moveUrlValue, 'patch'); + createHiddenInput(form, 'position', position); + this.requestSubmitForm(form); + } + } + + private save(form?: HTMLFormElement | null): void { + if (form) { + createHiddenInput(form, 'should_render', true); + } else { + this.element.querySelector('input[name="should_render"]')?.remove(); + } + + this.requestSubmitForm(form); + } + + private requestSubmitForm(form?: HTMLFormElement | null) { + if (form) { + this.submitForm(form); + } else { + const forms = [...this.#dirtyForms]; + this.#dirtyForms.clear(); + + for (const form of forms) { + this.submitForm(form); + } + } + } + + private submitForm(form: HTMLFormElement) { + const controller = this.abortForm(form); + + this.#latestPromise = this.#latestPromise.finally(() => + httpRequest(form.action, { + method: form.getAttribute('method') ?? '', + body: new FormData(form), + controller: controller + }) + .turbo() + .catch(() => null) + ); + } + + private abortForm(form: HTMLFormElement) { + const controller = new AbortController(); + this.#inFlightForms.get(form)?.abort(); + this.#inFlightForms.set(form, controller); + return controller; + } + + private updateAfterId() { + const parent = this.element.closest( + '.editor-block, .editor-root' + ); + if (parent) { + const selector = parent.classList.contains('editor-block') + ? '.add-to-block' + : '.add-to-root'; + const input = parent.querySelector( + `${selector} ${AFTER_ID_INPUT_SELECTOR}` + ); + if (input) { + input.value = this.typeDeChampIdValue; + } + } + } +} + +const AFTER_ID_INPUT_SELECTOR = 'input[name="type_de_champ[after_id]"]'; + +function createForm(action: string, method: string) { + const form = document.createElement('form'); + form.action = action; + form.method = 'post'; + createHiddenInput(form, '_method', method); + return form; +} + +function createHiddenInput( + form: HTMLFormElement, + name: string, + value: unknown +) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = String(value); + form.appendChild(input); +} + +function isSelectElement(element: HTMLElement): element is HTMLSelectElement { + return element.tagName == 'SELECT'; +} + +function isCheckboxOrRadioInputElement( + element: HTMLElement & { type?: string } +): element is HTMLInputElement { + return ( + element.tagName == 'INPUT' && + (element.type == 'checkbox' || element.type == 'radio') + ); +} + +function isTextInputElement( + element: HTMLElement & { type?: string } +): element is HTMLInputElement { + return ( + ['INPUT', 'TEXTAREA'].includes(element.tagName) && + element.type != 'checkbox' && + element.type != 'radio' + ); +} diff --git a/app/models/champ.rb b/app/models/champ.rb index 19f9a8a60..ec769dac8 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -49,6 +49,10 @@ class Champ < ApplicationRecord :dossier_link?, :titre_identite?, :header_section?, + :cnaf?, + :dgfip?, + :pole_emploi?, + :mesri?, :siret?, :stable_id, to: :type_de_champ diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 062c9f3b1..1ab8c6b26 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -112,7 +112,7 @@ class TypeDeChamp < ApplicationRecord before_validation :check_mandatory before_save :remove_piece_justificative_template, if: -> { type_champ_changed? } - before_save :remove_drop_down_list, if: -> { type_champ_changed? } + before_validation :remove_drop_down_list, if: -> { type_champ_changed? } before_save :remove_repetition, if: -> { type_champ_changed? } after_save if: -> { @remove_piece_justificative_template } do @@ -225,6 +225,22 @@ class TypeDeChamp < ApplicationRecord type_champ == TypeDeChamp.type_champs.fetch(:carte) end + def cnaf? + type_champ == TypeDeChamp.type_champs.fetch(:cnaf) + end + + def dgfip? + type_champ == TypeDeChamp.type_champs.fetch(:dgfip) + end + + def pole_emploi? + type_champ == TypeDeChamp.type_champs.fetch(:pole_emploi) + end + + def mesri? + type_champ == TypeDeChamp.type_champs.fetch(:mesri) + end + def public? !private? end @@ -233,12 +249,6 @@ class TypeDeChamp < ApplicationRecord "TypesDeChamp::#{type_champ.classify}TypeDeChamp" end - def piece_justificative_template_url - if piece_justificative_template.attached? - Rails.application.routes.url_helpers.url_for(piece_justificative_template) - end - end - def piece_justificative_template_filename if piece_justificative_template.attached? piece_justificative_template.filename @@ -298,7 +308,10 @@ class TypeDeChamp < ApplicationRecord end def editable_options - options.slice(*TypesDeChamp::CarteTypeDeChamp::LAYERS) + layers = TypesDeChamp::CarteTypeDeChamp::LAYERS.map do |layer| + [layer, layer_enabled?(layer)] + end + layers.each_slice((layers.size / 2.0).round).to_a end def read_attribute_for_serialization(name) @@ -338,6 +351,12 @@ class TypeDeChamp < ApplicationRecord def remove_drop_down_list if !drop_down_list? self.drop_down_options = nil + elsif !drop_down_options_changed? + self.drop_down_options = if linked_drop_down_list? + ['', '--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'] + else + ['', 'Premier choix', 'Deuxième choix'] + end end end diff --git a/app/views/administrateurs/procedures/annotations.html.haml b/app/views/administrateurs/procedures/annotations.html.haml index c37aab001..14889e5f9 100644 --- a/app/views/administrateurs/procedures/annotations.html.haml +++ b/app/views/administrateurs/procedures/annotations.html.haml @@ -7,4 +7,4 @@ %h1 Configuration des annotations privées %br - = react_component("TypesDeChampEditor", types_de_champ_private_data(@procedure)) + = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision, is_annotation: true) diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index f335361ce..8a71d1ed1 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -7,4 +7,4 @@ %h1 Configuration des champs %br - = react_component("TypesDeChampEditor", types_de_champ_data(@procedure)) + = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision) diff --git a/package.json b/package.json index cbd019139..21e93c957 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "@rails/activestorage": "^6.1.4-1", "@rails/ujs": "^6.1.4-1", "@rails/webpacker": "5.4.3", - "@reach/auto-id": "^0.16.0", "@reach/combobox": "^0.16.5", "@reach/slider": "^0.16.0", "@sentry/browser": "6.12.0", @@ -39,13 +38,12 @@ "react": "^18.0.0", "react-coordinate-input": "^1.0.0", "react-dom": "^18.0.0", - "react-intersection-observer": "^8.31.0", "react-popper": "^2.2.5", "react-query": "^3.34.19", - "react-sortable-hoc": "^2.0.0", + "sortablejs": "^1.15.0", + "stimulus-use": "^0.50.0", "tiny-invariant": "^1.2.0", "trix": "^1.2.3", - "use-debounce": "^5.2.0", "webpack": "^4.46.0", "webpack-cli": "^3.3.12", "whatwg-fetch": "^3.0.0", @@ -62,6 +60,7 @@ "@types/rails__ujs": "^6.0.1", "@types/react": "^17.0.43", "@types/react-dom": "^17.0.14", + "@types/sortablejs": "^1.10.7", "@typescript-eslint/eslint-plugin": "^5.8.1", "@typescript-eslint/parser": "^5.8.1", "babel-eslint": "^10.1.0", diff --git a/spec/system/administrateurs/procedure_creation_spec.rb b/spec/system/administrateurs/procedure_creation_spec.rb index c9be3feb2..b12403155 100644 --- a/spec/system/administrateurs/procedure_creation_spec.rb +++ b/spec/system/administrateurs/procedure_creation_spec.rb @@ -35,12 +35,12 @@ describe 'Creating a new procedure', js: true do visit champs_admin_procedure_path(procedure) add_champ(remove_flash_message: true) - fill_in 'champ-0-libelle', with: 'libelle de champ' + fill_in 'Libellé du champ', with: 'libelle de champ' blur expect(page).to have_content('Formulaire enregistré') add_champ - expect(page).to have_selector('#champ-1-libelle') + expect(page).to have_selector('.type-de-champ', count: 1) click_on Procedure.last.libelle @@ -56,8 +56,8 @@ describe 'Creating a new procedure', js: true do # Add an empty repetition type de champ add_champ(remove_flash_message: true) - select('Bloc répétable', from: 'champ-0-type_champ') - fill_in 'champ-0-libelle', with: 'libellé de champ' + select('Bloc répétable', from: 'Type de champ') + fill_in 'Libellé du champ', with: 'libellé de champ' blur expect(page).to have_content('Formulaire enregistré') diff --git a/spec/system/administrateurs/types_de_champ_spec.rb b/spec/system/administrateurs/types_de_champ_spec.rb index c35386b18..8e539dafe 100644 --- a/spec/system/administrateurs/types_de_champ_spec.rb +++ b/spec/system/administrateurs/types_de_champ_spec.rb @@ -10,8 +10,7 @@ describe 'As an administrateur I can edit types de champ', js: true do scenario "adding a new champ" do add_champ - fill_in 'champ-0-libelle', with: 'libellé de champ' - blur + fill_in 'Libellé du champ', with: 'libellé de champ' expect(page).to have_content('Formulaire enregistré') end @@ -25,21 +24,25 @@ describe 'As an administrateur I can edit types de champ', js: true do expect(page).to have_selector('.type-de-champ', count: 3) # Multiple champs can be edited - fill_in 'champ-0-libelle', with: 'libellé de champ 0' - fill_in 'champ-1-libelle', with: 'libellé de champ 1' - blur + within '.type-de-champ:nth-child(1)' do + fill_in 'Libellé du champ', with: 'libellé de champ 0' + end + within '.type-de-champ:nth-child(2)' do + fill_in 'Libellé du champ', with: 'libellé de champ 1' + end expect(page).to have_content('Formulaire enregistré') # Champs can be deleted - within '.type-de-champ[data-index="2"]' do + within '.type-de-champ:nth-child(3)' do page.accept_alert do click_on 'Supprimer' end end - expect(page).not_to have_selector('#champ-2-libelle') + expect(page).to have_content('Supprimer', count: 2) - fill_in 'champ-1-libelle', with: 'edited libellé de champ 1' - blur + within '.type-de-champ:nth-child(2)' do + fill_in 'Libellé du champ', with: 'edited libellé de champ 1' + end expect(page).to have_content('Formulaire enregistré') expect(page).to have_content('Supprimer', count: 2) @@ -50,8 +53,7 @@ describe 'As an administrateur I can edit types de champ', js: true do scenario "removing champs" do add_champ(remove_flash_message: true) - fill_in 'champ-0-libelle', with: 'libellé de champ' - blur + fill_in 'Libellé du champ', with: 'libellé de champ' expect(page).to have_content('Formulaire enregistré') page.refresh @@ -69,32 +71,28 @@ describe 'As an administrateur I can edit types de champ', js: true do scenario "adding an invalid champ" do add_champ(remove_flash_message: true) - fill_in 'champ-0-libelle', with: '' - fill_in 'champ-0-description', with: 'description du champ' - blur + fill_in 'Libellé du champ', with: '' + fill_in 'Description du champ (optionnel)', with: 'description du champ' expect(page).not_to have_content('Formulaire enregistré') - fill_in 'champ-0-libelle', with: 'libellé de champ' - blur + fill_in 'Libellé du champ', with: 'libellé de champ' expect(page).to have_content('Formulaire enregistré') end scenario "adding a repetition champ" do add_champ(remove_flash_message: true) - select('Bloc répétable', from: 'champ-0-type_champ') - fill_in 'champ-0-libelle', with: 'libellé de champ' - blur + select('Bloc répétable', from: 'Type de champ') + fill_in 'Libellé du champ', with: 'libellé de champ' expect(page).to have_content('Formulaire enregistré') page.refresh - within '.type-de-champ .repetition' do + within '.type-de-champ .editor-block' do click_on 'Ajouter un champ' - end - fill_in 'repetition-0-champ-0-libelle', with: 'libellé de champ 1' - blur + fill_in 'Libellé du champ', with: 'libellé de champ 1' + end expect(page).to have_content('Formulaire enregistré') expect(page).to have_content('Supprimer', count: 2) @@ -103,21 +101,23 @@ describe 'As an administrateur I can edit types de champ', js: true do click_on 'Ajouter un champ' end - select('Bloc répétable', from: 'champ-0-type_champ') - fill_in 'champ-0-libelle', with: 'libellé de champ 2' - blur + within '.type-de-champ:nth-child(2)' do + select('Bloc répétable', from: 'Type de champ') + fill_in 'Libellé du champ', with: 'libellé de champ 2' + end expect(page).to have_content('Supprimer', count: 3) end scenario "adding a carte champ" do - add_champ + add_champ(remove_flash_message: true) - select('Carte', from: 'champ-0-type_champ') - fill_in 'champ-0-libelle', with: 'Libellé de champ carte', fill_options: { clear: :backspace } + select('Carte', from: 'Type de champ') + fill_in 'Libellé du champ', with: 'Libellé de champ carte', fill_options: { clear: :backspace } check 'Cadastres' - wait_until { procedure.draft_types_de_champ.first.cadastres == true } + wait_until { procedure.draft_types_de_champ.first.layer_enabled?(:cadastres) } + wait_until { procedure.draft_types_de_champ.first.libelle == 'Libellé de champ carte' } expect(page).to have_content('Formulaire enregistré') preview_window = window_opened_by { click_on 'Prévisualiser le formulaire' } @@ -130,11 +130,11 @@ describe 'As an administrateur I can edit types de champ', js: true do end scenario "adding a dropdown champ" do - add_champ + add_champ(remove_flash_message: true) - select('Choix parmi une liste', from: 'champ-0-type_champ') - fill_in 'champ-0-libelle', with: 'Libellé de champ menu déroulant', fill_options: { clear: :backspace } - fill_in 'champ-0-drop_down_list_value', with: 'Un menu', fill_options: { clear: :backspace } + select('Choix parmi une liste', from: 'Type de champ') + fill_in 'Libellé du champ', with: 'Libellé de champ menu déroulant', fill_options: { clear: :backspace } + fill_in 'Options de la liste', with: 'Un menu', fill_options: { clear: :backspace } wait_until { procedure.draft_types_de_champ.first.drop_down_list_options == ['', 'Un menu'] } expect(page).to have_content('Formulaire enregistré') @@ -146,15 +146,15 @@ describe 'As an administrateur I can edit types de champ', js: true do scenario "displaying the estimated fill duration" do # It doesn't display anything when there are no champs - expect(page).not_to have_content('Durée de remplissage estimé') + expect(page).not_to have_content('Durée de remplissage estimée') # It displays the estimate when adding a new champ add_champ - select('Pièce justificative', from: 'champ-0-type_champ') - expect(page).to have_content('Durée de remplissage estimée : 1 mn') + select('Pièce justificative', from: 'Type de champ') + expect(page).to have_content('Durée de remplissage estimée : 2 mn') # It updates the estimate when updating the champ - check 'Obligatoire' + check 'Champ obligatoire' expect(page).to have_content('Durée de remplissage estimée : 3 mn') # It updates the estimate when removing the champ diff --git a/spec/system/api_particulier/api_particulier_spec.rb b/spec/system/api_particulier/api_particulier_spec.rb index 6ce18b726..9f385536f 100644 --- a/spec/system/api_particulier/api_particulier_spec.rb +++ b/spec/system/api_particulier/api_particulier_spec.rb @@ -219,8 +219,8 @@ describe 'fetch API Particulier Data', js: true do visit champs_admin_procedure_path(procedure) add_champ - select('Données de la Caisse nationale des allocations familiales', from: 'champ-0-type_champ') - fill_in 'champ-0-libelle', with: 'libellé de champ' + select('Données de la Caisse nationale des allocations familiales', from: 'Type de champ') + fill_in 'Libellé du champ', with: 'libellé de champ' blur expect(page).to have_content('Formulaire enregistré') @@ -279,7 +279,9 @@ describe 'fetch API Particulier Data', js: true do expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true) dossier = Dossier.last - expect(dossier.champs.first.code_postal).to eq('wrong_code') + cnaf_champ = dossier.champs.find(&:cnaf?) + + expect(cnaf_champ.code_postal).to eq('wrong_code') click_on 'Déposer le dossier' expect(page).to have_content(/code postal doit posséder 5 caractères/) @@ -332,7 +334,7 @@ describe 'fetch API Particulier Data', js: true do expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true) dossier = Dossier.last - pole_emploi_champ = dossier.champs.third + pole_emploi_champ = dossier.champs.find(&:pole_emploi?) expect(pole_emploi_champ.identifiant).to eq('wrong code') @@ -400,7 +402,7 @@ describe 'fetch API Particulier Data', js: true do expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true) dossier = Dossier.last - mesri_champ = dossier.champs.fourth + mesri_champ = dossier.champs.find(&:mesri?) expect(mesri_champ.ine).to eq('wrong code') @@ -442,68 +444,72 @@ describe 'fetch API Particulier Data', js: true do end end - scenario 'it can fill a DGFiP field' do - visit commencer_path(path: procedure.path) - click_on 'Commencer la démarche' + context 'DGFiP' do + scenario 'it can fill a DGFiP field' do + visit commencer_path(path: procedure.path) + click_on 'Commencer la démarche' - choose 'Madame' - fill_in 'individual_nom', with: 'FERRI' - fill_in 'individual_prenom', with: 'Karine' + choose 'Madame' + fill_in 'individual_nom', with: 'FERRI' + fill_in 'individual_prenom', with: 'Karine' - click_button('Continuer') + click_button('Continuer') - fill_in 'Le numéro fiscal', with: numero_fiscal - fill_in "La référence d'avis d'imposition", with: 'wrong_code' + fill_in 'Le numéro fiscal', with: numero_fiscal + fill_in "La référence d'avis d'imposition", with: 'wrong_code' - blur - expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true) + blur + expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true) - dossier = Dossier.last - expect(dossier.champs.second.reference_avis).to eq('wrong_code') + dossier = Dossier.last + dgfip_champ = dossier.champs.find(&:dgfip?) - click_on 'Déposer le dossier' - expect(page).to have_content(/reference avis doit posséder 13 ou 14 caractères/) + expect(dgfip_champ.reference_avis).to eq('wrong_code') - fill_in "La référence d'avis d'imposition", with: reference_avis + click_on 'Déposer le dossier' + expect(page).to have_content(/reference avis doit posséder 13 ou 14 caractères/) - VCR.use_cassette('api_particulier/success/avis_imposition') do - perform_enqueued_jobs { click_on 'Déposer le dossier' } + fill_in "La référence d'avis d'imposition", with: reference_avis + + VCR.use_cassette('api_particulier/success/avis_imposition') do + perform_enqueued_jobs { click_on 'Déposer le dossier' } + end + + visit demande_dossier_path(dossier) + expect(page).to have_content(/Des données.*ont été reçues depuis la DGFiP/) + + log_out + + login_as instructeur.user, scope: :user + + visit instructeur_dossier_path(procedure, dossier) + + expect(page).to have_content('nom FERRI') + expect(page).to have_content('nom de naissance FERRI') + expect(page).to have_content('prénoms Karine') + expect(page).to have_content('date de naissance 12/08/1978') + + expect(page).to have_content('date de recouvrement 09/10/2020') + expect(page).to have_content("date d’établissement 07/07/2020") + + expect(page).to have_content('année 2020') + expect(page).to have_content("adresse fiscale de l’année passée 13 rue de la Plage 97615 Pamanzi") + expect(page).to have_content('nombre de parts 1') + expect(page).to have_content('situation familiale Célibataire') + expect(page).to have_content('nombre de personnes à charge 0') + + expect(page).to have_content('revenu brut global 38814') + expect(page).to have_content('revenu imposable 38814') + expect(page).to have_content('impôt sur le revenu net avant correction 38814') + expect(page).to have_content("montant de l’impôt 38814") + expect(page).to have_content('revenu fiscal de référence 38814') + expect(page).to have_content("année d’imposition 2020") + expect(page).to have_content('année des revenus 2020') + + expect(page).to have_content('situation partielle SUP DOM') + + expect(page).not_to have_content('erreur correctif') end - - visit demande_dossier_path(dossier) - expect(page).to have_content(/Des données.*ont été reçues depuis la DGFiP/) - - log_out - - login_as instructeur.user, scope: :user - - visit instructeur_dossier_path(procedure, dossier) - - expect(page).to have_content('nom FERRI') - expect(page).to have_content('nom de naissance FERRI') - expect(page).to have_content('prénoms Karine') - expect(page).to have_content('date de naissance 12/08/1978') - - expect(page).to have_content('date de recouvrement 09/10/2020') - expect(page).to have_content("date d’établissement 07/07/2020") - - expect(page).to have_content('année 2020') - expect(page).to have_content("adresse fiscale de l’année passée 13 rue de la Plage 97615 Pamanzi") - expect(page).to have_content('nombre de parts 1') - expect(page).to have_content('situation familiale Célibataire') - expect(page).to have_content('nombre de personnes à charge 0') - - expect(page).to have_content('revenu brut global 38814') - expect(page).to have_content('revenu imposable 38814') - expect(page).to have_content('impôt sur le revenu net avant correction 38814') - expect(page).to have_content("montant de l’impôt 38814") - expect(page).to have_content('revenu fiscal de référence 38814') - expect(page).to have_content("année d’imposition 2020") - expect(page).to have_content('année des revenus 2020') - - expect(page).to have_content('situation partielle SUP DOM') - - expect(page).not_to have_content('erreur correctif') end end end diff --git a/yarn.lock b/yarn.lock index 2fd317b39..f28e04cbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1092,7 +1092,7 @@ "@babel/helper-validator-option" "^7.16.7" "@babel/plugin-transform-typescript" "^7.16.7" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.15.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.15.3", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.5.tgz#7f3e34bf8bdbbadf03fbb7b1ea0d929569c9487a" integrity sha512-TXWihFIS3Pyv5hzR7j6ihmeLkZfrXGxAr5UfSl8CHf+6q/wpiYDkUau0czckpYG8QmnCIuPpdLtuA9VmuGGyMA== @@ -2017,7 +2017,7 @@ webpack-cli "^3.3.12" webpack-sources "^1.4.3" -"@reach/auto-id@0.16.0", "@reach/auto-id@^0.16.0": +"@reach/auto-id@0.16.0": version "0.16.0" resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.16.0.tgz#dfabc3227844e8c04f8e6e45203a8e14a8edbaed" integrity sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg== @@ -2538,6 +2538,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc" integrity sha512-L/TMpyURfBkf+o/526Zb6kd/tchUP3iBDEPjqjb+U2MAJhVRxxrmr2fwpe08E7QsV7YLcpq0tUaQ9O9x97ZIxQ== +"@types/sortablejs@^1.10.7": + version "1.10.7" + resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.10.7.tgz#ab9039c85429f0516955ec6dbc0bb20139417b15" + integrity sha512-lGCwwgpj8zW/ZmaueoPVSP7nnc9t8VqVWXS+ASX3eoUUENmiazv0rlXyTRludXzuX9ALjPsMqBu85TgJNWbTOg== + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -7261,6 +7266,11 @@ hosted-git-info@^4.0.1: dependencies: lru-cache "^6.0.0" +hotkeys-js@>=3: + version "3.9.4" + resolved "https://registry.yarnpkg.com/hotkeys-js/-/hotkeys-js-3.9.4.tgz#ce1aa4c3a132b6a63a9dd5644fc92b8a9b9cbfb9" + integrity sha512-2zuLt85Ta+gIyvs4N88pCYskNrxf1TFv3LR9t5mdAZIX8BcgQQ48F2opUptvHa6m8zsy5v/a0i9mWzTrlNWU0Q== + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -7611,13 +7621,6 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -11258,7 +11261,7 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= -prop-types@>=15.0.0, prop-types@^15.5.7, prop-types@^15.7.2: +prop-types@>=15.0.0, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -11498,11 +11501,6 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== -react-intersection-observer@^8.31.0: - version "8.33.1" - resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-8.33.1.tgz#8e6442cac7052ed63056e191b7539e423e7d5c64" - integrity sha512-3v+qaJvp3D1MlGHyM+KISVg/CMhPiOlO6FgPHcluqHkx4YFCLuyXNlQ/LE6UkbODXlQcLOppfX6UMxCEkUhDLw== - react-is@^16.12.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -11530,15 +11528,6 @@ react-query@^3.34.19: broadcast-channel "^3.4.1" match-sorter "^6.0.2" -react-sortable-hoc@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz#f6780d8aa4b922a21f3e754af542f032677078b7" - integrity sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg== - dependencies: - "@babel/runtime" "^7.2.0" - invariant "^2.2.4" - prop-types "^15.5.7" - react@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96" @@ -12402,6 +12391,11 @@ sort-keys@^2.0.0: dependencies: is-plain-obj "^1.0.0" +sortablejs@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a" + integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w== + source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" @@ -12609,6 +12603,13 @@ statsd-client@0.4.7: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +stimulus-use@^0.50.0: + version "0.50.0" + resolved "https://registry.yarnpkg.com/stimulus-use/-/stimulus-use-0.50.0.tgz#0bae92fbb1fd961cbb23569f9edd12ae642ce4a6" + integrity sha512-9NScZQiOycQdzh8VZ15pxk6ep/a22fgha2halOvZFpJITC4nsTbWlO7D1hm+9LspFxa5b28tQhm3XkbH/qAlGw== + dependencies: + hotkeys-js ">=3" + stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" @@ -13631,11 +13632,6 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" -use-debounce@^5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-5.2.1.tgz#7366543c769f1de3e92104dee64de5c4dfddfd33" - integrity sha512-BQG5uEypYHd/ASF6imzYR8tJHh5qGn28oZG/5iVAbljV6MUrfyT4jzxA8co+L+WLCT1U8VBwzzvlb3CHmUDpEA== - use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"