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"