feat(type_de_champ): type_de_champ editor in stimulus/turbo

This commit is contained in:
Paul Chavard 2022-06-16 14:56:53 +02:00 committed by Paul Chavard
parent 1573d20ee9
commit 6801b04b7b
34 changed files with 898 additions and 248 deletions

View 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

View 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

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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

View file

@ -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) }
}

View file

@ -64,8 +64,6 @@ class Dossiers::MessageComponent < ApplicationComponent
end
end
private
def highlight?
commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at)
end

View file

@ -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

View file

@ -0,0 +1 @@
= button_to(button_title, admin_procedure_types_de_champ_path(procedure), button_options)

View 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

View file

@ -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)

View 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

View file

@ -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 dimportance internationale
znieff: ZNIEFF

View file

@ -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 didentité sera supprimé lors de lacceptation 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?)

View 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

View file

@ -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?)

View file

@ -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

View file

@ -0,0 +1,3 @@
en:
estimated_fill_duration: "Estimated fill time:"
estimated_fill_minutes: "%{estimated_minutes} mn"

View file

@ -0,0 +1,3 @@
fr:
estimated_fill_duration: "Durée de remplissage estimée :"
estimated_fill_minutes: "%{estimated_minutes} mn"

View file

@ -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)

View 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);
}
}

View file

@ -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);

View 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
};
}
}

View 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'
);
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)