feat(type_de_champ): type_de_champ editor in stimulus/turbo
This commit is contained in:
parent
1573d20ee9
commit
6801b04b7b
34 changed files with 898 additions and 248 deletions
3
app/assets/images/icons/arrow-down.svg
Normal file
3
app/assets/images/icons/arrow-down.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
After Width: | Height: | Size: 296 B |
3
app/assets/images/icons/arrow-up.svg
Normal file
3
app/assets/images/icons/arrow-up.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
After Width: | Height: | Size: 294 B |
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -64,8 +64,6 @@ class Dossiers::MessageComponent < ApplicationComponent
|
|||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def highlight?
|
||||
commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at)
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
= button_to(button_title, admin_procedure_types_de_champ_path(procedure), button_options)
|
20
app/components/types_de_champ_editor/block_component.rb
Normal file
20
app/components/types_de_champ_editor/block_component.rb
Normal file
|
@ -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
|
|
@ -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)
|
111
app/components/types_de_champ_editor/champ_component.rb
Normal file
111
app/components/types_de_champ_editor/champ_component.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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?)
|
20
app/components/types_de_champ_editor/editor_component.rb
Normal file
20
app/components/types_de_champ_editor/editor_component.rb
Normal file
|
@ -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
|
|
@ -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?)
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
en:
|
||||
estimated_fill_duration: "Estimated fill time:"
|
||||
estimated_fill_minutes: "%{estimated_minutes} mn"
|
|
@ -0,0 +1,3 @@
|
|||
fr:
|
||||
estimated_fill_duration: "Durée de remplissage estimée :"
|
||||
estimated_fill_minutes: "%{estimated_minutes} mn"
|
|
@ -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)
|
9
app/javascript/controllers/autofocus_controller.ts
Normal file
9
app/javascript/controllers/autofocus_controller.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
68
app/javascript/controllers/sortable_controller.ts
Normal file
68
app/javascript/controllers/sortable_controller.ts
Normal file
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
194
app/javascript/controllers/type_de_champ_editor_controller.ts
Normal file
194
app/javascript/controllers/type_de_champ_editor_controller.ts
Normal file
|
@ -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<HTMLFormElement> = new Set();
|
||||
#inFlightForms: Map<HTMLFormElement, AbortController> = 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<HTMLElement>(
|
||||
'.editor-block, .editor-root'
|
||||
);
|
||||
if (parent) {
|
||||
const selector = parent.classList.contains('editor-block')
|
||||
? '.add-to-block'
|
||||
: '.add-to-root';
|
||||
const input = parent.querySelector<HTMLInputElement>(
|
||||
`${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'
|
||||
);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue