Merge pull request #10404 from tchak/update-react-and-coldwired

chore(js): update coldwired, react and combobox
This commit is contained in:
Paul Chavard 2024-07-05 11:57:20 +00:00 committed by GitHub
commit fea8d8971b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 1724 additions and 3311 deletions

View file

@ -28,3 +28,7 @@ body {
.container { .container {
@extend %container; @extend %container;
} }
react-fragment {
display: block;
}

View file

@ -10,10 +10,6 @@
} }
} }
.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] {
margin-bottom: 0;
}
.map-style-control { .map-style-control {
position: absolute; position: absolute;
bottom: 4px; bottom: 4px;

View file

@ -32,29 +32,87 @@ trix-editor.fr-input {
} }
.fr-ds-combobox { .fr-ds-combobox {
.fr-menu {
width: 100%;
.fr-menu__list {
width: 100%;
max-height: 300px;
}
}
.fr-autocomplete { .fr-autocomplete {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E"); background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
} }
} }
.fr-ds-combobox__multiple {
.fr-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.3rem;
}
}
.fr-ds-combobox__menu {
&[data-placement=top] {
--origin: translateY(8px);
}
&[data-placement=bottom] {
--origin: translateY(-8px);
}
&[data-placement=right] {
--origin: translateX(-8px);
}
&[data-placement=left] {
--origin: translateX(8px);
}
&[data-entering] {
animation: popover-slide 200ms;
}
&.fr-menu {
width: var(--trigger-width);
top: unset;
.fr-menu__list {
display: block;
width: unset;
max-height: 300px;
overflow: auto;
}
.fr-menu__item {
&[data-selected] {
font-weight: bold;
}
&[data-focused] {
font-weight: bold;
}
}
}
}
@keyframes popover-slide {
from {
transform: var(--origin);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 62em) { @media (max-width: 62em) {
.fr-ds-combobox .fr-menu .fr-menu__list { .fr-ds-combobox__menu {
z-index: calc(var(--ground) + 1000); &.fr-menu .fr-menu__list {
background-color: var(--background-default-grey); z-index: calc(var(--ground) + 1000);
--idle: transparent; background-color: var(--background-default-grey);
--hover: var(--background-overlap-grey-hover); --idle: transparent;
--active: var(--background-overlap-grey-active); --hover: var(--background-overlap-grey-hover);
filter: drop-shadow(var(--overlap-shadow)); --active: var(--background-overlap-grey-active);
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france); filter: drop-shadow(var(--overlap-shadow));
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
}
} }
} }

View file

@ -356,41 +356,6 @@
margin-bottom: 0; margin-bottom: 0;
} }
[data-reach-combobox-input] {
&:not([class^='width-']) {
width: 100%;
min-width: 50%;
max-width: 100%;
}
&:focus {
border-color: $blue-france-500;
}
}
[data-reach-combobox-token-list] {
padding: $default-spacer;
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
}
[data-reach-combobox-token] button {
border: solid 1px $border-grey;
border-radius: 4px;
padding: $default-spacer;
margin-right: $default-spacer;
cursor: pointer;
display: flex;
align-items: center;
}
[data-reach-combobox-token] button:focus {
background-color: $black;
color: $white;
}
.editable-champ { .editable-champ {
&:not(.editable-champ-carte) .algolia-autocomplete { &:not(.editable-champ-carte) .algolia-autocomplete {
margin-bottom: 2 * $default-padding; margin-bottom: 2 * $default-padding;
@ -524,91 +489,8 @@
} }
} }
[data-react-component-value^="ComboMultiple"] { .fr-ds-combobox__multiple {
margin-bottom: $default-fields-spacer; margin-bottom: $default-fields-spacer;
[data-reach-combobox-input] {
flex-grow: 1;
background-image: image-url("icons/chevron-down");
background-size: 14px;
background-repeat: no-repeat;
background-position: right 10px center;
border-radius: 4px;
border: solid 1px $border-grey;
padding: $default-padding;
margin: $default-spacer;
margin-top: 0;
width: 100%;
}
ul {
list-style: none;
li {
margin-right: $default-spacer;
display: inline-block;
}
}
}
[data-reach-combobox-token-label] {
border: 1px solid #CCCCCC;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
}
[data-reach-combobox-option] {
font-size: 16px;
list-style-type: none;
}
[data-reach-combobox-option][aria-selected="true"] {
background: $light-blue !important;
color: $white;
}
[data-reach-combobox-separator] {
font-size: 16px;
color: $dark-grey;
background: $light-grey;
padding: $default-spacer;
}
[data-reach-combobox-no-results] {
font-size: 16px;
color: $dark-grey;
background: $light-grey;
padding: $default-spacer;
}
[data-reach-combobox-token] button {
cursor: pointer;
background-color: transparent;
background-image: none;
border: none;
line-height: 1;
padding: 0;
margin-right: 4px;
display: flex;
align-items: center !important;
}
[data-reach-combobox-input] button:focus {
outline-color: $light-blue;
}
[data-fr-theme="dark"] [data-reach-combobox-popover] {
border: none;
background: var(--background-action-low-blue-france);
}
[data-fr-theme="dark"] [data-reach-combobox-option]:hover {
background: var(--background-action-low-blue-france-hover);
}
[data-reach-combobox-popover] {
z-index: 20;
} }
.fconnect-form { .fconnect-form {
@ -634,10 +516,6 @@ textarea::placeholder {
.fr-menu__item { .fr-menu__item {
list-style-type: none; list-style-type: none;
margin-bottom: $default-spacer; margin-bottom: $default-spacer;
&[aria-selected] {
font-weight: bold;
}
} }
} }

View file

@ -1,36 +1,5 @@
@import "constants"; @import "constants";
[data-reach-combobox-token-label] {
border: 1px solid #CCCCCC;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
}
.form [data-reach-combobox-token-list] {
padding: 8px;
display: flex;
align-items: center;
list-style: none;
}
.form [data-reach-combobox-input]:not([class^='width-']) {
width: 100%;
min-width: 50%;
max-width: 100%;
}
.form [data-reach-combobox-token] button {
border: solid 1px #CCCCCC;
background-color: transparent;
border-radius: 4px;
padding: 8px;
margin-right: 8px;
cursor: pointer;
display: flex;
align-items: center;
}
.hidden { .hidden {
display: none; display: none;
} }
@ -70,4 +39,79 @@
margin-bottom: 4px; margin-bottom: 4px;
} }
} }
.fr-ds-combobox {
.fr-autocomplete {
background-repeat: no-repeat;
background-position: right;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
}
}
.fr-ds-combobox__multiple {
.fr-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.3rem;
}
.fr-tag {
font-size: small;
padding: 0.5rem;
display: flex;
align-items: center;
border: solid 1px #dcdcdc;
button {
margin-left: 0.3rem;
}
}
}
.fr-ds-combobox__menu {
&[data-placement=top] {
--origin: translateY(8px);
}
&[data-placement=bottom] {
--origin: translateY(-8px);
}
&[data-placement=right] {
--origin: translateX(-8px);
}
&[data-placement=left] {
--origin: translateX(8px);
}
&[data-entering] {
animation: popover-slide 200ms;
}
&.fr-menu {
width: var(--trigger-width);
top: unset;
background-color: white;
border: solid 1px #dcdcdc;
.fr-menu__list {
display: block;
width: unset;
max-height: 300px;
overflow: auto;
}
.fr-menu__item {
&[data-selected] {
font-weight: bold;
}
&[data-focused] {
font-weight: bold;
}
}
}
}
} }

View file

@ -9,41 +9,7 @@
margin-left: 16px; margin-left: 16px;
} }
[data-react-component-value^="ComboMultiple"] { .fr-ds-combobox__multiple {
margin-bottom: 0; margin-bottom: 0;
[data-reach-combobox-token-list] {
padding: 0.5 * $default-padding;
display: flex;
}
[data-reach-combobox-token] button {
border: solid 1px $border-grey;
margin-top: 0.5 * $default-padding;
margin-bottom: 0.5 * $default-padding;
margin-right: 0.5 * $default-padding;
border-radius: 4px;
padding: 0.5 * $default-padding;
cursor: pointer;
list-style: none;
}
[data-reach-combobox-token] button:focus {
background-color: $black;
color: $white;
}
[data-reach-combobox-input] {
outline: none;
border: none;
flex-grow: 1;
margin: 0.25rem;
}
[data-reach-combobox-input]:focus {
outline: solid;
outline-color: $light-blue;
}
} }
} }

View file

@ -45,45 +45,8 @@
display: inline-block; display: inline-block;
} }
[data-react-component-value^="ComboMultiple"] { .fr-ds-combobox__multiple {
margin-bottom: $default-fields-spacer; margin-bottom: $default-fields-spacer;
[data-reach-combobox-token-list] {
padding: 0.25 * $default-padding;
display: inline-block;
width: 100%;
}
[data-reach-combobox-token] button {
border: solid 1px $border-grey;
margin: 0.25 * $default-padding;
border-radius: 2px;
padding: 0.25 * $default-padding;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
}
[data-reach-combobox-token] button:focus {
background-color: $black;
color: $white;
}
[data-reach-combobox-input] {
outline: none;
flex-grow: 1;
margin: $default-spacer;
padding: $default-spacer;
border-radius: 4px;
border: solid 1px $border-grey;
margin-top: 0;
}
[data-reach-combobox-input]:focus {
border-color: $blue-france-500;
}
} }
// fix/dsfr // fix/dsfr

View file

@ -8,10 +8,6 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
attr_reader :procedure, :procedure_presentation, :statut, :field_id attr_reader :procedure, :procedure_presentation, :statut, :field_id
def filterable_fields_for_select
procedure_presentation.filterable_fields_options
end
def field_type def field_type
return :text if field_id.nil? return :text if field_id.nil?
procedure_presentation.field_type(field_id) procedure_presentation.field_type(field_id)
@ -20,4 +16,16 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
def options_for_select_of_field def options_for_select_of_field
procedure_presentation.field_enum(field_id) procedure_presentation.field_enum(field_id)
end end
def filter_react_props
{
selected_key: @field_id || '',
items: procedure_presentation.filterable_fields_options,
name: :field,
id: 'search-filter',
'aria-describedby': 'instructeur-filter-combo-label',
form: 'filter-component',
data: { no_autosubmit: 'input blur', no_autosubmit_on_empty: 'true', autosubmit_target: 'input' }
}
end
end end

View file

@ -1,11 +1,8 @@
= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do = form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do
.fr-select-group .fr-select-group
= label_tag :field, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter' = label_tag :field, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter'
= render Dsfr::ComboboxComponent.new form: nil, %react-fragment
options: filterable_fields_for_select, = render ReactComponent.new "ComboBox/SingleComboBox", **filter_react_props
selected: field_id,
input_html_options: { name: :field, id: 'search-filter', class: 'fr-select', describedby: 'instructeur-filter-combo-label', allows_custom_value: false, form_id: 'filter-component' },
hidden_html_options: { data: { no_autosubmit: ['input', 'blur'].join(' '), no_autosubmit_on_empty: "true", autosubmit_target: 'input' } }
%input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } } %input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } }

View file

@ -1,10 +0,0 @@
en:
sr:
results:
zero: No result
one: 1 result
other: "{count} results"
results_with_label:
one: "1 result. {label} is the top result press Enter to activate"
other: "{count} results. {label} is the top result press Enter to activate"
selected: "{label} selected"

View file

@ -1,10 +0,0 @@
fr:
sr:
results:
zero: Aucun résultat
one: 1 résultat
other: "{count} résultats"
results_with_label:
one: "1 résultat. {label} est le premier résultat appuyez sur Entrée pour sélectionner"
other: "{count} résultats. {label} est le premier résultat appuyez sur Entrée pour sélectionner"
selected: "{label} sélectionné"

View file

@ -1,14 +0,0 @@
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value, limit: limit } }
.fr-ds-combobox-input
%input{ value: selected_option_label_input_value, **html_input_options }
- if form
= form.hidden_field name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options
- else
%input{ type: 'hidden', name: name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options }
.fr-menu
%ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, url:, hints: hints_json }.compact }
.sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } }
%template
%li.fr-menu__item{ role: 'option' }
%slot{ name: 'label' }
= content

View file

@ -73,22 +73,26 @@ module Dsfr
} }
end end
def input_opts(other_opts = {}) def react_input_opts(other_opts = {})
input_opts(other_opts, true)
end
def input_opts(other_opts = {}, react = false)
@opts = @opts.deep_merge!(other_opts) @opts = @opts.deep_merge!(other_opts)
@opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class]) @opts[react ? :class_name : :class] = class_names(map_array_to_hash_with_true(@opts[:class])
.merge({ .merge({
'fr-password__input': password?, 'fr-password__input': password?,
'fr-input': true, 'fr-input': !react,
'fr-mb-0': true 'fr-mb-0': true
}.merge(input_error_class_names))) }.merge(input_error_class_names)))
if errors_on_attribute? if errors_on_attribute?
@opts.deep_merge!(aria: { describedby: describedby_id }) @opts.deep_merge!('aria-describedby': describedby_id)
elsif hintable? elsif hintable?
@opts.deep_merge!(aria: { describedby: hint_id }) @opts.deep_merge!('aria-describedby': hint_id)
end end
if @required if @required
@opts[:required] = true @opts[react ? :is_required : :required] = true
end end
if email? if email?

View file

@ -2,4 +2,15 @@ class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponen
def dsfr_input_classname def dsfr_input_classname
'fr-select' 'fr-select'
end end
def react_props
react_input_opts(id: @champ.input_id,
class: 'fr-mt-1w',
name: @form.field_name(:value),
selected_key: @champ.value,
items: @champ.selected_items,
loader: data_sources_data_source_adresse_path,
minimum_input_length: 2,
allows_custom_value: true)
end
end end

View file

@ -1,3 +1,3 @@
= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_adresse_path, selected: @champ.value, allows_custom_value: true, input_html_options: { name: :value, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do %react-fragment
= @form.hidden_field :external_id, data: { value_slot: 'value' } = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
= @form.hidden_field :feature, data: { value_slot: 'data' } = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: @form.field_name(:feature)

View file

@ -1,12 +1,15 @@
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
def dsfr_input_classname def dsfr_input_classname
'fr-input' 'fr-select'
end end
def react_input_opts def react_props
opts = input_opts(id: @champ.input_id, required: @champ.required?, aria: { describedby: @champ.describedby_id }) react_input_opts(id: @champ.input_id,
opts[:className] = "#{opts.delete(:class)} fr-mt-1w" class: "fr-mt-1w",
name: @form.field_name(:external_id),
opts selected_key: @champ.external_id,
items: @champ.selected_items,
loader: data_sources_data_source_education_path,
minimum_input_length: 3)
end end
end end

View file

@ -1,7 +1,3 @@
- render_parent %react-fragment
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
= @form.hidden_field :value = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :label, name: @form.field_name(:value)
= @form.hidden_field :external_id
= react_component("ComboAnnuaireEducationSearch",
**react_input_opts,
**react_combo_props)

View file

@ -4,10 +4,14 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
:fieldset :fieldset
end end
def initialize(**args) def react_props
super(**args) {
feature_collection: @champ.to_feature_collection,
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args) champ_id: @champ.input_id,
url: update_path,
adresse_source: data_sources_data_source_adresse_path,
options: @champ.render_options
}
end end
def update_path def update_path

View file

@ -1,14 +1,6 @@
.fr-fieldset__element .fr-fieldset__element
= render @autocomplete_component %react-fragment.width-100
= render ReactComponent.new "MapEditor", **react_props
= react_component("MapEditor",
{ featureCollection: @champ.to_feature_collection,
champId: @champ.input_id,
url: update_path,
options: @champ.render_options,
autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id,
autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions") },
{class: 'width-100'})
.geo-areas{ id: dom_id(@champ, :geo_areas) } .geo-areas{ id: dom_id(@champ, :geo_areas) }
= render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true) = render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true)

View file

@ -1,17 +0,0 @@
class EditableChamp::ComboSearchComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
def announce_template_id
@announce_template_id ||= dom_id(@champ, "aria-announce-template")
end
# NOTE: because this template is called by `render_parent` from a child template,
# as of ViewComponent 2.x translations virtual paths are not properly propagated
# and we can't use the usual component namespacing. Instead we use global translations.
def react_combo_props
{
screenReaderInstructions: t("combo_search_component.screen_reader_instructions"),
announceTemplateId: announce_template_id
}
end
end

View file

@ -1,4 +0,0 @@
%template{ id: announce_template_id }
%slot{ "name": "0" }= t("combo_search_component.result_slot_html", count: 0)
%slot{ "name": "1" }= t("combo_search_component.result_slot_html", count: 1)
%slot{ "name": "many" }= t("combo_search_component.result_slot_html", count: 2)

View file

@ -4,4 +4,15 @@ class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseCompone
def dsfr_input_classname def dsfr_input_classname
'fr-select' 'fr-select'
end end
def react_props
react_input_opts(id: @champ.input_id,
class: 'fr-mt-1w',
name: @form.field_name(:code),
selected_key: @champ.selected,
items: @champ.selected_items,
loader: data_sources_data_source_commune_path(with_combined_code: true),
limit: 20,
minimum_input_length: 2)
end
end end

View file

@ -1,2 +1,2 @@
= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_commune_path, selected: [@champ.to_s, @champ.selected], limit: 20, input_html_options: { name: :external_id, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do %react-fragment
= @form.hidden_field :code_postal, data: { value_slot: 'data:string' } = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props

View file

@ -23,4 +23,13 @@ class EditableChamp::DropDownListComponent < EditableChamp::EditableChampBaseCom
max_length = 100 max_length = 100
@champ.enabled_non_empty_options.any? { _1.size > max_length } @champ.enabled_non_empty_options.any? { _1.size > max_length }
end end
def react_props
react_input_opts(id: @champ.input_id,
class: 'fr-mt-1w',
name: @form.field_name(:value),
selected_key: @champ.selected,
items: @champ.enabled_non_empty_options(other: true).map { _1.is_a?(Array) ? _1 : [_1, _1] },
empty_filter_key: @champ.drop_down_other? ? Champs::DropDownListChamp::OTHER : nil)
end
end end

View file

@ -18,7 +18,8 @@
%label.fr-label{ for: dom_id(@champ, "radio_option_other") } %label.fr-label{ for: dom_id(@champ, "radio_option_other") }
= t('shared.champs.drop_down_list.other') = t('shared.champs.drop_down_list.other')
- elsif @champ.render_as_combobox? - elsif @champ.render_as_combobox?
= render Dsfr::ComboboxComponent.new form: @form, options: @champ.enabled_non_empty_options(other: true), selected: @champ.selected, input_html_options: { name: :value, id: @champ.input_id, class: select_class_names, describedby: @champ.describedby_id } %react-fragment
= render ReactComponent.new "ComboBox/SingleComboBox", **react_props
- else - else
= @form.select :value, = @form.select :value,
@champ.enabled_non_empty_options(other: true), @champ.enabled_non_empty_options(other: true),

View file

@ -14,6 +14,20 @@ class Procedure::ChorusFormComponent < ApplicationComponent
} }
end end
def selected_key(attribute_name)
items(attribute_name).first&.dig(:value)
end
def items(attribute_name)
label = format_displayed_value(attribute_name)
data = format_hidden_value(attribute_name)
if label.present?
[{ label:, value: label, data: }]
else
[]
end
end
def format_displayed_value(attribute_name) def format_displayed_value(attribute_name)
case attribute_name case attribute_name
when :centre_de_cout when :centre_de_cout
@ -30,13 +44,23 @@ class Procedure::ChorusFormComponent < ApplicationComponent
def format_hidden_value(attribute_name) def format_hidden_value(attribute_name)
case attribute_name case attribute_name
when :centre_de_cout when :centre_de_cout
@chorus_configuration.centre_de_cout.to_json @chorus_configuration.centre_de_cout
when :domaine_fonctionnel when :domaine_fonctionnel
@chorus_configuration.domaine_fonctionnel.to_json @chorus_configuration.domaine_fonctionnel
when :referentiel_de_programmation when :referentiel_de_programmation
@chorus_configuration.referentiel_de_programmation.to_json @chorus_configuration.referentiel_de_programmation
else else
raise 'unknown attribute_name' raise 'unknown attribute_name'
end end
end end
def react_props(name, chorus_configuration_attribute, datasource_endpoint)
{
name:,
selected_key: selected_key(chorus_configuration_attribute),
items: items(chorus_configuration_attribute),
loader: datasource_endpoint,
id: chorus_configuration_attribute
}
end
end end

View file

@ -1,12 +1,10 @@
= form_for([procedure, @chorus_configuration],url: admin_procedure_chorus_path(procedure), method: :put) do |f| = form_for([procedure, @chorus_configuration], url: admin_procedure_chorus_path(procedure), method: :put) do |f|
- map_attribute_to_autocomplete_endpoint.map do |chorus_configuration_attribute, datasource_endpoint| - map_attribute_to_autocomplete_endpoint.map do |chorus_configuration_attribute, datasource_endpoint|
- label_id = "#{chorus_configuration_attribute}-label" - label_id = "#{chorus_configuration_attribute}-label"
.fr-select-group .fr-select-group
= f.label chorus_configuration_attribute, class: 'fr-label', id: label_id = f.label chorus_configuration_attribute, class: 'fr-label', id: label_id, for: chorus_configuration_attribute
= render Dsfr::ComboboxComponent.new form: f, %react-fragment
url: datasource_endpoint, = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props(f.field_name(:chorus_configuration_attribute), chorus_configuration_attribute, datasource_endpoint) do
selected: format_displayed_value(chorus_configuration_attribute), = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: f.field_name(chorus_configuration_attribute)
input_html_options: { id: chorus_configuration_attribute, class: 'fr-select', describedby: label_id, name: :chorus_configuration_attribute } do
= f.hidden_field chorus_configuration_attribute, data: { value_slot: 'data' }, value: format_hidden_value(chorus_configuration_attribute)
= f.submit "Enregister", class: 'fr-btn' = f.submit "Enregister", class: 'fr-btn'

View file

@ -0,0 +1,14 @@
class ReactComponent < ApplicationComponent
erb_template <<-ERB
<% if content? %>
<react-component name=<%= @name %> props="<%= @props.to_json %>"><%= content %></react-component>
<% else %>
<react-component name=<%= @name %> props="<%= @props.to_json %>"></react-component>
<% end %>
ERB
def initialize(name, **props)
@name = name
@props = props
end
end

View file

@ -9,8 +9,8 @@ module Administrateurs
end end
def create def create
emails = params['emails'].presence || [].to_json emails = params['emails'].presence || []
emails = JSON.parse(emails).map { EmailSanitizer.sanitize(_1) } emails = emails.map { EmailSanitizer.sanitize(_1) }
@maybe_typos, no_suggestions = emails @maybe_typos, no_suggestions = emails
.map { |email| [email, EmailChecker.check(email:)[:suggestions]&.first] } .map { |email| [email, EmailChecker.check(email:)[:suggestions]&.first] }
.partition { _1[1].present? } .partition { _1[1].present? }

View file

@ -218,8 +218,8 @@ module Administrateurs
end end
def add_instructeur def add_instructeur
emails = params['emails'].presence || [].to_json emails = params[:emails].presence || []
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(emails:) instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(emails:)

View file

@ -527,11 +527,10 @@ module Administrateurs
:accuse_lecture, :accuse_lecture,
:api_entreprise_token, :api_entreprise_token,
:duree_conservation_dossiers_dans_ds, :duree_conservation_dossiers_dans_ds,
{ zone_ids: [] },
:lien_dpo, :lien_dpo,
:opendata, :opendata,
:procedure_expires_when_termine_enabled, :procedure_expires_when_termine_enabled,
:tags { zone_ids: [], tags: [] }
] ]
editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple? editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple?
@ -544,9 +543,6 @@ module Administrateurs
if permited_params[:auto_archive_on].present? if permited_params[:auto_archive_on].present?
permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day
end end
if permited_params[:tags].present?
permited_params[:tags] = JSON.parse(permited_params[:tags])
end
permited_params permited_params
end end

View file

@ -4,7 +4,7 @@ module CreateAvisConcern
private private
def create_avis_from_params(dossier, instructeur_or_expert, confidentiel = false) def create_avis_from_params(dossier, instructeur_or_expert, confidentiel = false)
if create_avis_params[:emails].empty? if create_avis_params[:emails].blank?
avis = Avis.new(create_avis_params) avis = Avis.new(create_avis_params)
errors = avis.errors errors = avis.errors
errors.add(:emails, :blank) errors.add(:emails, :blank)
@ -19,8 +19,8 @@ module CreateAvisConcern
# the :emails parameter is a 1-element array. # the :emails parameter is a 1-element array.
# Hence the call to first # Hence the call to first
# https://github.com/rails/rails/issues/17225 # https://github.com/rails/rails/issues/17225
expert_emails = create_avis_params[:emails].presence || [].to_json expert_emails = create_avis_params[:emails].presence || []
expert_emails = JSON.parse(expert_emails).map(&:strip).map(&:downcase) expert_emails = expert_emails.map(&:strip).map(&:downcase)
allowed_dossiers = [dossier] allowed_dossiers = [dossier]
if create_avis_params[:invite_linked_dossiers].present? if create_avis_params[:invite_linked_dossiers].present?
@ -84,6 +84,6 @@ module CreateAvisConcern
end end
def create_avis_params def create_avis_params
params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :emails, :question_label) params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :question_label, emails: [])
end end
end end

View file

@ -61,11 +61,18 @@ class DataSources::CommuneController < ApplicationController
else else
[item] [item]
end.map do |item| end.map do |item|
{ if params[:with_combined_code].present?
label: "#{item[:name]} (#{item[:postal_code]})", {
value: item[:code], label: "#{item[:name]} (#{item[:postal_code]})",
data: item[:postal_code] value: "#{item[:code]}-#{item[:postal_code]}"
} }
else
{
label: "#{item[:name]} (#{item[:postal_code]})",
value: item[:code],
data: item[:postal_code]
}
end
end end
end end
end end

View file

@ -0,0 +1,38 @@
class DataSources::EducationController < ApplicationController
def search
if params[:q].present? && params[:q].length >= 3
response = fetch_results
if response.success?
results = JSON.parse(response.body, symbolize_names: true)
return render json: format_results(results)
end
end
render json: []
rescue JSON::ParserError => e
Sentry.set_extras(body: response.body, code: response.code)
Sentry.capture_exception(e)
render json: []
end
private
def fetch_results
Typhoeus.get("#{API_EDUCATION_URL}/search", params: { q: params[:q], rows: 5, dataset: 'fr-en-annuaire-education' }, timeout: 3)
end
def format_results(results)
results[:records].map do |record|
fields = record.fetch(:fields)
value = fields.fetch(:identifiant_de_l_etablissement)
{
label: "#{fields.fetch(:nom_etablissement)}, #{fields.fetch(:nom_commune)} (#{value})",
value:,
data: record
}
end
end
end

View file

@ -6,8 +6,8 @@ module Gestionnaires
end end
def create def create
emails = [params.require(:administrateur)[:email]].to_json emails = [params.require(:administrateur)[:email]].compact
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
administrateurs_to_add, valid_emails, invalid_emails = Administrateur.find_all_by_identifier_with_emails(emails:) administrateurs_to_add, valid_emails, invalid_emails = Administrateur.find_all_by_identifier_with_emails(emails:)
not_found_emails = valid_emails - administrateurs_to_add.map(&:email) not_found_emails = valid_emails - administrateurs_to_add.map(&:email)

View file

@ -6,8 +6,8 @@ module Gestionnaires
end end
def create def create
emails = [params.require(:gestionnaire)[:email]].to_json emails = [params.require(:gestionnaire)[:email]].compact
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:) gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email) not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)

View file

@ -86,9 +86,9 @@ module Instructeurs
end end
def send_to_instructeurs def send_to_instructeurs
recipients = params['recipients'].presence || [].to_json recipients = params['recipients'].presence || []
# instructeurs are scoped by groupe_instructeur to avoid enumeration # instructeurs are scoped by groupe_instructeur to avoid enumeration
recipients = dossier.groupe_instructeur.instructeurs.where(id: JSON.parse(recipients)) recipients = dossier.groupe_instructeur.instructeurs.where(id: recipients)
if recipients.present? if recipients.present?
recipients.each do |recipient| recipients.each do |recipient|
@ -401,6 +401,7 @@ module Instructeurs
:value, :value,
:value_other, :value_other,
:external_id, :external_id,
:code,
:primary_value, :primary_value,
:secondary_value, :secondary_value,
:numero_allocataire, :numero_allocataire,

View file

@ -73,7 +73,6 @@ module Instructeurs
@current_filters = current_filters @current_filters = current_filters
@displayable_fields_for_select, @displayable_fields_selected = procedure_presentation.displayable_fields_for_select @displayable_fields_for_select, @displayable_fields_selected = procedure_presentation.displayable_fields_for_select
@filterable_fields_for_select = procedure_presentation.filterable_fields_options
@counts = current_instructeur @counts = current_instructeur
.dossiers_count_summary(groupe_instructeur_ids) .dossiers_count_summary(groupe_instructeur_ids)
.symbolize_keys .symbolize_keys
@ -135,8 +134,8 @@ module Instructeurs
end end
def update_displayed_fields def update_displayed_fields
values = params['values'].presence || [].to_json values = params['values'].presence || []
procedure_presentation.update_displayed_fields(JSON.parse(values)) procedure_presentation.update_displayed_fields(values)
redirect_back(fallback_location: instructeur_procedure_url(procedure)) redirect_back(fallback_location: instructeur_procedure_url(procedure))
end end

View file

@ -2,8 +2,8 @@ module Manager
class GroupeGestionnairesController < Manager::ApplicationController class GroupeGestionnairesController < Manager::ApplicationController
def add_gestionnaire def add_gestionnaire
groupe_gestionnaire = GroupeGestionnaire.find(params[:id]) groupe_gestionnaire = GroupeGestionnaire.find(params[:id])
emails = [params['emails'].presence || ''].to_json emails = [params['emails']].compact
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:) gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email) not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)

View file

@ -111,8 +111,7 @@ module Manager
end end
def add_tags def add_tags
tags_h = { tags: JSON.parse(tags_params[:tags]) } if procedure.update(tags: tags_params[:tags])
if procedure.update(tags_h)
flash.notice = "Le modèle est mis à jour." flash.notice = "Le modèle est mis à jour."
else else
flash.alert = procedure.errors.full_messages.join(', ') flash.alert = procedure.errors.full_messages.join(', ')
@ -181,7 +180,7 @@ module Manager
end end
def tags_params def tags_params
params.require(:procedure).permit(:tags) params.require(:procedure).permit(tags: [])
end end
def template_params def template_params

View file

@ -494,6 +494,7 @@ module Users
:value, :value,
:value_other, :value_other,
:external_id, :external_id,
:code,
:primary_value, :primary_value,
:secondary_value, :secondary_value,
:numero_allocataire, :numero_allocataire,

View file

@ -59,10 +59,6 @@ module ApplicationHelper
'alert' 'alert'
end end
def react_component(name, props = {}, html = {})
tag.div(**html.merge(data: { controller: 'react', react_component_value: name, react_props_value: props.to_json }))
end
def current_email def current_email
current_user&.email || current_user&.email ||
current_instructeur&.email || current_instructeur&.email ||

View file

@ -1,32 +0,0 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import type { FeatureCollection, Geometry } from 'geojson';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
type RawResult = FeatureCollection<Geometry, { label: string }>;
type AdresseResult = RawResult['features'][0];
type ComboAdresseSearchProps = Omit<
ComboSearchProps<AdresseResult>,
'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope'
>;
export default function ComboAdresseSearch({
allowInputValues = true,
...props
}: ComboAdresseSearchProps) {
return (
<QueryClientProvider client={queryClient}>
<ComboSearch<AdresseResult>
{...props}
allowInputValues={allowInputValues}
scope="adresse"
minimumInputLength={2}
transformResult={({ properties: { label } }) => [label, label, label]}
transformResults={(_, result) => (result as RawResult).features}
debounceDelay={300}
/>
</QueryClientProvider>
);
}

View file

@ -1,40 +0,0 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
type AnnuaireEducationResult = {
fields: {
identifiant_de_l_etablissement: string;
nom_etablissement: string;
nom_commune: string;
};
};
function transformResults(_: unknown, result: unknown) {
const results = result as { records: AnnuaireEducationResult[] };
return results.records as AnnuaireEducationResult[];
}
export default function ComboAnnuaireEducationSearch(
props: ComboSearchProps<AnnuaireEducationResult>
) {
return (
<QueryClientProvider client={queryClient}>
<ComboSearch
{...props}
scope="annuaire-education"
minimumInputLength={3}
transformResults={transformResults}
transformResult={({
fields: {
identifiant_de_l_etablissement: id,
nom_etablissement,
nom_commune
}
}) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]}
/>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,346 @@
import type { ListBoxItemProps } from 'react-aria-components';
import {
ComboBox as AriaComboBox,
ListBox,
ListBoxItem,
Popover,
Input,
Label,
Text,
Button,
TagGroup,
TagList,
Tag
} from 'react-aria-components';
import { useMemo, useRef, createContext, useContext } from 'react';
import type { RefObject } from 'react';
import { findOrCreateContainerElement } from '@coldwired/react';
import {
useLabelledBy,
useDispatchChangeEvent,
useMultiList,
useSingleList,
useRemoteList,
useOnFormReset,
createLoader,
type ComboBoxProps
} from './react-aria/hooks';
import {
type Item,
SingleComboBoxProps,
MultiComboBoxProps,
RemoteComboBoxProps
} from './react-aria/props';
const getPortal = () => findOrCreateContainerElement('rac-portal');
export function ComboBox({
children,
label,
description,
className,
inputRef,
...props
}: ComboBoxProps & { inputRef?: RefObject<HTMLInputElement> }) {
return (
<AriaComboBox
{...props}
className={`fr-ds-combobox ${className ?? ''}`}
shouldFocusWrap={true}
>
{label ? <Label className="fr-label">{label}</Label> : null}
{description ? (
<Text slot="description" className="fr-hint-text">
{description}
</Text>
) : null}
<div className="fr-ds-combobox__input" style={{ position: 'relative' }}>
<Input className="fr-select fr-autocomplete" ref={inputRef} />
<Button
style={{
width: '40px',
height: '100%',
position: 'absolute',
opacity: 0,
right: 0,
top: 0
}}
>
{' '}
</Button>
</div>
<Popover
className="fr-ds-combobox__menu fr-menu"
UNSTABLE_portalContainer={getPortal()!}
>
<ListBox className="fr-menu__list">{children}</ListBox>
</Popover>
</AriaComboBox>
);
}
export function ComboBoxItem(props: ListBoxItemProps<Item>) {
return <ListBoxItem {...props} className="fr-menu__item" />;
}
export function SingleComboBox({
children,
...maybeProps
}: SingleComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
emptyFilterKey,
name,
formValue,
form,
data,
...props
} = useMemo(() => SingleComboBoxProps.parse(maybeProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const { selectedItem, onReset, ...comboBoxProps } = useSingleList({
defaultItems,
defaultSelectedKey,
emptyFilterKey,
onChange: dispatch
});
return (
<>
<ComboBox aria-labelledby={labelledby} {...comboBoxProps} {...props}>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={formValue == 'text' ? 'label' : 'value'}
name={name}
form={form}
onReset={onReset}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function MultiComboBox(maybeProps: MultiComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKeys: defaultSelectedKeys,
name,
form,
formValue,
allowsCustomValue,
valueSeparator,
...props
} = useMemo(() => MultiComboBoxProps.parse(maybeProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const inputRef = useRef<HTMLInputElement>(null);
const {
selectedItems,
hiddenInputValues,
onRemove,
onReset,
...comboBoxProps
} = useMultiList({
defaultItems,
defaultSelectedKeys,
onChange: dispatch,
formValue,
allowsCustomValue,
valueSeparator,
focusInput: () => {
inputRef.current?.focus();
}
});
const formResetRef = useOnFormReset(onReset);
return (
<div className="fr-ds-combobox__multiple">
{selectedItems.length > 0 ? (
<TagGroup onRemove={onRemove} aria-label={props['aria-label']}>
<TagList items={selectedItems} className="fr-tag-list">
{selectedItems.map((item) => (
<Tag
key={item.value}
id={item.value}
textValue={`Retirer ${item.label}`}
className="fr-tag fr-tag--sm"
>
{item.label}
<Button slot="remove" className="fr-tag--dismiss"></Button>
</Tag>
))}
</TagList>
</TagGroup>
) : null}
<ComboBox
aria-labelledby={labelledby}
allowsCustomValue={allowsCustomValue}
inputRef={inputRef}
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{name ? (
<span ref={ref}>
{hiddenInputValues.map((value, i) => (
<input
type="hidden"
value={value}
name={name}
form={form}
ref={i == 0 ? formResetRef : undefined}
key={value}
/>
))}
</span>
) : null}
</div>
);
}
export function RemoteComboBox({
loader,
onChange,
children,
...maybeProps
}: RemoteComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
allowsCustomValue,
minimumInputLength,
limit,
formValue,
name,
form,
data,
...props
} = useMemo(() => RemoteComboBoxProps.parse(maybeProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const load = useMemo(
() =>
typeof loader == 'string'
? createLoader(loader, { minimumInputLength, limit })
: loader,
[loader, minimumInputLength, limit]
);
const { selectedItem, onReset, ...comboBoxProps } = useRemoteList({
allowsCustomValue,
defaultItems,
defaultSelectedKey,
load,
onChange: (item) => {
onChange?.(item);
dispatch();
}
});
return (
<>
<ComboBox
allowsEmptyCollection={comboBoxProps.inputValue.length > 0}
allowsCustomValue={allowsCustomValue}
aria-labelledby={labelledby}
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={
formValue == 'text' || allowsCustomValue ? 'label' : 'value'
}
name={name}
form={form}
onReset={onReset}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function ComboBoxValueSlot({
field,
name,
form,
onReset,
data
}: {
field: 'label' | 'value' | 'data';
name: string;
form?: string;
onReset?: () => void;
data?: Record<string, string>;
}) {
const selectedItem = useContext(SelectedItemContext);
const value = getSelectedValue(selectedItem, field);
const dataProps = Object.fromEntries(
Object.entries(data ?? {}).map(([key, value]) => [
`data-${key.replace(/_/g, '-')}`,
value
])
);
const ref = useOnFormReset(onReset);
return (
<input
ref={onReset ? ref : undefined}
type="hidden"
name={name}
value={value}
form={form}
{...dataProps}
/>
);
}
const SelectedItemContext = createContext<Item | null>(null);
const SelectedItemProvider = SelectedItemContext.Provider;
function getSelectedValue(
selectedItem: Item | null,
field: 'label' | 'value' | 'data'
): string {
if (selectedItem == null) {
return '';
} else if (field == 'data') {
if (typeof selectedItem.data == 'string') {
return selectedItem.data;
} else if (!selectedItem.data) {
return '';
}
return JSON.stringify(selectedItem.data);
}
return selectedItem[field];
}

View file

@ -1,374 +0,0 @@
import React, {
useMemo,
useState,
useRef,
useContext,
createContext,
useId,
ReactNode,
ChangeEventHandler,
KeyboardEventHandler
} from 'react';
import {
Combobox,
ComboboxInput,
ComboboxList,
ComboboxOption,
ComboboxPopover
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import { matchSorter } from 'match-sorter';
import { XIcon } from '@heroicons/react/outline';
import isHotkey from 'is-hotkey';
import invariant from 'tiny-invariant';
import { useDeferredSubmit, useHiddenField } from './shared/hooks';
const Context = createContext<{
onRemove: (value: string) => void;
} | null>(null);
type Option = [label: string, value: string];
function isOptions(options: string[] | Option[]): options is Option[] {
return Array.isArray(options[0]);
}
const optionLabelByValue = (
values: string[],
options: Option[],
value: string
): string => {
const maybeOption: Option | undefined = values.includes(value)
? [value, value]
: options.find(([, optionValue]) => optionValue == value);
return maybeOption ? maybeOption[0] : '';
};
export type ComboMultipleProps = {
options: string[] | Option[];
id: string;
labelledby: string;
describedby: string;
label: string;
group: string;
name?: string;
selected: string[];
acceptNewValues?: boolean;
};
export default function ComboMultiple({
options,
id,
labelledby,
describedby,
label,
group,
name = 'value',
selected,
acceptNewValues = false
}: ComboMultipleProps) {
invariant(id || label, 'ComboMultiple: `id` or a `label` are required');
invariant(group, 'ComboMultiple: `group` is required');
const inputRef = useRef<HTMLInputElement>(null);
const [term, setTerm] = useState('');
const [selections, setSelections] = useState(selected);
const [newValues, setNewValues] = useState<string[]>([]);
const internalId = useId();
const inputId = id ?? internalId;
const removedLabelledby = `${inputId}-remove`;
const selectedLabelledby = `${inputId}-selected`;
const optionsWithLabels = useMemo<Option[]>(
() =>
isOptions(options)
? options
: options.filter((o) => o).map((o) => [o, o]),
[options]
);
const extraOptions = useMemo(
() =>
acceptNewValues &&
term &&
term.length > 2 &&
!optionLabelByValue(newValues, optionsWithLabels, term)
? [[term, term]]
: [],
[acceptNewValues, term, optionsWithLabels, newValues]
);
const extraListOptions = useMemo(
() =>
acceptNewValues && term && term.length > 2 && term.includes(';')
? term.split(';').map((val) => [val.trim(), val.trim()])
: [],
[acceptNewValues, term]
);
const results = useMemo(
() =>
[
...extraOptions,
...(term
? matchSorter(
optionsWithLabels.filter(([label]) => !label.startsWith('--')),
term
)
: optionsWithLabels)
].filter(([, value]) => !selections.includes(value)),
[term, selections, extraOptions, optionsWithLabels]
);
const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name);
const awaitFormSubmit = useDeferredSubmit(hiddenField);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setTerm(event.target.value);
};
const saveSelection = (fn: (selections: string[]) => string[]) => {
setSelections((selections) => {
selections = fn(selections);
setHiddenFieldValue(JSON.stringify(selections));
return selections;
});
};
const onSelect = (value: string) => {
const maybeValue = [...extraOptions, ...optionsWithLabels].find(
([, val]) => val == value
);
const maybeValueFromListOptions = extraListOptions.find(
([, val]) => val == value
);
const selectedValue =
term.includes(';') && acceptNewValues
? maybeValueFromListOptions && maybeValueFromListOptions[1]
: maybeValue && maybeValue[1];
if (selectedValue) {
if (
(acceptNewValues &&
extraOptions[0] &&
extraOptions[0][0] == selectedValue) ||
(acceptNewValues && extraListOptions[0])
) {
setNewValues((newValues) => {
const set = new Set(newValues);
set.add(selectedValue);
return [...set];
});
}
saveSelection((selections) => {
const set = new Set(selections);
set.add(selectedValue);
return [...set];
});
}
setTerm('');
awaitFormSubmit.done();
hidePopover();
};
const onRemove = (optionValue: string) => {
if (optionValue) {
saveSelection((selections) =>
selections.filter((value) => value != optionValue)
);
setNewValues((newValues) =>
newValues.filter((value) => value != optionValue)
);
}
inputRef.current?.focus();
};
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (
isHotkey('enter', event) ||
isHotkey(' ', event) ||
isHotkey(',', event) ||
isHotkey(';', event)
) {
if (term.includes(';')) {
for (const val of term.split(';')) {
event.preventDefault();
onSelect(val.trim());
}
} else if (
term &&
[...extraOptions, ...optionsWithLabels]
.map(([label]) => label)
.includes(term)
) {
event.preventDefault();
onSelect(term);
}
}
};
const hidePopover = () => {
document
.querySelector(`[data-reach-combobox-popover-id="${inputId}"]`)
?.setAttribute('hidden', 'true');
};
const showPopover = () => {
document
.querySelector(`[data-reach-combobox-popover-id="${inputId}"]`)
?.removeAttribute('hidden');
};
const onBlur = () => {
const shouldSelect =
term &&
[...extraOptions, ...optionsWithLabels]
.map(([label]) => label)
.includes(term);
awaitFormSubmit(() => {
if (term.includes(';')) {
for (const val of term.split(';')) {
onSelect(val.trim());
}
} else if (shouldSelect) {
onSelect(term);
} else {
hidePopover();
}
});
};
return (
<Combobox openOnFocus={true} onSelect={onSelect}>
<ComboboxTokenLabel onRemove={onRemove}>
<span id={removedLabelledby} className="hidden">
désélectionner
</span>
<ul
id={selectedLabelledby}
aria-live="polite"
aria-atomic={true}
data-reach-combobox-token-list
>
{selections.map((selection) => (
<ComboboxToken
key={selection}
value={selection}
describedby={removedLabelledby}
>
{optionLabelByValue(newValues, optionsWithLabels, selection)}
</ComboboxToken>
))}
</ul>
<ComboboxInput
ref={inputRef}
value={term}
onChange={handleChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
onClick={showPopover}
autocomplete={false}
id={inputId}
aria-label={label}
aria-labelledby={[labelledby, selectedLabelledby]
.filter(Boolean)
.join(' ')}
aria-describedby={describedby}
/>
</ComboboxTokenLabel>
{results && (results.length > 0 || !acceptNewValues) && (
<ComboboxPopover
className="shadow-popup"
data-reach-combobox-popover-id={inputId}
>
<ComboboxList>
{results.length === 0 && (
<li data-reach-combobox-no-results>
Aucun résultat{' '}
<button
onClick={() => {
setTerm('');
inputRef.current?.focus();
}}
className="button"
>
Effacer
</button>
</li>
)}
{results.map(([label, value], index) => {
if (label.startsWith('--')) {
return <ComboboxSeparator key={index} value={label} />;
}
return (
<ComboboxOption key={index} value={value}>
{label}
</ComboboxOption>
);
})}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
);
}
function ComboboxTokenLabel({
onRemove,
children
}: {
onRemove: (value: string) => void;
children: ReactNode;
}) {
return (
<Context.Provider value={{ onRemove }}>
<div data-reach-combobox-token-label>{children}</div>
</Context.Provider>
);
}
function ComboboxSeparator({ value }: { value: string }) {
return (
<li aria-disabled="true" role="option" data-reach-combobox-separator>
{value.slice(2, -2)}
</li>
);
}
function ComboboxToken({
value,
describedby,
children,
...props
}: {
value: string;
describedby: string;
children: ReactNode;
}) {
const context = useContext(Context);
invariant(context, 'invalid context');
const { onRemove } = context;
return (
<li data-reach-combobox-token {...props}>
<button
type="button"
onClick={() => {
onRemove(value);
}}
onKeyDown={(event) => {
if (event.key === 'Backspace') {
onRemove(value);
}
}}
aria-describedby={describedby}
>
<XIcon className="icon-size mr-1" aria-hidden="true" />
{children}
</button>
</li>
);
}

View file

@ -1,222 +0,0 @@
import React, {
useState,
useEffect,
useRef,
useId,
ChangeEventHandler
} from 'react';
import { useDebounce } from 'use-debounce';
import { useQuery } from 'react-query';
import {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import invariant from 'tiny-invariant';
import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
type TransformResults<Result> = (term: string, results: unknown) => Result[];
type TransformResult<Result> = (
result: Result
) => [key: string, value: string, label?: string];
export type ComboSearchProps<Result = unknown> = {
onChange?: (value: string | null, result?: Result) => void;
value?: string;
scope: string;
scopeExtra?: string;
minimumInputLength: number;
transformResults?: TransformResults<Result>;
transformResult: TransformResult<Result>;
allowInputValues?: boolean;
id?: string;
describedby?: string;
className?: string;
placeholder?: string;
debounceDelay?: number;
screenReaderInstructions: string;
announceTemplateId: string;
};
type QueryKey = readonly [
scope: string,
term: string,
extra: string | undefined
];
function ComboSearch<Result>({
onChange,
value: controlledValue,
scope,
scopeExtra,
minimumInputLength,
transformResult,
allowInputValues = false,
transformResults = (_, results) => results as Result[],
id,
describedby,
screenReaderInstructions,
announceTemplateId,
debounceDelay = 0,
...props
}: ComboSearchProps<Result>) {
invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required');
const group = !onChange && id ? groupId(id) : undefined;
const [externalValue, setExternalValue, hiddenField] = useHiddenField(group);
const [, setExternalId] = useHiddenField(group, 'external_id');
const initialValue = externalValue ? externalValue : controlledValue;
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebounce(searchTerm, debounceDelay);
const [value, setValue] = useState(initialValue);
const resultsMap = useRef<
Record<string, { key: string; value: string; result: Result }>
>({});
const getLabel = (result: Result) => {
const [, value, label] = transformResult(result);
return label ?? value;
};
const setExternalValueAndId = (label: string) => {
const { key, value, result } = resultsMap.current[label];
if (onChange) {
onChange(value, result);
} else {
setExternalId(key);
setExternalValue(value);
}
};
const awaitFormSubmit = useDeferredSubmit(hiddenField);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setValue(value);
if (!value) {
if (onChange) {
onChange(null);
} else {
setExternalId('');
setExternalValue('');
}
} else if (value.length >= minimumInputLength) {
setSearchTerm(value.trim());
if (allowInputValues) {
setExternalId('');
setExternalValue(value);
}
}
};
const handleOnSelect = (value: string) => {
setExternalValueAndId(value);
setValue(value);
setSearchTerm('');
awaitFormSubmit.done();
};
const { isSuccess, data } = useQuery<void, void, unknown, QueryKey>(
[scope, debouncedSearchTerm, scopeExtra],
{
enabled: !!debouncedSearchTerm,
refetchOnMount: false
}
);
const results =
isSuccess && data ? transformResults(debouncedSearchTerm, data) : [];
const onBlur = () => {
if (!allowInputValues && isSuccess && results[0]) {
const label = getLabel(results[0]);
awaitFormSubmit(() => {
handleOnSelect(label);
});
}
};
const [announceLive, setAnnounceLive] = useState('');
const announceTimeout = useRef<ReturnType<typeof setTimeout>>();
const announceTemplate = document.querySelector<HTMLTemplateElement>(
`#${announceTemplateId}`
);
invariant(announceTemplate, `Missing #${announceTemplateId}`);
const announceFragment = useRef(
announceTemplate.content.cloneNode(true) as DocumentFragment
).current;
useEffect(() => {
if (isSuccess) {
const slot = announceFragment.querySelector<HTMLSlotElement>(
'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]'
);
if (!slot) {
return;
}
const countSlot =
slot.querySelector<HTMLSlotElement>('slot[name="count"]');
if (countSlot) {
countSlot.replaceWith(String(results.length));
}
setAnnounceLive(slot.textContent ?? '');
}
announceTimeout.current = setTimeout(() => {
setAnnounceLive('');
}, 3000);
return () => clearTimeout(announceTimeout.current);
}, [announceFragment, results.length, isSuccess]);
const initInstrId = useId();
const resultsId = useId();
return (
<Combobox onSelect={handleOnSelect}>
<ComboboxInput
{...props}
onChange={handleOnChange}
onBlur={onBlur}
value={value ?? ''}
autocomplete={false}
id={id}
aria-describedby={describedby ?? initInstrId}
aria-owns={resultsId}
/>
{isSuccess && (
<ComboboxPopover id={resultsId} className="shadow-popup">
{results.length > 0 ? (
<ComboboxList>
{results.map((result, index) => {
const label = getLabel(result);
const [key, value] = transformResult(result);
resultsMap.current[label] = { key, value, result };
return <ComboboxOption key={`${key}-${index}`} value={label} />;
})}
</ComboboxList>
) : (
<span style={{ display: 'block', margin: 8 }}>
Aucun résultat trouvé
</span>
)}
</ComboboxPopover>
)}
{!describedby && (
<span id={initInstrId} className="hidden">
{screenReaderInstructions}
</span>
)}
<div aria-live="assertive" className="sr-only">
{announceLive}
</div>
</Combobox>
);
}
export default ComboSearch;

View file

@ -0,0 +1,12 @@
import { I18nProvider } from 'react-aria-components';
import { StrictMode, type ReactNode } from 'react';
export function Layout({ children }: { children: ReactNode }) {
const locale = document.documentElement.lang;
console.debug(`locale: ${locale}`);
return (
<I18nProvider locale={locale}>
<StrictMode>{children}</StrictMode>
</I18nProvider>
);
}

View file

@ -1,33 +1,33 @@
import React from 'react';
import { fire } from '@utils'; import { fire } from '@utils';
import type { FeatureCollection } from 'geojson'; import type { FeatureCollection } from 'geojson';
import ComboAdresseSearch from '../../ComboAdresseSearch'; import { RemoteComboBox } from '../../ComboBox';
import { ComboSearchProps } from '~/components/ComboSearch';
export function AddressInput( export function AddressInput({
comboProps: Pick< source,
ComboSearchProps, featureCollection,
'screenReaderInstructions' | 'announceTemplateId' champId
> & { featureCollection: FeatureCollection; champId: string } }: {
) { source: string;
featureCollection: FeatureCollection;
champId: string;
}) {
return ( return (
<div <div style={{ marginBottom: '10px' }}>
style={{ <RemoteComboBox
marginBottom: '10px' minimumInputLength={2}
}} id={champId}
> loader={source}
<ComboAdresseSearch label="Rechercher une Adresse"
className="fr-input fr-mt-1w" description="Saisissez au moins 2 caractères"
allowInputValues={false} onChange={(item) => {
id={comboProps.champId} if (item && item.data) {
onChange={(_, feature) => { fire(document, 'map:zoom', {
fire(document, 'map:zoom', { featureCollection,
featureCollection: comboProps.featureCollection, feature: item.data
feature });
}); }
}} }}
{...comboProps}
/> />
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; import { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
import type { FeatureCollection } from 'geojson'; import type { FeatureCollection } from 'geojson';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';

View file

@ -1,4 +1,4 @@
import React, { useState, useId } from 'react'; import { useState, useId } from 'react';
import { fire } from '@utils'; import { fire } from '@utils';
import type { Feature, FeatureCollection } from 'geojson'; import type { Feature, FeatureCollection } from 'geojson';
import CoordinateInput from 'react-coordinate-input'; import CoordinateInput from 'react-coordinate-input';

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { CursorClickIcon } from '@heroicons/react/outline'; import { CursorClickIcon } from '@heroicons/react/outline';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
@ -12,21 +12,18 @@ import { AddressInput } from './components/AddressInput';
import { PointInput } from './components/PointInput'; import { PointInput } from './components/PointInput';
import { ImportFileInput } from './components/ImportFileInput'; import { ImportFileInput } from './components/ImportFileInput';
import { FlashMessage } from '../shared/FlashMessage'; import { FlashMessage } from '../shared/FlashMessage';
import { ComboSearchProps } from '../ComboSearch';
export default function MapEditor({ export default function MapEditor({
featureCollection: initialFeatureCollection, featureCollection: initialFeatureCollection,
url, url,
adresseSource,
options, options,
autocompleteAnnounceTemplateId,
autocompleteScreenReaderInstructions,
champId champId
}: { }: {
featureCollection: FeatureCollection; featureCollection: FeatureCollection;
url: string; url: string;
adresseSource: string;
options: { layers: string[] }; options: { layers: string[] };
autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId'];
autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions'];
champId: string; champId: string;
}) { }) {
const [cadastreEnabled, setCadastreEnabled] = useState(false); const [cadastreEnabled, setCadastreEnabled] = useState(false);
@ -41,15 +38,10 @@ export default function MapEditor({
{error && <FlashMessage message={error} level="alert" fixed={true} />} {error && <FlashMessage message={error} level="alert" fixed={true} />}
<ImportFileInput featureCollection={featureCollection} {...actions} /> <ImportFileInput featureCollection={featureCollection} {...actions} />
<label className="fr-label" htmlFor={champId}>
Rechercher une Adresse
<span className="fr-hint-text">Saisissez au moins 2 caractères</span>
</label>
<AddressInput <AddressInput
source={adresseSource}
champId={champId} champId={champId}
featureCollection={featureCollection} featureCollection={featureCollection}
screenReaderInstructions={autocompleteScreenReaderInstructions}
announceTemplateId={autocompleteAnnounceTemplateId}
/> />
<MapLibre layers={options.layers}> <MapLibre layers={options.layers}>

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl'; import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl';
import type { Feature, FeatureCollection, Point } from 'geojson'; import type { Feature, FeatureCollection, Point } from 'geojson';

View file

@ -1,4 +1,3 @@
import React from 'react';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { FeatureCollection } from 'geojson'; import type { FeatureCollection } from 'geojson';

View file

@ -0,0 +1,474 @@
import type {
ComboBoxProps as AriaComboBoxProps,
TagGroupProps
} from 'react-aria-components';
import { useAsyncList, type AsyncListOptions } from 'react-stately';
import { useMemo, useRef, useState, useEffect } from 'react';
import type { Key } from 'react';
import { matchSorter } from 'match-sorter';
import { useDebounceCallback } from 'usehooks-ts';
import { useEvent } from 'react-use-event-hook';
import isEqual from 'react-fast-compare';
import { Item } from './props';
export type Loader = AsyncListOptions<Item, string>['load'];
export interface ComboBoxProps
extends Omit<AriaComboBoxProps<Item>, 'children'> {
children: React.ReactNode | ((item: Item) => React.ReactNode);
label?: string;
description?: string;
}
const inputMap = new WeakMap<HTMLInputElement, string>();
export function useDispatchChangeEvent() {
const ref = useRef<HTMLSpanElement>(null);
return {
ref,
dispatch: () => {
requestAnimationFrame(() => {
const input = ref.current?.querySelector('input');
if (input) {
const value = input.value;
const prevValue = inputMap.get(input) || '';
if (value != prevValue) {
inputMap.set(input, value);
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
}
};
}
export function useSingleList({
defaultItems,
defaultSelectedKey,
emptyFilterKey,
onChange
}: {
defaultItems?: Item[];
defaultSelectedKey?: string | null;
emptyFilterKey?: string;
onChange?: (item: Item | null) => void;
}) {
const [selectedKey, setSelectedKey] = useState(defaultSelectedKey);
const items = useMemo(() => defaultItems || [], [defaultItems]);
const selectedItem = useMemo(
() => items.find((item) => item.value == selectedKey) ?? null,
[items, selectedKey]
);
const [inputValue, setInputValue] = useState(() => selectedItem?.label ?? '');
// show fallback item when input value is not matching any items
const fallbackItem = useMemo(
() => items.find((item) => item.value == emptyFilterKey),
[items, emptyFilterKey]
);
const filteredItems = useMemo(() => {
if (inputValue == '') {
return items;
}
const filteredItems = matchSorter(items, inputValue, { keys: ['label'] });
if (filteredItems.length == 0 && fallbackItem) {
return [fallbackItem];
} else {
return filteredItems;
}
}, [items, inputValue, fallbackItem]);
const initialSelectedKeyRef = useRef(defaultSelectedKey);
const setSelection = useEvent((key?: string | null) => {
const inputValue = defaultSelectedKey
? items.find((item) => item.value == defaultSelectedKey)?.label
: '';
setSelectedKey(key);
setInputValue(inputValue ?? '');
});
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
setSelection(key ? String(key) : null);
const item =
typeof key != 'string'
? null
: selectedItem?.value == key
? selectedItem
: items.find((item) => item.value == key) ?? null;
onChange?.(item);
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
setInputValue(value);
if (value == '') {
onSelectionChange(null);
}
}
);
const onReset = useEvent(() => {
setSelectedKey(null);
setInputValue('');
});
// reset default selected key when props change
useEffect(() => {
if (initialSelectedKeyRef.current != defaultSelectedKey) {
initialSelectedKeyRef.current = defaultSelectedKey;
setSelection(defaultSelectedKey);
}
}, [defaultSelectedKey, setSelection]);
return {
selectedItem,
selectedKey,
onSelectionChange,
inputValue,
onInputChange,
items: filteredItems,
onReset
};
}
export function useMultiList({
defaultItems,
defaultSelectedKeys,
allowsCustomValue,
valueSeparator,
onChange,
focusInput,
formValue
}: {
defaultItems?: Item[];
defaultSelectedKeys?: string[];
allowsCustomValue?: boolean;
valueSeparator?: string;
onChange?: () => void;
focusInput?: () => void;
formValue?: 'text' | 'key';
}) {
const valueSeparatorRegExp = useMemo(
() => (valueSeparator ? new RegExp(valueSeparator) : /\s|,|;/),
[valueSeparator]
);
const [selectedKeys, setSelectedKeys] = useState(
() => new Set(defaultSelectedKeys ?? [])
);
const [inputValue, setInputValue] = useState('');
const items = useMemo(() => defaultItems || [], [defaultItems]);
const itemsIndex = useMemo(() => {
const index = new Map<string, Item>();
for (const item of items) {
index.set(item.value, item);
}
return index;
}, [items]);
const filteredItems = useMemo(
() =>
inputValue.length == 0
? items
: matchSorter(items, inputValue, { keys: ['label'] }),
[items, inputValue]
);
const selectedItems = useMemo(() => {
const selectedItems: Item[] = [];
for (const key of selectedKeys) {
const item = itemsIndex.get(key);
if (item) {
selectedItems.push(item);
} else if (allowsCustomValue) {
selectedItems.push({ label: key, value: key });
}
}
return selectedItems;
}, [itemsIndex, selectedKeys, allowsCustomValue]);
const hiddenInputValues = useMemo(() => {
const values = selectedItems.map((item) =>
formValue == 'text' || allowsCustomValue ? item.label : item.value
);
if (!allowsCustomValue || inputValue == '') {
return values;
}
return [
...new Set([
...values,
...inputValue.split(valueSeparatorRegExp).filter(Boolean)
])
];
}, [
selectedItems,
inputValue,
valueSeparatorRegExp,
allowsCustomValue,
formValue
]);
const isSelectionSetRef = useRef(false);
const initialSelectedKeysRef = useRef(defaultSelectedKeys);
// reset default selected keys when props change
useEffect(() => {
if (!isEqual(initialSelectedKeysRef.current, defaultSelectedKeys)) {
initialSelectedKeysRef.current = defaultSelectedKeys;
setSelectedKeys(new Set(defaultSelectedKeys));
}
}, [defaultSelectedKeys]);
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
if (key) {
isSelectionSetRef.current = true;
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
selectedKeys.add(String(key));
return selectedKeys;
});
setInputValue('');
onChange?.();
}
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
const isSelectionSet = isSelectionSetRef.current;
isSelectionSetRef.current = false;
if (isSelectionSet) {
setInputValue('');
return;
}
if (allowsCustomValue) {
const values = value.split(valueSeparatorRegExp);
// if input contains a separator, add all values
if (values.length > 1) {
const addedKeys = values.filter(Boolean);
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
for (const key of addedKeys) {
selectedKeys.add(key);
}
return selectedKeys;
});
setInputValue('');
} else {
setInputValue(value);
}
onChange?.();
} else {
setInputValue(value);
}
}
);
const onRemove = useEvent<NonNullable<TagGroupProps['onRemove']>>(
(removedKeys) => {
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
for (const key of removedKeys) {
selectedKeys.delete(String(key));
}
// focus input when all items are removed
if (selectedKeys.size == 0) {
focusInput?.();
}
return selectedKeys;
});
onChange?.();
}
);
const onReset = useEvent(() => {
setSelectedKeys(new Set());
setInputValue('');
});
return {
onRemove,
onSelectionChange,
onInputChange,
selectedItems,
items: filteredItems,
hiddenInputValues,
inputValue,
onReset
};
}
export function useRemoteList({
load,
defaultItems,
defaultSelectedKey,
onChange,
debounce,
allowsCustomValue
}: {
load: Loader;
defaultItems?: Item[];
defaultSelectedKey?: Key | null;
onChange?: (item: Item | null) => void;
debounce?: number;
allowsCustomValue?: boolean;
}) {
const [defaultSelectedItem, setSelectedItem] = useState<Item | null>(() => {
if (defaultItems) {
return (
defaultItems.find((item) => item.value == defaultSelectedKey) ?? null
);
}
return null;
});
const [inputValue, setInputValue] = useState(
defaultSelectedItem?.label ?? ''
);
const selectedItem = useMemo<Item | null>(() => {
if (defaultSelectedItem) {
return defaultSelectedItem;
}
if (allowsCustomValue && inputValue != '') {
return { label: inputValue, value: inputValue };
}
return null;
}, [defaultSelectedItem, inputValue, allowsCustomValue]);
const list = useAsyncList<Item>({ getKey, load });
const setFilterText = useEvent((filterText: string) => {
list.setFilterText(filterText);
});
const debouncedSetFilterText = useDebounceCallback(
setFilterText,
debounce ?? 300
);
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
const item =
typeof key != 'string'
? null
: selectedItem?.value == key
? selectedItem
: list.getItem(key);
setSelectedItem(item);
if (item) {
setInputValue(item.label);
} else if (!allowsCustomValue) {
setInputValue('');
}
onChange?.(item);
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
debouncedSetFilterText(value);
setInputValue(value);
if (value == '') {
onSelectionChange(null);
} else if (allowsCustomValue && selectedItem?.label != value) {
onChange?.(selectedItem);
}
}
);
const onReset = useEvent(() => {
setSelectedItem(null);
setInputValue('');
});
// add to items list current selected item if it's not in the list
const items =
selectedItem && !list.getItem(selectedItem.value)
? [selectedItem, ...list.items]
: list.items;
return {
selectedItem,
selectedKey: selectedItem?.value ?? null,
onSelectionChange,
inputValue,
onInputChange,
items,
onReset
};
}
function getKey(item: Item) {
return item.value;
}
export const createLoader: (
source: string,
options?: {
minimumInputLength?: number;
limit?: number;
param?: string;
}
) => Loader =
(source, options) =>
async ({ signal, filterText }) => {
const url = new URL(source, location.href);
const minimumInputLength = options?.minimumInputLength ?? 2;
const param = options?.param ?? 'q';
const limit = options?.limit ?? 10;
if (!filterText || filterText.length < minimumInputLength) {
return { items: [] };
}
url.searchParams.set(param, filterText);
try {
const response = await fetch(url.toString(), {
headers: { accept: 'application/json' },
signal
});
if (response.ok) {
const json = await response.json();
const result = Item.array().safeParse(json);
if (result.success) {
const items = matchSorter(result.data, filterText, {
keys: ['label']
});
return {
items: limit ? items.slice(0, limit) : items
};
}
}
return { items: [] };
} catch {
return { items: [] };
}
};
export function useLabelledBy(id?: string, ariaLabelledby?: string) {
return useMemo(
() => (ariaLabelledby ? ariaLabelledby : findLabelledbyId(id)),
[id, ariaLabelledby]
);
}
function findLabelledbyId(id?: string) {
if (!id) {
return;
}
const label = document.querySelector(`[for="${id}"]`);
if (!label?.id) {
return;
}
return label.id;
}
export function useOnFormReset(onReset?: () => void) {
const ref = useRef<HTMLInputElement>(null);
const onResetListener = useEvent<EventListener>((event) => {
if (event.target == ref.current?.form) {
onReset?.();
}
});
useEffect(() => {
if (onReset) {
addEventListener('reset', onResetListener);
return () => {
removeEventListener('reset', onResetListener);
};
}
}, [onReset, onResetListener]);
return ref;
}

View file

@ -0,0 +1,73 @@
import type { ReactNode } from 'react';
import { z } from 'zod';
import type { Loader } from './hooks';
export const Item = z.object({
label: z.string(),
value: z.string(),
data: z.any().optional()
});
export type Item = z.infer<typeof Item>;
const ComboBoxPropsSchema = z
.object({
id: z.string(),
className: z.string(),
name: z.string(),
label: z.string(),
description: z.string(),
isRequired: z.boolean(),
'aria-label': z.string(),
'aria-labelledby': z.string(),
'aria-describedby': z.string(),
items: z
.array(Item)
.or(
z
.string()
.array()
.transform((items) =>
items.map<Item>((label) => ({ label, value: label }))
)
)
.or(
z
.tuple([z.string(), z.string().or(z.number())])
.array()
.transform((items) =>
items.map<Item>(([label, value]) => ({
label,
value: String(value)
}))
)
),
formValue: z.enum(['text', 'key']),
form: z.string(),
data: z.record(z.string())
})
.partial();
export const SingleComboBoxProps = ComboBoxPropsSchema.extend({
selectedKey: z.string().nullable(),
emptyFilterKey: z.string()
}).partial();
export const MultiComboBoxProps = ComboBoxPropsSchema.extend({
selectedKeys: z.string().array(),
allowsCustomValue: z.boolean(),
valueSeparator: z.string()
}).partial();
export const RemoteComboBoxProps = ComboBoxPropsSchema.extend({
selectedKey: z.string().nullable(),
minimumInputLength: z.number(),
limit: z.number(),
allowsCustomValue: z.boolean()
}).partial();
export type SingleComboBoxProps = z.infer<typeof SingleComboBoxProps> & {
children?: ReactNode;
};
export type MultiComboBoxProps = z.infer<typeof MultiComboBoxProps>;
export type RemoteComboBoxProps = z.infer<typeof RemoteComboBoxProps> & {
children?: ReactNode;
loader: Loader | string;
onChange?: (item: Item | null) => void;
};

View file

@ -1,4 +1,3 @@
import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';

View file

@ -1,90 +0,0 @@
import { useRef, useCallback, useMemo, useState } from 'react';
import { fire } from '@utils';
export function useDeferredSubmit(input?: HTMLInputElement): {
(callback: () => void): void;
done: () => void;
} {
const calledRef = useRef(false);
const awaitFormSubmit = useCallback(
(callback: () => void) => {
const form = input?.form;
if (!form) {
return;
}
const interceptFormSubmit = (event: Event) => {
event.preventDefault();
runCallback();
if (
!Array.from(form.elements).some(
(e) =>
e.hasAttribute('data-direct-upload-url') &&
'value' in e &&
e.value != ''
)
) {
form.submit();
}
// else: form will be submitted by diret upload once file have been uploaded
};
calledRef.current = false;
form.addEventListener('submit', interceptFormSubmit);
const runCallback = () => {
form.removeEventListener('submit', interceptFormSubmit);
clearTimeout(timer);
if (!calledRef.current) {
callback();
}
};
const timer = setTimeout(runCallback, 400);
},
[input]
);
const done = () => {
calledRef.current = true;
};
return Object.assign(awaitFormSubmit, { done });
}
export function groupId(id: string) {
return `#${id.replace(/-input$/, '')}`;
}
export function useHiddenField(
group?: string,
name = 'value'
): [
value: string | undefined,
setValue: (value: string) => void,
input: HTMLInputElement | undefined
] {
const hiddenField = useMemo(
() => selectInputInGroup(group, name),
[group, name]
);
const [value, setValue] = useState(() => hiddenField?.value);
return [
value,
(value) => {
if (hiddenField) {
hiddenField.setAttribute('value', value);
setValue(value);
fire(hiddenField, 'change');
}
},
hiddenField ?? undefined
];
}
function selectInputInGroup(
group: string | undefined,
name: string
): HTMLInputElement | undefined | null {
if (group) {
return document.querySelector<HTMLInputElement>(
`${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]`
);
}
}

View file

@ -1,4 +1,4 @@
import React, { import {
useState, useState,
useContext, useContext,
useRef, useRef,

View file

@ -1,4 +1,4 @@
import React, { useState, useId } from 'react'; import { useState, useId } from 'react';
import { Popover, RadioGroup } from '@headlessui/react'; import { Popover, RadioGroup } from '@headlessui/react';
import { usePopper } from 'react-popper'; import { usePopper } from 'react-popper';
import { MapIcon } from '@heroicons/react/outline'; import { MapIcon } from '@heroicons/react/outline';

View file

@ -1,62 +0,0 @@
import { QueryClient, QueryFunction } from 'react-query';
import { httpRequest, getConfig } from '@utils';
const API_EDUCATION_QUERY_LIMIT = 5;
const API_ADRESSE_QUERY_LIMIT = 5;
const {
autocomplete: { api_adresse_url, api_education_url }
} = getConfig();
type QueryKey = readonly [
scope: string,
term: string,
extra: string | undefined
];
function buildURL(scope: string, term: string) {
term = term.replace(/\(|\)/g, '');
const params = new URLSearchParams();
let path = '';
if (scope == 'adresse') {
path = `${api_adresse_url}/search`;
params.set('q', term);
params.set('limit', `${API_ADRESSE_QUERY_LIMIT}`);
} else if (scope == 'annuaire-education') {
path = `${api_education_url}/search`;
params.set('q', term);
params.set('rows', `${API_EDUCATION_QUERY_LIMIT}`);
params.set('dataset', 'fr-en-annuaire-education');
}
return `${path}?${params}`;
}
const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
queryKey: [scope, term],
signal
}) => {
// BAN will error with queries less then 3 chars long
if (scope == 'adresse' && term.length < 3) {
return {
type: 'FeatureCollection',
version: 'draft',
features: [],
query: term
};
}
const url = buildURL(scope, term);
return httpRequest(url, { csrf: false, signal }).json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// we don't really care about global queryFn type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryFn: defaultQueryFn as any
}
}
});

View file

@ -1,99 +0,0 @@
import invariant from 'tiny-invariant';
import { isInputElement, isElement } from '@coldwired/utils';
import { Hint } from '../shared/combobox';
import { ComboboxUI } from '../shared/combobox-ui';
import { ApplicationController } from './application_controller';
export class ComboboxController extends ApplicationController {
#combobox?: ComboboxUI;
connect() {
const { input, selectedValueInput, valueSlots, list, item, hint } =
this.getElements();
const hints = JSON.parse(list.dataset.hints ?? '{}') as Record<
string,
string
>;
this.#combobox = new ComboboxUI({
input,
selectedValueInput,
valueSlots,
list,
item,
hint,
allowsCustomValue: this.element.hasAttribute('data-allows-custom-value'),
limit: this.element.hasAttribute('data-limit')
? Number(this.element.getAttribute('data-limit'))
: undefined,
getHintText: (hint) => getHintText(hints, hint)
});
this.#combobox.init();
}
disconnect() {
this.#combobox?.destroy();
}
private getElements() {
const input =
this.element.querySelector<HTMLInputElement>('input[type="text"]');
const selectedValueInput = this.element.querySelector<HTMLInputElement>(
'input[type="hidden"]'
);
const valueSlots = this.element.querySelectorAll<HTMLInputElement>(
'input[type="hidden"][data-value-slot]'
);
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
const item = this.element.querySelector<HTMLTemplateElement>('template');
const hint =
this.element.querySelector<HTMLElement>('[aria-live]') ?? undefined;
invariant(
isInputElement(input),
'ComboboxController requires a input element'
);
invariant(
isInputElement(selectedValueInput),
'ComboboxController requires a hidden input element'
);
invariant(
isElement(list),
'ComboboxController requires a [role=listbox] element'
);
invariant(
isElement(item),
'ComboboxController requires a template element'
);
return { input, selectedValueInput, valueSlots, list, item, hint };
}
}
function getHintText(hints: Record<string, string>, hint: Hint): string {
const slot = hints[getSlotName(hint)];
switch (hint.type) {
case 'empty':
return slot;
case 'selected':
return slot.replace('{label}', hint.label ?? '');
default:
return slot
.replace('{count}', String(hint.count))
.replace('{label}', hint.label ?? '');
}
}
function getSlotName(hint: Hint): string {
switch (hint.type) {
case 'empty':
return 'empty';
case 'selected':
return 'selected';
default:
if (hint.count == 1) {
return hint.label ? 'oneWithLabel' : 'one';
}
return hint.label ? 'manyWithLabel' : 'many';
}
}

View file

@ -86,6 +86,7 @@ export class MenuButtonController extends ApplicationController {
target.isConnected && target.isConnected &&
!this.element.contains(target) && !this.element.contains(target) &&
!target.closest('reach-portal') && !target.closest('reach-portal') &&
!target.closest('#rac-portal') &&
this.isOpen this.isOpen
); );
} }

View file

@ -1,72 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import React, { lazy, Suspense, FunctionComponent } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import invariant from 'tiny-invariant';
type Props = Record<string, unknown>;
type Loader = () => Promise<{ default: FunctionComponent<Props> }>;
const componentsRegistry = new Map<string, FunctionComponent<Props>>();
const components = import.meta.glob('../components/*.tsx');
for (const [path, loader] of Object.entries(components)) {
const [filename] = path.split('/').reverse();
const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
console.debug(
`Registered lazy default export for "${componentClassName}" component`
);
componentsRegistry.set(
componentClassName,
LoadableComponent(loader as Loader)
);
}
// Initialize React components when their markup appears into the DOM.
//
// Example:
// <div data-controller="react" data-react-component-value="ComboMultiple" data-react-props-value="{}"></div>
//
export class ReactController extends Controller {
static values = {
component: String,
props: Object
};
declare readonly componentValue: string;
declare readonly propsValue: Props;
connect(): void {
this.mountComponent(this.element as HTMLElement);
}
disconnect(): void {
unmountComponentAtNode(this.element as HTMLElement);
}
private mountComponent(node: HTMLElement): void {
const componentName = this.componentValue;
const props = this.propsValue;
const Component = this.getComponent(componentName);
invariant(
Component,
`Cannot find a React component with class "${componentName}"`
);
render(<Component {...props} />, node);
}
private getComponent(componentName: string): FunctionComponent<Props> | null {
return componentsRegistry.get(componentName) ?? null;
}
}
const Spinner = () => <div className="spinner left" />;
function LoadableComponent(loader: Loader): FunctionComponent<Props> {
const LazyComponent = lazy(loader);
const Component: FunctionComponent<Props> = (props: Props) => (
<Suspense fallback={<Spinner />}>
<LazyComponent {...props} />
</Suspense>
);
return Component;
}

View file

@ -1,7 +1,9 @@
import { Actions } from '@coldwired/actions'; import { Actions } from '@coldwired/actions';
import { parseTurboStream } from '@coldwired/turbo-stream'; import { parseTurboStream } from '@coldwired/turbo-stream';
import { createRoot, createReactPlugin, type Root } from '@coldwired/react';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';
import { session as TurboSession, type StreamElement } from '@hotwired/turbo'; import { session as TurboSession, type StreamElement } from '@hotwired/turbo';
import type { ComponentType } from 'react';
import { ApplicationController } from './application_controller'; import { ApplicationController } from './application_controller';
@ -20,6 +22,7 @@ export class TurboController extends ApplicationController {
#submitting = false; #submitting = false;
#actions?: Actions; #actions?: Actions;
#root?: Root;
// `actions` instrface exposes all available actions as methods and also `applyActions` method // `actions` instrface exposes all available actions as methods and also `applyActions` method
// wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also // wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also
@ -32,6 +35,17 @@ export class TurboController extends ApplicationController {
} }
connect() { connect() {
this.#root = createRoot({
layoutComponentName: 'Layout/Layout',
loader,
schema: {
fragmentTagName: 'react-fragment',
componentTagName: 'react-component',
slotTagName: 'react-slot',
loadingClassName: 'loading'
}
});
const plugin = createReactPlugin(this.#root);
this.#actions = new Actions({ this.#actions = new Actions({
element: document.body, element: document.body,
schema: { schema: {
@ -40,6 +54,7 @@ export class TurboController extends ApplicationController {
focusDirectionAttribute: 'data-turbo-focus-direction', focusDirectionAttribute: 'data-turbo-focus-direction',
hiddenClassName: 'hidden' hiddenClassName: 'hidden'
}, },
plugins: [plugin],
debug: false debug: false
}); });
@ -47,6 +62,10 @@ export class TurboController extends ApplicationController {
// They allow us to preserve certain HTML changes across mutations. // They allow us to preserve certain HTML changes across mutations.
this.#actions.observe(); this.#actions.observe();
this.#actions.ready().then(() => {
document.body.classList.add('dom-ready');
});
// setup spinner events // setup spinner events
this.onGlobal('turbo:submit-start', () => this.startSpinner()); this.onGlobal('turbo:submit-start', () => this.startSpinner());
this.onGlobal('turbo:submit-end', () => this.stopSpinner()); this.onGlobal('turbo:submit-end', () => this.stopSpinner());
@ -73,6 +92,11 @@ export class TurboController extends ApplicationController {
}); });
} }
disconnect(): void {
this.#actions?.disconnect();
this.#root?.destroy();
}
private startSpinner() { private startSpinner() {
this.#submitting = true; this.#submitting = true;
this.actions.show({ targets: this.spinnerTargets }); this.actions.show({ targets: this.spinnerTargets });
@ -89,3 +113,24 @@ export class TurboController extends ApplicationController {
} }
} }
} }
type Loader = (exportName: string) => Promise<ComponentType<unknown>>;
const componentsRegistry: Record<string, Loader> = {};
const components = import.meta.glob('../components/*.tsx');
const loader: Loader = (name) => {
const [moduleName, exportName] = name.split('/');
const loader = componentsRegistry[moduleName];
invariant(loader, `Cannot find a React component with name "${name}"`);
return loader(exportName ?? 'default');
};
for (const [path, loader] of Object.entries(components)) {
const [filename] = path.split('/').reverse();
const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
console.debug(`Registered lazy export for "${componentClassName}" component`);
componentsRegistry[componentClassName] = (exportName) =>
loader().then(
(m) => (m as Record<string, ComponentType<unknown>>)[exportName]
);
}

View file

@ -1,470 +0,0 @@
import invariant from 'tiny-invariant';
import { isElement, dispatch, isInputElement } from '@coldwired/utils';
import { dispatchAction } from '@coldwired/actions';
import { createPopper, Instance as Popper } from '@popperjs/core';
import {
Combobox,
Action,
type State,
type Option,
type Hint,
type Fetcher
} from './combobox';
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/);
export type ComboboxUIOptions = {
input: HTMLInputElement;
selectedValueInput: HTMLInputElement;
list: HTMLUListElement;
item: HTMLTemplateElement;
valueSlots?: HTMLInputElement[] | NodeListOf<HTMLInputElement>;
allowsCustomValue?: boolean;
limit?: number;
hint?: HTMLElement;
getHintText?: (hint: Hint) => string;
};
export class ComboboxUI implements EventListenerObject {
#combobox?: Combobox;
#popper?: Popper;
#interactingWithList = false;
#mouseOverList = false;
#isComposing = false;
#input: HTMLInputElement;
#selectedValueInput: HTMLInputElement;
#valueSlots: HTMLInputElement[];
#list: HTMLUListElement;
#item: HTMLTemplateElement;
#hint?: HTMLElement;
#getHintText = defaultGetHintText;
#allowsCustomValue: boolean;
#limit?: number;
#selectedData: Option['data'] = null;
constructor({
input,
selectedValueInput,
valueSlots,
list,
item,
hint,
getHintText,
allowsCustomValue,
limit
}: ComboboxUIOptions) {
this.#input = input;
this.#selectedValueInput = selectedValueInput;
this.#valueSlots = valueSlots ? Array.from(valueSlots) : [];
this.#list = list;
this.#item = item;
this.#hint = hint;
this.#getHintText = getHintText ?? defaultGetHintText;
this.#allowsCustomValue = allowsCustomValue ?? false;
this.#limit = limit;
}
init() {
if (this.#list.dataset.url) {
const fetcher = createFetcher(this.#list.dataset.url);
this.#list.removeAttribute('data-url');
const selected: Option | null = this.#input.value
? { label: this.#input.value, value: this.#selectedValueInput.value }
: null;
this.#combobox = new Combobox({
options: fetcher,
selected,
allowsCustomValue: this.#allowsCustomValue,
limit: this.#limit,
render: (state) => this.render(state)
});
} else {
const selectedValue = this.#selectedValueInput.value;
const options = JSON.parse(
this.#list.dataset.options ?? '[]'
) as Option[];
const selected =
options.find(({ value }) => value == selectedValue) ?? null;
this.#list.removeAttribute('data-options');
this.#list.removeAttribute('data-selected');
this.#combobox = new Combobox({
options,
selected,
allowsCustomValue: this.#allowsCustomValue,
limit: this.#limit,
render: (state) => this.render(state)
});
}
this.#combobox.init();
this.#input.addEventListener('blur', this);
this.#input.addEventListener('focus', this);
this.#input.addEventListener('click', this);
this.#input.addEventListener('input', this);
this.#input.addEventListener('keydown', this);
this.#list.addEventListener('mousedown', this);
this.#list.addEventListener('mouseenter', this);
this.#list.addEventListener('mouseleave', this);
document.body.addEventListener('mouseup', this);
}
destroy() {
this.#combobox?.destroy();
this.#popper?.destroy();
this.#input.removeEventListener('blur', this);
this.#input.removeEventListener('focus', this);
this.#input.removeEventListener('click', this);
this.#input.removeEventListener('input', this);
this.#input.removeEventListener('keydown', this);
this.#list.removeEventListener('mousedown', this);
this.#list.removeEventListener('mouseenter', this);
this.#list.removeEventListener('mouseleave', this);
document.body.removeEventListener('mouseup', this);
}
handleEvent(event: Event) {
switch (event.type) {
case 'input':
this.onInputChange(event as InputEvent);
break;
case 'blur':
this.onInputBlur();
break;
case 'focus':
this.onInputFocus();
break;
case 'click':
if (event.target == this.#input) {
this.onInputClick(event as MouseEvent);
} else {
this.onListClick(event as MouseEvent);
}
break;
case 'keydown':
this.onKeydown(event as KeyboardEvent);
break;
case 'mousedown':
this.onListMouseDown();
break;
case 'mouseenter':
this.onListMouseEnter();
break;
case 'mouseleave':
this.onListMouseLeave();
break;
case 'mouseup':
this.onBodyMouseUp(event);
break;
case 'compositionstart':
case 'compositionend':
this.#isComposing = event.type == 'compositionstart';
break;
}
}
private get combobox() {
invariant(this.#combobox, 'ComboboxUI requires a Combobox instance');
return this.#combobox;
}
private render(state: State) {
console.debug('combobox render', state);
switch (state.action) {
case Action.Select:
case Action.Clear:
this.renderSelect(state);
break;
}
this.renderList(state);
this.renderOptionList(state);
this.renderValue(state);
this.renderHintForScreenReader(state.hint);
}
private renderList(state: State): void {
if (state.open) {
if (!this.#list.hidden) return;
this.#list.hidden = false;
this.#list.classList.remove('hidden');
this.#list.addEventListener('click', this);
this.#input.setAttribute('aria-expanded', 'true');
this.#input.addEventListener('compositionstart', this);
this.#input.addEventListener('compositionend', this);
this.#popper = createPopper(this.#input, this.#list, {
placement: 'bottom-start'
});
} else {
if (this.#list.hidden) return;
this.#list.hidden = true;
this.#list.classList.add('hidden');
this.#list.removeEventListener('click', this);
this.#input.setAttribute('aria-expanded', 'false');
this.#input.removeEventListener('compositionstart', this);
this.#input.removeEventListener('compositionend', this);
this.#popper?.destroy();
this.#interactingWithList = false;
}
}
private renderValue(state: State): void {
if (this.#input.value != state.inputValue) {
this.#input.value = state.inputValue;
}
this.dispatchChange(() => {
if (this.#selectedValueInput.value != state.inputValue) {
if (state.allowsCustomValue || !state.inputValue) {
this.#selectedValueInput.value = state.inputValue;
}
}
return state.selection?.data;
});
}
private renderSelect(state: State): void {
this.dispatchChange(() => {
this.#selectedValueInput.value = state.selection?.value ?? '';
this.#input.value = state.selection?.label ?? '';
return state.selection?.data;
});
}
private renderOptionList(state: State): void {
const html = state.options
.map(({ label, value }) => {
const fragment = this.#item.content.cloneNode(true) as DocumentFragment;
const item = fragment.querySelector('li');
if (item) {
item.id = optionId(value);
item.setAttribute('data-turbo-force', 'server');
if (state.focused?.value == value) {
item.setAttribute('aria-selected', 'true');
} else {
item.removeAttribute('aria-selected');
}
item.setAttribute('data-value', value);
item.querySelector('slot[name="label"]')?.replaceWith(label);
return item.outerHTML;
}
return '';
})
.join('');
dispatchAction({ targets: this.#list, action: 'update', fragment: html });
if (state.focused) {
const id = optionId(state.focused.value);
const item = this.#list.querySelector<HTMLElement>(`#${id}`);
this.#input.setAttribute('aria-activedescendant', id);
if (item) {
scrollTo(this.#list, item);
}
} else {
this.#input.removeAttribute('aria-activedescendant');
}
}
private renderHintForScreenReader(hint: Hint | null): void {
if (this.#hint) {
if (hint) {
this.#hint.textContent = this.#getHintText(hint);
} else {
this.#hint.textContent = '';
}
}
}
private dispatchChange(cb: () => Option['data']): void {
const value = this.#selectedValueInput.value;
const data = cb();
if (value != this.#selectedValueInput.value || data != this.#selectedData) {
this.#selectedData = data;
for (const input of this.#valueSlots) {
switch (input.dataset.valueSlot) {
case 'value':
input.value = this.#selectedValueInput.value;
break;
case 'label':
input.value = this.#input.value;
break;
case 'data:string':
input.value = data ? String(data) : '';
break;
case 'data':
input.value = data ? JSON.stringify(data) : '';
break;
}
}
console.debug('combobox change', this.#selectedValueInput.value);
dispatch('change', {
target: this.#selectedValueInput,
detail: data ? { data } : undefined
});
}
}
private onKeydown(event: KeyboardEvent): void {
if (event.shiftKey || event.metaKey || event.altKey) return;
if (!ctrlBindings && event.ctrlKey) return;
if (this.#isComposing) return;
if (this.combobox.keyboard(event.key)) {
event.preventDefault();
event.stopPropagation();
}
}
private onInputClick(event: MouseEvent): void {
const rect = this.#input.getBoundingClientRect();
const clickOnArrow =
event.clientX >= rect.right - 40 &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (clickOnArrow) {
this.combobox.toggle();
}
}
private onListClick(event: MouseEvent): void {
if (isElement(event.target)) {
const element = event.target.closest<HTMLElement>('[role="option"]');
if (element) {
const value = element.getAttribute('data-value')?.trim();
if (value) {
this.combobox.select(value);
}
}
}
}
private onInputFocus(): void {
this.combobox.focus();
}
private onInputBlur(): void {
if (!this.#interactingWithList) {
this.combobox.close();
}
}
private onInputChange(event: InputEvent): void {
if (isInputElement(event.target)) {
this.combobox.input(event.target.value);
}
}
private onListMouseDown(): void {
this.#interactingWithList = true;
}
private onBodyMouseUp(event: Event): void {
if (
this.#interactingWithList &&
!this.#mouseOverList &&
isElement(event.target) &&
event.target != this.#list &&
!this.#list.contains(event.target)
) {
this.combobox.close();
}
}
private onListMouseEnter(): void {
this.#mouseOverList = true;
}
private onListMouseLeave(): void {
this.#mouseOverList = false;
}
}
function scrollTo(container: HTMLElement, target: HTMLElement): void {
if (!inViewport(container, target)) {
container.scrollTop = target.offsetTop;
}
}
function inViewport(container: HTMLElement, element: HTMLElement): boolean {
const scrollTop = container.scrollTop;
const containerBottom = scrollTop + container.clientHeight;
const top = element.offsetTop;
const bottom = top + element.clientHeight;
return top >= scrollTop && bottom <= containerBottom;
}
function optionId(value: string) {
return `option-${value
.toLowerCase()
// Replace spaces and special characters with underscores
.replace(/[^a-z0-9]/g, '_')
// Remove non-alphanumeric characters at start and end
.replace(/^[^a-z]+|[^\w]$/g, '')}`;
}
function defaultGetHintText(hint: Hint): string {
switch (hint.type) {
case 'results':
if (hint.label) {
return `${hint.count} results. ${hint.label} is the top result: press Enter to activate.`;
}
return `${hint.count} results.`;
case 'empty':
return 'No results.';
case 'selected':
return `${hint.label} selected.`;
}
}
function createFetcher(source: string, param = 'q'): Fetcher {
const url = new URL(source, location.href);
const fetcher: Fetcher = (term: string, options) => {
url.searchParams.set(param, term);
return fetch(url.toString(), {
headers: { accept: 'application/json' },
signal: options?.signal
}).then<Option[]>((response) => {
if (response.ok) {
return response.json();
}
return [];
});
};
return async (term: string, options) => {
await wait(500, options?.signal);
return fetcher(term, options);
};
}
function wait(ms: number, signal?: AbortSignal) {
return new Promise((resolve, reject) => {
const abort = () => reject(new DOMException('Aborted', 'AbortError'));
if (signal?.aborted) {
abort();
} else {
signal?.addEventListener('abort', abort);
setTimeout(resolve, ms);
}
});
}

View file

@ -1,295 +0,0 @@
import { suite, test, beforeEach, expect } from 'vitest';
import { matchSorter } from 'match-sorter';
import { Combobox, Option, State } from './combobox';
suite('Combobox', () => {
const options: Option[] =
'Fraises,Myrtilles,Framboises,Mûres,Canneberges,Groseilles,Baies de sureau,Mûres blanches,Baies de genièvre,Baies daçaï'
.split(',')
.map((label) => ({ label, value: label }));
let combobox: Combobox;
let currentState: State;
suite('single select without custom value', () => {
suite('with default selection', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: options.at(0) ?? null,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('open select box and select option with click', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.loading).toBe(null);
expect(currentState.selection?.label).toBe('Fraises');
combobox.open();
expect(currentState.open).toBeTruthy();
combobox.select('Mûres');
expect(currentState.selection?.label).toBe('Mûres');
expect(currentState.open).toBeFalsy();
});
test('open select box and select option with enter', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.selection?.label).toBe('Fraises');
combobox.keyboard('ArrowDown');
expect(currentState.open).toBeTruthy();
expect(currentState.selection?.label).toBe('Fraises');
expect(currentState.focused?.label).toBe('Fraises');
combobox.keyboard('ArrowDown');
expect(currentState.selection?.label).toBe('Fraises');
expect(currentState.focused?.label).toBe('Myrtilles');
combobox.keyboard('Enter');
expect(currentState.selection?.label).toBe('Myrtilles');
expect(currentState.open).toBeFalsy();
combobox.keyboard('Enter');
expect(currentState.selection?.label).toBe('Myrtilles');
expect(currentState.open).toBeFalsy();
});
test('open select box and select option with tab', () => {
combobox.keyboard('ArrowDown');
combobox.keyboard('ArrowDown');
combobox.keyboard('Tab');
expect(currentState.selection?.label).toBe('Myrtilles');
expect(currentState.open).toBeFalsy();
expect(currentState.hint).toEqual({
type: 'selected',
label: 'Myrtilles'
});
});
test('do not open select box on focus', () => {
combobox.focus();
expect(currentState.open).toBeFalsy();
});
});
suite('empty', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: null,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('open select box on focus', () => {
combobox.focus();
expect(currentState.open).toBeTruthy();
});
suite('open', () => {
beforeEach(() => {
combobox.open();
});
test('if tab on empty input nothing is selected', () => {
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
test('if enter on empty input nothing is selected', () => {
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
});
suite('closed', () => {
test('if tab on empty input nothing is selected', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
test('if enter on empty input nothing is selected', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
});
test('type exact match and press enter', () => {
combobox.input('Baies');
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
expect(currentState.options.length).toEqual(3);
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection?.label).toBe('Baies daçaï');
});
test('type exact match and press tab', () => {
combobox.input('Baies');
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection?.label).toBe('Baies daçaï');
expect(currentState.inputValue).toEqual('Baies daçaï');
});
test('type non matching input and press enter', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('type non matching input and press tab', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('type non matching input and close', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.close();
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('focus should circle', () => {
combobox.input('Baie');
expect(currentState.open).toBeTruthy();
expect(currentState.options.map(({ label }) => label)).toEqual([
'Baies daçaï',
'Baies de genièvre',
'Baies de sureau'
]);
expect(currentState.focused).toBeNull();
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies daçaï');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies de genièvre');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies de sureau');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies daçaï');
combobox.keyboard('ArrowUp');
expect(currentState.focused?.label).toBe('Baies de sureau');
});
});
});
suite('single select with custom value', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: null,
allowsCustomValue: true,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('type non matching input and press enter', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
test('type non matching input and press tab', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
test('type non matching input and close', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.close();
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
});
suite('single select with fetcher', () => {
beforeEach(() => {
combobox = new Combobox({
options: (term: string) =>
Promise.resolve(matchSorter(options, term, { keys: ['value'] })),
selected: null,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('type and get options from fetcher', async () => {
expect(currentState.open).toBeFalsy();
expect(currentState.loading).toBe(false);
const result = combobox.input('Baies');
expect(currentState.loading).toBe(true);
await result;
expect(currentState.loading).toBe(false);
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
expect(currentState.options.length).toEqual(3);
});
});
});

View file

@ -1,300 +0,0 @@
import { matchSorter } from 'match-sorter';
export enum Action {
Init = 'init',
Open = 'open',
Close = 'close',
Navigate = 'navigate',
Select = 'select',
Clear = 'clear',
Update = 'update'
}
export type Option = { value: string; label: string; data?: unknown };
export type Hint =
| {
type: 'results';
label: string | null;
count: number;
}
| { type: 'empty' }
| { type: 'selected'; label: string };
export type State = {
action: Action;
open: boolean;
inputValue: string;
focused: Option | null;
selection: Option | null;
options: Option[];
allowsCustomValue: boolean;
hint: Hint | null;
loading: boolean | null;
};
export type Fetcher = (
term: string,
options?: { signal: AbortSignal }
) => Promise<Option[]>;
export class Combobox {
#allowsCustomValue = false;
#limit?: number;
#open = false;
#inputValue = '';
#selectedOption: Option | null = null;
#focusedOption: Option | null = null;
#options: Option[] = [];
#visibleOptions: Option[] = [];
#render: (state: State) => void;
#fetcher: Fetcher | null;
#abortController?: AbortController | null;
constructor({
options,
selected,
allowsCustomValue,
limit,
render
}: {
options: Option[] | Fetcher;
selected: Option | null;
allowsCustomValue?: boolean;
limit?: number;
render: (state: State) => void;
}) {
this.#allowsCustomValue = allowsCustomValue ?? false;
this.#limit = limit;
this.#options = Array.isArray(options) ? options : [];
this.#fetcher = Array.isArray(options) ? null : options;
this.#selectedOption = selected;
if (this.#selectedOption) {
this.#inputValue = this.#selectedOption.label;
}
this.#render = render;
}
init(): void {
this.#visibleOptions = this._filterOptions();
this._render(Action.Init);
}
destroy(): void {
this.#render = () => null;
}
navigate(indexDiff: -1 | 1 = 1): void {
const focusIndex = this._focusedOptionIndex;
const lastIndex = this.#visibleOptions.length - 1;
let indexOfItem = indexDiff == 1 ? 0 : lastIndex;
if (focusIndex == lastIndex && indexDiff == 1) {
indexOfItem = 0;
} else if (focusIndex == 0 && indexDiff == -1) {
indexOfItem = lastIndex;
} else if (focusIndex == -1) {
indexOfItem = 0;
} else {
indexOfItem = focusIndex + indexDiff;
}
this.#focusedOption = this.#visibleOptions.at(indexOfItem) ?? null;
this._render(Action.Navigate);
}
select(value?: string): boolean {
const maybeValue = this._nextSelectValue(value);
if (!maybeValue) {
this.close();
return false;
}
const option = this.#visibleOptions.find(
(option) => option.value.trim() == maybeValue.trim()
);
if (!option) return false;
this.#selectedOption = option;
this.#focusedOption = null;
this.#inputValue = option.label;
this.#open = false;
this.#visibleOptions = this._filterOptions();
this._render(Action.Select);
return true;
}
async input(value: string) {
if (this.#inputValue == value) return;
this.#inputValue = value;
if (this.#fetcher) {
this.#abortController?.abort();
this.#abortController = new AbortController();
this._render(Action.Update);
this.#options = await this.#fetcher(value, {
signal: this.#abortController.signal
}).catch(() => []);
this.#abortController = null;
this._render(Action.Update);
this.#selectedOption = null;
} else {
this.#selectedOption = null;
}
this.#visibleOptions = this._filterOptions();
if (this.#visibleOptions.length > 0) {
if (!this.#open) {
this.open();
} else {
this._render(Action.Update);
}
} else if (this.#allowsCustomValue) {
this.#open = false;
this.#focusedOption = null;
this._render(Action.Close);
} else {
this._render(Action.Update);
}
}
keyboard(key: string) {
switch (key) {
case 'Enter':
case 'Tab':
return this.select();
case 'Escape':
this.close();
return true;
case 'ArrowDown':
if (this.#open) {
this.navigate(1);
} else {
this.open();
}
return true;
case 'ArrowUp':
if (this.#open) {
this.navigate(-1);
} else {
this.open();
}
return true;
}
}
clear() {
if (!this.#inputValue && !this.#selectedOption) return;
this.#inputValue = '';
this.#selectedOption = this.#focusedOption = null;
this.#visibleOptions = this.#options;
this.#visibleOptions = this._filterOptions();
this._render(Action.Clear);
}
open() {
if (this.#open || this.#visibleOptions.length == 0) return;
this.#open = true;
this.#focusedOption = this.#selectedOption;
this._render(Action.Open);
}
close() {
this.#open = false;
this.#focusedOption = null;
if (!this.#allowsCustomValue && !this.#selectedOption) {
this.#inputValue = '';
}
this.#visibleOptions = this._filterOptions();
this._render(Action.Close);
}
focus() {
if (this.#open) return;
if (this.#selectedOption) return;
this.open();
}
toggle() {
this.#open ? this.close() : this.open();
}
private _nextSelectValue(value?: string): string | false {
if (value) {
return value;
}
if (this.#focusedOption && this._focusedOptionIndex != -1) {
return this.#focusedOption.value;
}
if (this.#allowsCustomValue) {
return false;
}
if (this.#inputValue.length > 0 && !this.#selectedOption) {
return this.#visibleOptions.at(0)?.value ?? false;
}
return false;
}
private _filterOptions(): Option[] {
const emptyOrSelected =
!this.#inputValue || this.#inputValue == this.#selectedOption?.value;
const options = emptyOrSelected
? this.#options
: matchSorter(this.#options, this.#inputValue, {
keys: ['label']
});
if (this.#limit) {
return options.slice(0, this.#limit);
}
return options;
}
private get _focusedOptionIndex(): number {
if (this.#focusedOption) {
return this.#visibleOptions.indexOf(this.#focusedOption);
}
return -1;
}
private _render(action: Action): void {
this.#render(this._getState(action));
}
private _getState(action: Action): State {
const state = {
action,
open: this.#open,
options: this.#visibleOptions,
inputValue: this.#inputValue,
focused: this.#focusedOption,
selection: this.#selectedOption,
allowsCustomValue: this.#allowsCustomValue,
hint: null,
loading: this.#abortController ? true : this.#fetcher ? false : null
};
return { ...state, hint: this._getFeedback(state) };
}
private _getFeedback(state: State): Hint | null {
const count = state.options.length;
if (state.action == Action.Open || state.action == Action.Update) {
if (!state.selection) {
const defaultOption = state.options.at(0);
if (defaultOption) {
return { type: 'results', label: defaultOption.label, count };
} else if (count > 0) {
return { type: 'results', label: null, count };
}
return { type: 'empty' };
}
} else if (state.action == Action.Select && state.selection) {
return { type: 'selected', label: state.selection.label };
}
return null;
}
}

View file

@ -285,7 +285,7 @@ class Champ < ApplicationRecord
return if value.nil? return if value.nil?
return if value.present? && !value.include?("\u0000") return if value.present? && !value.include?("\u0000")
self.value = value.delete("\u0000") write_attribute(:value, value.delete("\u0000"))
end end
class NotImplemented < ::StandardError class NotImplemented < ::StandardError

View file

@ -3,10 +3,6 @@ class Champs::AddressChamp < Champs::TextChamp
data.present? data.present?
end end
def feature
data.to_json if full_address?
end
def feature=(value) def feature=(value)
if value.blank? if value.blank?
self.data = nil self.data = nil
@ -22,6 +18,14 @@ class Champs::AddressChamp < Champs::TextChamp
self.data = nil self.data = nil
end end
def selected_items
if value.present?
[{ value:, label: value, data: full_address? ? data : nil }]
else
[]
end
end
def address def address
full_address? ? data : nil full_address? ? data : nil
end end

View file

@ -7,14 +7,11 @@ class Champs::AnnuaireEducationChamp < Champs::TextChamp
APIEducation::AnnuaireEducationAdapter.new(external_id).to_params APIEducation::AnnuaireEducationAdapter.new(external_id).to_params
end end
def update_with_external_data!(data:) def selected_items
if data&.is_a?(Hash) && data['nom_etablissement'].present? && data['nom_commune'].present? && data['identifiant_de_l_etablissement'].present? if external_id.present?
update!( [{ value: external_id, label: value }]
data: data,
value: "#{data['nom_etablissement']}, #{data['nom_commune']} (#{data['identifiant_de_l_etablissement']})"
)
else else
update!(data: data) []
end end
end end
end end

View file

@ -28,10 +28,6 @@ class Champs::CommuneChamp < Champs::TextChamp
code_postal.present? code_postal.present?
end end
def code_postal=(value)
super(value&.gsub(/[[:space:]]/, ''))
end
alias postal_code code_postal alias postal_code code_postal
def name def name
@ -43,7 +39,36 @@ class Champs::CommuneChamp < Champs::TextChamp
end end
def selected def selected
code code? ? "#{code}-#{code_postal}" : nil
end
def selected_items
if code?
[{ label: to_s, value: selected }]
else
[]
end
end
def code=(code)
if code.blank?
self.code_departement = nil
self.code_postal = nil
self.external_id = nil
self.value = nil
elsif code.match?(/-/)
codes = code.split('-')
self.external_id = codes.first
self.code_postal = codes.second
else
self.external_id = code
end
end
private
def safe_to_s
value.present? ? value.to_s : ''
end end
def communes def communes
@ -54,12 +79,6 @@ class Champs::CommuneChamp < Champs::TextChamp
end end
end end
private
def safe_to_s
value.present? ? value.to_s : ''
end
def on_codes_change def on_codes_change
return if !code? return if !code?

View file

@ -641,8 +641,7 @@ class TypeDeChamp < ApplicationRecord
# We should refresh all champs after update except for champs using react or custom refresh # We should refresh all champs after update except for champs using react or custom refresh
# logic (RNA, SIRET, etc.) # logic (RNA, SIRET, etc.)
case type_champ case type_champ
when type_champs.fetch(:annuaire_education), when type_champs.fetch(:carte),
type_champs.fetch(:carte),
type_champs.fetch(:piece_justificative), type_champs.fetch(:piece_justificative),
type_champs.fetch(:titre_identite), type_champs.fetch(:titre_identite),
type_champs.fetch(:rna), type_champs.fetch(:rna),

View file

@ -65,15 +65,13 @@
.instructeur-wrapper .instructeur-wrapper
%p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie %p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie
= hidden_field_tag :emails, nil %react-fragment
= react_component("ComboMultiple", = render ReactComponent.new "ComboBox/MultiComboBox",
options: [], id: 'emails',
selected: [], disabled: [], name: 'emails[]',
group: '.instructeur-wrapper', allows_custom_value: true,
name: 'emails', 'aria-label': 'Emails',
label: 'Emails', 'aria-describedby': 'experts-emails'
describedby: 'experts-emails',
acceptNewValues: true)
= f.submit 'Ajouter à la liste', class: 'fr-btn' = f.submit 'Ajouter à la liste', class: 'fr-btn'

View file

@ -9,14 +9,8 @@
- if disabled_as_super_admin - if disabled_as_super_admin
= f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails' = f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails'
- else - else
= hidden_field_tag :emails, nil %react-fragment
= react_component("ComboMultiple", = render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails'
options: available_instructeur_emails, selected: [], disabled: [],
group: '.instructeur-wrapper',
id: 'instructeur_emails',
name: 'emails',
label: 'Emails',
acceptNewValues: true)
= f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin = f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin

View file

@ -122,17 +122,16 @@
.fr-fieldset__element .fr-fieldset__element
= f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label' = f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label'
%p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs. %p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs.
= hidden_field_tag 'procedure[tags]', JSON.generate(@procedure.tags) %react-fragment
= react_component("ComboMultiple", = render ReactComponent.new "ComboBox/MultiComboBox",
id: "procedure_tags_combo", id: "procedure_tags_combo",
options: Procedure.tags, items: Procedure.tags,
selected: @procedure.tags, selected_keys: @procedure.tags,
disabled: [], name: 'procedure[tags][]',
label: 'Tags', value_separator: ',|;',
group: '.procedure_tags_combo', allows_custom_value: true,
name: 'tags', 'aria-label': 'Tags',
describedby: 'procedure-tags', 'aria-describedby': 'procedure-tags'
acceptNewValues: true)
%details.procedure-form__options-details %details.procedure-form__options-details
%summary.procedure-form__options-summary %summary.procedure-form__options-summary

View file

@ -7,12 +7,7 @@
%p.tab-paragrah.mb-1 %p.tab-paragrah.mb-1
Le destinataire suivra automatiquement le dossier Le destinataire suivra automatiquement le dossier
= form_for dossier, url: send_to_instructeurs_instructeur_dossier_path(dossier.procedure, dossier), method: :post, html: { class: 'form recipients-form fr-mb-4w' } do |f| = form_for dossier, url: send_to_instructeurs_instructeur_dossier_path(dossier.procedure, dossier), method: :post, html: { class: 'form recipients-form fr-mb-4w' } do |f|
= hidden_field_tag :recipients, nil %react-fragment
= react_component("ComboMultiple", = render ReactComponent.new "ComboBox/MultiComboBox", items: potential_recipients.map { [_1.email, _1.id] }, name: 'recipients[]', 'aria-label': 'Emails'
options: potential_recipients.map{|r| [r.email, r.id]},
selected: [], disabled: [],
group: '.recipients-form',
name: 'recipients',
label: 'Emails')
= f.submit "Envoyer", class: "fr-btn fr-mt-2w" = f.submit "Envoyer", class: "fr-btn fr-mt-2w"

View file

@ -110,14 +110,8 @@
= t('views.instructeurs.dossiers.personalize') = t('views.instructeurs.dossiers.personalize')
- menu.with_form do - menu.with_form do
= form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do = form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do
= hidden_field_tag :values, nil %react-fragment
= react_component("ComboMultiple", = render ReactComponent.new "ComboBox/MultiComboBox", items: @displayable_fields_for_select, selected_keys: @displayable_fields_selected, name: 'values[]', 'aria-label': 'Colonne à afficher'
options: @displayable_fields_for_select,
selected: @displayable_fields_selected,
disabled: [],
label: 'Colonne à afficher',
group: '.columns-form',
name: 'values')
= submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary' = submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary'

View file

@ -59,7 +59,5 @@
- else - else
= render 'footer' = render 'footer'
- if Rails.env.development?
= vite_typescript_tag 'axe-core'
= yield :charts_js = yield :charts_js
= render Attachment::ProgressBarComponent.new = render Attachment::ProgressBarComponent.new

View file

@ -11,6 +11,8 @@ by providing a `content_for(:javascript)` block.
<%= javascript_include_tag js_path %> <%= javascript_include_tag js_path %>
<% end %> <% end %>
<%= vite_client_tag %>
<%= vite_react_refresh_tag %>
<%= vite_typescript_tag 'manager' %> <%= vite_typescript_tag 'manager' %>
<%= yield :javascript %> <%= yield :javascript %>

View file

@ -93,16 +93,15 @@ as well as a link to its edit page.
<% elsif attribute.name == 'tags' %> <% elsif attribute.name == 'tags' %>
<%= form_for procedure, url: add_tags_manager_procedure_path(procedure), html: { class: 'form procedure-form__column--form fr-background-alt--blue-france mt-1' } do %> <%= form_for procedure, url: add_tags_manager_procedure_path(procedure), html: { class: 'form procedure-form__column--form fr-background-alt--blue-france mt-1' } do %>
<%= hidden_field_tag 'procedure[tags]', nil %> <react-fragment>
<%= react_component("ComboMultiple", <%= render ReactComponent.new "ComboBox/MultiComboBox",
options: Procedure.tags, items: Procedure.tags,
selected: procedure.tags, selected_keys: procedure.tags,
disabled: [], value_separator: ',|;',
label: 'Tags', allows_custom_value: true,
group: '.procedure-form__column--form', name: 'procedure[tags][]',
name: 'tags', 'aria-label': 'Tags' %>
describedby: 'procedure-tags', </react-fragment>
acceptNewValues: true) %>
<button class="mt-1">Ajouter des tags</button> <button class="mt-1">Ajouter des tags</button>
<% end %> <% end %>

View file

@ -10,16 +10,9 @@
= render NestedForms::FormOwnerComponent.new = render NestedForms::FormOwnerComponent.new
= form_for avis, url: url, html: { multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@dossier, :avis_by_instructeur) } } do |f| = form_for avis, url: url, html: { multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@dossier, :avis_by_instructeur) } } do |f|
= hidden_field_tag 'avis[emails]', nil
.fr-input-group .fr-input-group
= react_component("ComboMultiple", %react-fragment
options: current_expert_not_instructeur? ? [] : @experts_emails, = render ReactComponent.new "ComboBox/MultiComboBox", items: current_expert_not_instructeur? ? [] : @experts_emails, name: f.field_name(:emails, multiple: true), id: 'avis_emails', 'aria-label': 'Emails', 'aria-describedby': 'avis-emails-description', allows_custom_value: !@dossier.procedure.experts_require_administrateur_invitation
selected: [], disabled: [],
label: 'Emails',
group: '.ask-avis',
name: 'emails',
describedby: 'avis-emails-description',
acceptNewValues: !@dossier.procedure.experts_require_administrateur_invitation)
.fr-input-group .fr-input-group
= f.label :introduction, t('helpers.label.introduction'), class: 'fr-label' = f.label :introduction, t('helpers.label.introduction'), class: 'fr-label'

View file

@ -1,4 +1,5 @@
- if champ.geometry? - if champ.geometry?
= react_component("MapReader", { featureCollection: champ.to_feature_collection, options: champ.render_options } ) %react-fragment.width-100
= render ReactComponent.new "MapReader", feature_collection: champ.to_feature_collection, options: champ.render_options
.geo-areas .geo-areas
= render Dossiers::GeoAreasComponent.new(champ:, editing: false) = render Dossiers::GeoAreasComponent.new(champ:, editing: false)

BIN
bun.lockb

Binary file not shown.

View file

@ -251,6 +251,7 @@ Rails.application.routes.draw do
namespace :data_sources do namespace :data_sources do
get :adresse, to: 'adresse#search', as: :data_source_adresse get :adresse, to: 'adresse#search', as: :data_source_adresse
get :commune, to: 'commune#search', as: :data_source_commune get :commune, to: 'commune#search', as: :data_source_commune
get :education, to: 'education#search', as: :data_source_education
get :search_domaine_fonct, to: 'chorus#search_domaine_fonct', as: :search_domaine_fonct get :search_domaine_fonct, to: 'chorus#search_domaine_fonct', as: :search_domaine_fonct
get :search_centre_couts, to: 'chorus#search_centre_couts', as: :search_centre_couts get :search_centre_couts, to: 'chorus#search_centre_couts', as: :search_centre_couts

View file

@ -1,9 +1,10 @@
{ {
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@coldwired/actions": "^0.11.2", "@coldwired/actions": "^0.13.0",
"@coldwired/turbo-stream": "^0.11.1", "@coldwired/react": "^0.15.0",
"@coldwired/utils": "^0.11.4", "@coldwired/turbo-stream": "^0.13.0",
"@coldwired/utils": "^0.13.0",
"@frsource/autoresize-textarea": "^2.0.75", "@frsource/autoresize-textarea": "^2.0.75",
"@gouvfr/dsfr": "^1.11.2", "@gouvfr/dsfr": "^1.11.2",
"@graphiql/plugin-explorer": "^3.0.2", "@graphiql/plugin-explorer": "^3.0.2",
@ -17,7 +18,6 @@
"@rails/actiontext": "^7.1.3-2", "@rails/actiontext": "^7.1.3-2",
"@rails/activestorage": "^7.1.3-2", "@rails/activestorage": "^7.1.3-2",
"@rails/ujs": "^7.1.3-2", "@rails/ujs": "^7.1.3-2",
"@reach/combobox": "^0.17.0",
"@reach/slider": "^0.17.0", "@reach/slider": "^0.17.0",
"@sentry/browser": "8.7.0", "@sentry/browser": "8.7.0",
"@tiptap/core": "^2.2.4", "@tiptap/core": "^2.2.4",
@ -50,31 +50,32 @@
"graphiql": "^3.2.3", "graphiql": "^3.2.3",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"highcharts": "^10.3.3", "highcharts": "^10.3.3",
"is-hotkey": "^0.2.0",
"lightgallery": "^2.7.2", "lightgallery": "^2.7.2",
"maplibre-gl": "^1.15.2", "maplibre-gl": "^1.15.2",
"match-sorter": "^6.3.4", "match-sorter": "^6.3.4",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "^18.2.0", "react": "^18.3.0",
"react-aria-components": "^1.2.0",
"react-coordinate-input": "^1.0.0", "react-coordinate-input": "^1.0.0",
"react-dom": "^18.2.0", "react-dom": "^18.3.0",
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"react-query": "^3.39.3", "react-use-event-hook": "^0.9.6",
"spectaql": "^2.3.1", "spectaql": "^2.3.1",
"stimulus-use": "^0.52.2", "stimulus-use": "^0.52.2",
"terser": "^5.31.0", "terser": "^5.31.0",
"tiny-invariant": "^1.3.3", "tiny-invariant": "^1.3.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"trix": "^1.2.3", "trix": "^1.2.3",
"use-debounce": "^9.0.4", "usehooks-ts": "^3.1.0",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@esbuild/darwin-arm64": "=0.19.9", "@esbuild/darwin-arm64": "=0.19.9",
"@esbuild/linux-x64": "=0.19.9", "@esbuild/linux-x64": "=0.19.9",
"@esbuild/win32-x64": "=0.19.9", "@esbuild/win32-x64": "=0.19.9",
"@rollup/rollup-linux-x64-gnu": "=4.9.1", "@react-aria/optimize-locales-plugin": "^1.1.0",
"@rollup/rollup-darwin-arm64": "=4.9.1", "@rollup/rollup-darwin-arm64": "=4.9.1",
"@rollup/rollup-linux-x64-gnu": "=4.9.1",
"@rollup/rollup-win32-x64-msvc": "=4.9.1", "@rollup/rollup-win32-x64-msvc": "=4.9.1",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/geojson": "^7946.0.14", "@types/geojson": "^7946.0.14",
@ -82,8 +83,8 @@
"@types/mapbox__mapbox-gl-draw": "^1.2.5", "@types/mapbox__mapbox-gl-draw": "^1.2.5",
"@types/rails__activestorage": "^7.1.1", "@types/rails__activestorage": "^7.1.1",
"@types/rails__ujs": "^6.0.4", "@types/rails__ujs": "^6.0.4",
"@types/react": "^17.0.43", "@types/react": "^18.2.79",
"@types/react-dom": "^17.0.14", "@types/react-dom": "^18.2.25",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.11.0", "@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0", "@typescript-eslint/parser": "^7.11.0",
@ -114,7 +115,8 @@
"postinstall": "patch-package", "postinstall": "patch-package",
"test": "vitest", "test": "vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"up": "bunx npm-check-updates --root --format group -i" "up": "bunx npm-check-updates --root --format group -i",
"vite-bundle-visualizer": "bunx vite-bundle-visualizer"
}, },
"resolutions": { "resolutions": {
"string-width": "4.2.2", "string-width": "4.2.2",
@ -169,6 +171,7 @@
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"plugin:react/jsx-runtime",
"prettier" "prettier"
], ],
"rules": { "rules": {

View file

@ -1,112 +0,0 @@
class Dsfr::ComboboxComponentPreview < ViewComponent::Preview
OPTIONS = [
'Cheddar',
'Brie',
'Mozzarella',
'Gouda',
'Swiss',
'Parmesan',
'Feta',
'Blue cheese',
'Camembert',
'Monterey Jack',
'Roquefort',
'Provolone',
'Colby',
'Havarti',
'Ricotta',
'Pepper Jack',
'Muenster',
'Fontina',
'Limburger',
'Asiago',
'Cottage cheese',
'Emmental',
'Mascarpone',
'Taleggio',
'Gruyere',
'Edam',
'Pecorino Romano',
'Manchego',
'Halloumi',
'Jarlsberg',
'Munster',
'Stilton',
'Gorgonzola',
'Queso blanco',
'Queso fresco',
'Queso de bola',
'Queso de cabra',
'Queso panela',
'Queso Oaxaca',
'Queso Chihuahua',
'Queso manchego',
'Queso de bola',
'Queso de bola de cabra',
'Queso de bola de vaca',
'Queso de bola de oveja',
'Queso de bola de mezcla',
'Queso de bola de leche cruda',
'Queso de bola de leche pasteurizada',
'Queso de bola de leche de cabra',
'Queso de bola de leche de vaca',
'Queso de bola de leche de oveja',
'Queso de bola de leche de mezcla',
'Burrata',
'Scamorza',
'Caciocavallo',
'Provolone piccante',
'Pecorino sardo',
'Pecorino toscano',
'Pecorino siciliano',
'Pecorino calabrese',
'Pecorino moliterno',
'Pecorino di fossa',
'Pecorino di filiano',
'Pecorino di pienza',
'Pecorino di grotta',
'Pecorino di capra',
'Pecorino di mucca',
'Pecorino di pecora',
'Pecorino di bufala',
'Cacio di bosco',
'Cacio di roma',
'Cacio di fossa',
'Cacio di tricarico',
'Cacio di cavallo',
'Cacio di capra',
'Cacio di mucca',
'Cacio di pecora',
'Cacio di bufala',
'Taleggio di capra',
'Taleggio di mucca',
'Taleggio di pecora',
'Taleggio di bufala',
'Bel Paese',
'Crescenza',
'Stracchino',
'Robiola',
'Toma',
'Bra',
'Castelmagno',
'Raschera',
'Montasio',
'Piave',
'Bitto',
'Quartirolo Lombardo',
'Formaggella del Luinese',
'Formaggella della Val Vigezzo',
'Formaggella della Valle Grana',
'Formaggella della Val Bognanco',
'Formaggella della Val dIntelvi',
'Formaggella della Val Gerola'
]
def simple_select_with_options
render Dsfr::ComboboxComponent.new(options: OPTIONS, selected: OPTIONS.sample, input_html_options: { name: :value, id: 'simple-select', class: 'width-33' })
end
def simple_select_with_options_and_allows_custom_value
render Dsfr::ComboboxComponent.new(options: OPTIONS, selected: OPTIONS.sample, allows_custom_value: true, input_html_options: { id: 'simple-select', class: 'width-33', name: :value })
end
end

View file

@ -26,7 +26,7 @@ describe Administrateurs::ExpertsProceduresController, type: :controller do
subject { post :create, params: params } subject { post :create, params: params }
context 'when inviting multiple valid experts' do context 'when inviting multiple valid experts' do
let(:params) { { procedure_id: procedure.id, emails: [expert.email, "new@expert.fr"].to_json } } let(:params) { { procedure_id: procedure.id, emails: [expert.email, "new@expert.fr"] } }
it 'creates experts' do it 'creates experts' do
subject subject
@ -38,7 +38,7 @@ describe Administrateurs::ExpertsProceduresController, type: :controller do
end end
context 'when inviting expert using an email with typos' do context 'when inviting expert using an email with typos' do
let(:params) { { procedure_id: procedure.id, emails: ['martin@oraneg.fr'].to_json } } let(:params) { { procedure_id: procedure.id, emails: ['martin@oraneg.fr'] } }
render_views render_views
it 'warns' do it 'warns' do
subject subject

View file

@ -332,14 +332,14 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
describe '#add_instructeur_procedure_non_routee' do describe '#add_instructeur_procedure_non_routee' do
# faire la meme chose sur une procedure non routee # faire la meme chose sur une procedure non routee
let(:procedure_non_routee) { create(:procedure, administrateur: admin) } let(:procedure_non_routee) { create(:procedure, administrateur: admin) }
let(:emails) { ['instructeur_3@ministere_a.gouv.fr', 'instructeur_4@ministere_b.gouv.fr'].to_json } let(:emails) { ['instructeur_3@ministere_a.gouv.fr', 'instructeur_4@ministere_b.gouv.fr'] }
let(:manager) { false } let(:manager) { false }
before { before {
procedure_non_routee.administrateurs_procedures.where(administrateur: admin).update_all(manager:) procedure_non_routee.administrateurs_procedures.where(administrateur: admin).update_all(manager:)
} }
subject { post :add_instructeur, params: { emails: emails, procedure_id: procedure_non_routee.id, id: procedure_non_routee.defaut_groupe_instructeur.id } } subject { post :add_instructeur, params: { emails: emails, procedure_id: procedure_non_routee.id, id: procedure_non_routee.defaut_groupe_instructeur.id } }
context 'when all emails are valid' do context 'when all emails are valid' do
let(:emails) { ['test@b.gouv.fr', 'test2@b.gouv.fr'].to_json } let(:emails) { ['test@b.gouv.fr', 'test2@b.gouv.fr'] }
it do it do
expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee) expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee)
expect(subject.request.flash[:alert]).to be_nil expect(subject.request.flash[:alert]).to be_nil
@ -348,7 +348,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
end end
context 'when there is at least one bad email' do context 'when there is at least one bad email' do
let(:emails) { ['badmail', 'instructeur2@gmail.com'].to_json } let(:emails) { ['badmail', 'instructeur2@gmail.com'] }
it do it do
expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee) expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee)
expect(subject.request.flash[:alert]).to be_present expect(subject.request.flash[:alert]).to be_present
@ -359,7 +359,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
context 'when the admin wants to assign an instructor who is already assigned on this procedure' do context 'when the admin wants to assign an instructor who is already assigned on this procedure' do
let(:instructeur) { create(:instructeur) } let(:instructeur) { create(:instructeur) }
before { procedure_non_routee.groupe_instructeurs.first.add_instructeurs(emails: [instructeur.user.email]) } before { procedure_non_routee.groupe_instructeurs.first.add_instructeurs(emails: [instructeur.user.email]) }
let(:emails) { [instructeur.email].to_json } let(:emails) { [instructeur.email] }
it { expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee) } it { expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee) }
end end
@ -376,7 +376,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
params: { params: {
procedure_id: procedure.id, procedure_id: procedure.id,
id: gi_1_2.id, id: gi_1_2.id,
emails: new_instructeur_emails.to_json emails: new_instructeur_emails
} }
end end

View file

@ -13,7 +13,7 @@ describe Administrateurs::ProceduresController, type: :controller do
let(:lien_site_web) { 'http://mon-site.gouv.fr' } let(:lien_site_web) { 'http://mon-site.gouv.fr' }
let(:zone) { create(:zone) } let(:zone) { create(:zone) }
let(:zone_ids) { [zone.id] } let(:zone_ids) { [zone.id] }
let(:tags) { "[\"planete\",\"environnement\"]" } let(:tags) { ["planete", "environnement"] }
describe '#apercu' do describe '#apercu' do
subject { get :apercu, params: { id: procedure.id } } subject { get :apercu, params: { id: procedure.id } }

View file

@ -367,7 +367,7 @@ describe Experts::AvisController, type: :controller do
let(:previous_avis_confidentiel) { false } let(:previous_avis_confidentiel) { false }
let(:previous_revoked_at) { nil } let(:previous_revoked_at) { nil }
let!(:previous_avis) { create(:avis, dossier:, claimant:, experts_procedure:, confidentiel: previous_avis_confidentiel, revoked_at: previous_revoked_at) } let!(:previous_avis) { create(:avis, dossier:, claimant:, experts_procedure:, confidentiel: previous_avis_confidentiel, revoked_at: previous_revoked_at) }
let(:emails) { '["a@b.com"]' } let(:emails) { ["a@b.com"] }
let(:introduction) { 'introduction' } let(:introduction) { 'introduction' }
let(:created_avis) { Avis.last } let(:created_avis) { Avis.last }
let!(:old_avis_count) { Avis.count } let!(:old_avis_count) { Avis.count }
@ -394,7 +394,7 @@ describe Experts::AvisController, type: :controller do
end end
context 'when an invalid email' do context 'when an invalid email' do
let(:emails) { "[\"toto.fr\"]" } let(:emails) { ["toto.fr"] }
it do it do
expect(response).to render_template :instruction expect(response).to render_template :instruction
@ -414,7 +414,7 @@ describe Experts::AvisController, type: :controller do
end end
context 'ask review with attachment' do context 'ask review with attachment' do
let(:emails) { "[\"toto@totomail.com\"]" } let(:emails) { ["toto@totomail.com"] }
it do it do
expect(created_avis.introduction_file).to be_attached expect(created_avis.introduction_file).to be_attached
@ -425,7 +425,7 @@ describe Experts::AvisController, type: :controller do
end end
context 'with multiple emails' do context 'with multiple emails' do
let(:emails) { "[\"toto.fr\",\"titi@titimail.com\"]" } let(:emails) { ["toto.fr", "titi@titimail.com"] }
it do it do
expect(response).to render_template :instruction expect(response).to render_template :instruction

View file

@ -28,7 +28,7 @@ describe Instructeurs::DossiersController, type: :controller do
post( post(
:send_to_instructeurs, :send_to_instructeurs,
params: { params: {
recipients: [recipient.id].to_json, recipients: [recipient.id],
procedure_id: procedure.id, procedure_id: procedure.id,
dossier_id: dossier.id dossier_id: dossier.id
} }
@ -776,7 +776,7 @@ describe Instructeurs::DossiersController, type: :controller do
} }
end end
let(:emails) { "[\"email@a.com\"]" } let(:emails) { ["email@a.com"] }
context "notifications updates" do context "notifications updates" do
context 'when an instructeur follows the dossier' do context 'when an instructeur follows the dossier' do
@ -811,7 +811,7 @@ describe Instructeurs::DossiersController, type: :controller do
it { expect(response).to redirect_to(avis_instructeur_dossier_path(dossier.procedure, dossier)) } it { expect(response).to redirect_to(avis_instructeur_dossier_path(dossier.procedure, dossier)) }
context "with an invalid email" do context "with an invalid email" do
let(:emails) { "[\"emaila.com\"]" } let(:emails) { ["emaila.com"] }
before { subject } before { subject }
@ -822,7 +822,7 @@ describe Instructeurs::DossiersController, type: :controller do
end end
context "with no email" do context "with no email" do
let(:emails) { "" } let(:emails) { [] }
before { subject } before { subject }
@ -833,7 +833,7 @@ describe Instructeurs::DossiersController, type: :controller do
end end
context 'with multiple emails' do context 'with multiple emails' do
let(:emails) { "[\"toto.fr\",\"titi@titimail.com\"]" } let(:emails) { ["toto.fr", "titi@titimail.com"] }
before { subject } before { subject }
@ -845,7 +845,7 @@ describe Instructeurs::DossiersController, type: :controller do
end end
context 'when the expert do not want to receive notification' do context 'when the expert do not want to receive notification' do
let(:emails) { "[\"email@a.com\"]" } let(:emails) { ["email@a.com"] }
let(:experts_procedure) { create(:experts_procedure, expert: expert, procedure: dossier.procedure, notify_on_new_avis: false) } let(:experts_procedure) { create(:experts_procedure, expert: expert, procedure: dossier.procedure, notify_on_new_avis: false) }
before { subject } before { subject }

File diff suppressed because one or more lines are too long

View file

@ -22,21 +22,19 @@ RSpec.describe Champs::AnnuaireEducationChamp do
it_behaves_like "a data updater (without updating the value)", '' it_behaves_like "a data updater (without updating the value)", ''
end end
context 'when data is inconsistent' do
let(:data) { { 'yo' => 'lo' } }
it_behaves_like "a data updater (without updating the value)", { 'yo' => 'lo' }
end
context 'when data is consistent' do context 'when data is consistent' do
let(:data) { let(:data) {
{ {
'nom_etablissement': "karrigel an ankou", 'nom_etablissement' => "karrigel an ankou",
'nom_commune' => 'kumun', 'nom_commune' => 'kumun',
'identifiant_de_l_etablissement' => '666667' 'identifiant_de_l_etablissement' => '666667'
}.with_indifferent_access }
}
it_behaves_like "a data updater (without updating the value)", {
'nom_etablissement' => "karrigel an ankou",
'nom_commune' => 'kumun',
'identifiant_de_l_etablissement' => '666667'
} }
it { expect { subject }.to change { champ.reload.data }.to(data) }
it { expect { subject }.to change { champ.reload.value }.to('karrigel an ankou, kumun (666667)') }
end end
end end
end end

View file

@ -5,7 +5,7 @@ describe Champs::CommuneChamp do
let(:champ) { create(:champ_communes, code_postal:, external_id: code_insee) } let(:champ) { create(:champ_communes, code_postal:, external_id: code_insee) }
describe 'value' do describe 'value' do
it 'with code_postal' do it 'find commune' do
expect(champ.to_s).to eq('Châteldon (63290)') expect(champ.to_s).to eq('Châteldon (63290)')
expect(champ.name).to eq('Châteldon') expect(champ.name).to eq('Châteldon')
expect(champ.external_id).to eq(code_insee) expect(champ.external_id).to eq(code_insee)
@ -15,15 +15,22 @@ describe Champs::CommuneChamp do
expect(champ.for_export(:value)).to eq 'Châteldon (63290)' expect(champ.for_export(:value)).to eq 'Châteldon (63290)'
expect(champ.for_export(:code)).to eq '63102' expect(champ.for_export(:code)).to eq '63102'
expect(champ.for_export(:departement)).to eq '63 Puy-de-Dôme' expect(champ.for_export(:departement)).to eq '63 Puy-de-Dôme'
expect(champ.communes.size).to eq(8)
end end
end
describe 'code_postal with spaces' do context 'with code' do
let(:code_postal) { ' 63 2 90  ' } let(:champ) { create(:champ_communes, code: '63102-63290') }
it 'with code_postal' do it 'find commune' do
expect(champ.communes.size).to eq(8) expect(champ.to_s).to eq('Châteldon (63290)')
expect(champ.name).to eq('Châteldon')
expect(champ.external_id).to eq(code_insee)
expect(champ.code).to eq(code_insee)
expect(champ.code_departement).to eq(code_departement)
expect(champ.code_postal).to eq(code_postal)
expect(champ.for_export(:value)).to eq 'Châteldon (63290)'
expect(champ.for_export(:code)).to eq '63102'
expect(champ.for_export(:departement)).to eq '63 Puy-de-Dôme'
end
end end
end end
end end

View file

@ -106,25 +106,10 @@ module SystemHelpers
end end
end end
def select_combobox(libelle, fill_with, value, check: true) def select_combobox(libelle, value, custom_value: false)
fill_in libelle, with: fill_with fill_in libelle, with: custom_value ? "#{value}," : value
find('li[role="option"][data-reach-combobox-option]', text: value, wait: 5).click if !custom_value
if check find_field(libelle).send_keys(:down, :enter)
check_selected_value(libelle, with: value)
end
end
def check_selected_value(libelle, with:)
field = find_hidden_field_for(libelle)
value = field.value.starts_with?('[') ? JSON.parse(field.value) : field.value
if value.is_a?(Array)
if with.is_a?(Array)
expect(value.sort).to eq(with.sort)
else
expect(value).to include(with)
end
else
expect(value).to eq(with)
end end
end end

View file

@ -60,7 +60,7 @@ describe 'Administrateurs can edit procedures', js: true do
procedure.update!(tags: ['social']) procedure.update!(tags: ['social'])
visit edit_admin_procedure_path(procedure) visit edit_admin_procedure_path(procedure)
select_combobox('procedure_tags_combo', 'planete', 'planete', check: false) select_combobox('procedure_tags_combo', 'planete', custom_value: true)
click_on 'Enregistrer' click_on 'Enregistrer'
expect(procedure.reload.tags).to eq(['social', 'planete']) expect(procedure.reload.tags).to eq(['social', 'planete'])

View file

@ -29,7 +29,8 @@ describe 'Inviting an expert:', js: true do
within('.fr-sidemenu') { click_on 'Demander un avis' } within('.fr-sidemenu') { click_on 'Demander un avis' }
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier)) expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
page.execute_script("document.querySelector('#avis_emails').value = '[\"#{expert.email}\",\"#{expert2.email}\"]'") fill_in 'Emails', with: "#{expert.email},"
fill_in 'Emails', with: expert2.email
fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.' fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.'
check 'avis_invite_linked_dossiers' check 'avis_invite_linked_dossiers'
page.select 'confidentiel', from: 'avis_confidentiel' page.select 'confidentiel', from: 'avis_confidentiel'
@ -109,7 +110,7 @@ describe 'Inviting an expert:', js: true do
within('.fr-sidemenu') { click_on 'Demander un avis' } within('.fr-sidemenu') { click_on 'Demander un avis' }
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier)) expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
fill_in 'Emails', with: "#{expert.email}; #{expert2.email}" select_combobox 'Emails', expert.email
fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.' fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.'
check 'avis_invite_linked_dossiers' check 'avis_invite_linked_dossiers'
page.select 'confidentiel', from: 'avis_confidentiel' page.select 'confidentiel', from: 'avis_confidentiel'

View file

@ -160,13 +160,10 @@ describe 'Instructing a dossier:', js: true do
within('.fr-sidemenu') { click_on 'Demander un avis' } within('.fr-sidemenu') { click_on 'Demander un avis' }
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier)) expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
expert_email_formated = "[\"expert@tps.com\"]"
expert_email = 'expert@tps.com' expert_email = 'expert@tps.com'
ask_confidential_avis(expert_email_formated, 'a good introduction') ask_confidential_avis(expert_email, 'a good introduction')
expert_email_formated = "[\"#{instructeur2.email}\"]" ask_confidential_avis(instructeur2.email, 'a good introduction')
expert_email = instructeur2.email
ask_confidential_avis(expert_email_formated, 'a good introduction')
click_on 'Personnes impliquées' click_on 'Personnes impliquées'
expect(page).to have_text(expert_email) expect(page).to have_text(expert_email)
@ -189,8 +186,8 @@ describe 'Instructing a dossier:', js: true do
click_on 'Personnes impliquées' click_on 'Personnes impliquées'
select_combobox('Emails', instructeur_2.email, instructeur_2.email, check: false) select_combobox('Emails', instructeur_2.email)
select_combobox('Emails', instructeur_3.email, instructeur_3.email, check: false) select_combobox('Emails', instructeur_3.email)
click_on 'Envoyer' click_on 'Envoyer'
@ -287,7 +284,7 @@ describe 'Instructing a dossier:', js: true do
end end
def ask_confidential_avis(to, introduction) def ask_confidential_avis(to, introduction)
page.execute_script("document.querySelector('#avis_emails').value = '#{to}'") fill_in 'avis_emails', with: to
fill_in 'avis_introduction', with: introduction fill_in 'avis_introduction', with: introduction
select 'confidentiel', from: 'avis_confidentiel' select 'confidentiel', from: 'avis_confidentiel'
within('form#new_avis') { click_on 'Demander un avis' } within('form#new_avis') { click_on 'Demander un avis' }

View file

@ -88,33 +88,13 @@ describe "procedure filters" do
scenario "should be able to user custom fiters", js: true do scenario "should be able to user custom fiters", js: true do
# use date filter # use date filter
click_on 'Sélectionner un filtre' add_filter("En construction le", "10/10/2010", type: :date)
wait_until { all("#search-filter").size == 1 }
find('#search-filter', wait: 5).click
find('.fr-menu__item', text: "En construction le", wait: 5).click
find("input#value[type=date]", visible: true)
fill_in "Valeur", with: "10/10/2010"
click_button "Ajouter le filtre"
expect(page).to have_no_css("#search-filter", visible: true)
# use statut dropdown filter # use statut dropdown filter
click_on 'Sélectionner un filtre' add_filter('Statut', 'En construction', type: :enum)
wait_until { all("#search-filter").size == 1 }
find('#search-filter', wait: 5).click
find('.fr-menu__item', text: "Statut", wait: 5).click
find("select#value", visible: false)
select 'En construction', from: "Valeur"
click_button "Ajouter le filtre"
expect(page).to have_no_css("#search-filter", visible: true)
# use choice dropdown filter # use choice dropdown filter
click_on 'Sélectionner un filtre' add_filter('Choix unique', 'val1', type: :enum)
wait_until { all("#search-filter").size == 1 }
find('#search-filter', wait: 5).click
find('.fr-menu__item', text: "Choix unique", wait: 5).click
find("select#value", visible: false)
select 'val1', from: "Valeur"
click_button "Ajouter le filtre"
end end
describe 'with a vcr cached cassette' do describe 'with a vcr cached cassette' do
@ -124,14 +104,7 @@ describe "procedure filters" do
departement_champ.reload departement_champ.reload
champ_select_value = "#{departement_champ.external_id} #{departement_champ.value}" champ_select_value = "#{departement_champ.external_id} #{departement_champ.value}"
click_on 'Sélectionner un filtre' add_filter(departement_champ.libelle, champ_select_value, type: :enum)
wait_until { all("#search-filter").size == 1 }
find('#search-filter', wait: 5).click
find('.fr-menu__item', text: departement_champ.libelle, wait: 5).click
find("select#value", visible: true)
select champ_select_value, from: "Valeur"
click_button "Ajouter le filtre"
find("select#value", visible: false) # w8 for filter to be applied
expect(page).to have_link(new_unfollow_dossier.id.to_s) expect(page).to have_link(new_unfollow_dossier.id.to_s)
end end
@ -140,14 +113,7 @@ describe "procedure filters" do
region_champ.update!(value: 'Bretagne', external_id: '53') region_champ.update!(value: 'Bretagne', external_id: '53')
region_champ.reload region_champ.reload
click_on 'Sélectionner un filtre' add_filter(region_champ.libelle, region_champ.value, type: :enum)
wait_until { all("#search-filter").size == 1 }
find('#search-filter', wait: 5).click
find('.fr-menu__item', text: region_champ.libelle, wait: 5).click
find("select#value", visible: true)
select region_champ.value, from: "Valeur"
click_button "Ajouter le filtre"
find("select#value", visible: false) # w8 for filter to be applied
expect(page).to have_link(new_unfollow_dossier.id.to_s) expect(page).to have_link(new_unfollow_dossier.id.to_s)
end end
end end
@ -155,7 +121,7 @@ describe "procedure filters" do
scenario "should be able to add and remove two filters for the same field", js: true do scenario "should be able to add and remove two filters for the same field", js: true do
add_filter(type_de_champ.libelle, champ.value) add_filter(type_de_champ.libelle, champ.value)
add_filter(type_de_champ.libelle, champ_2.value) add_filter(type_de_champ.libelle, champ_2.value)
add_enum_filter('Groupe instructeur', procedure.groupe_instructeurs.first.label) add_filter('Groupe instructeur', procedure.groupe_instructeurs.first.label, type: :enum)
within ".dossiers-table" do within ".dossiers-table" do
expect(page).to have_link(new_unfollow_dossier.id.to_s, exact: true) expect(page).to have_link(new_unfollow_dossier.id.to_s, exact: true)
@ -185,40 +151,40 @@ describe "procedure filters" do
end end
end end
def add_filter(column_name, filter_value, type: :text)
click_on 'Sélectionner un filtre'
wait_until { all("#search-filter").size == 1 }
find('#search-filter + button', wait: 5).click
find('.fr-menu__item', text: column_name, wait: 5).click
case type
when :text
fill_in "Valeur", with: filter_value
when :date
find("input#value[type=date]", visible: true)
fill_in "Valeur", with: filter_value
when :enum
find("select#value", visible: false)
select filter_value, from: "Valeur"
end
click_button "Ajouter le filtre"
expect(page).to have_no_css("#search-filter", visible: true)
end
def remove_filter(filter_value) def remove_filter(filter_value)
click_link text: filter_value click_link text: filter_value
end end
def add_filter(column_name, filter_value)
click_on 'Sélectionner un filtre'
wait_until { all("#search-filter").size == 1 }
find('#search-filter', wait: 5).click
find('.fr-menu__item', text: column_name, wait: 5).click
fill_in "Valeur", with: filter_value
click_button "Ajouter le filtre"
expect(page).to have_no_css("#search-filter", visible: true)
end
def add_enum_filter(column_name, filter_value)
click_on 'Sélectionner un filtre'
wait_until { all("#search-filter").size == 1 }
find('#search-filter', wait: 5).click
find('.fr-menu__item', text: column_name, wait: 5).click
select filter_value, from: "Valeur"
click_button "Ajouter le filtre"
expect(page).to have_no_css("#search-filter", visible: true)
end
def add_column(column_name) def add_column(column_name)
click_on 'Personnaliser' click_on 'Personnaliser'
select_combobox('Colonne à afficher', column_name, column_name, check: false) select_combobox('Colonne à afficher', column_name)
click_button "Enregistrer" click_button "Enregistrer"
end end
def remove_column(column_name) def remove_column(column_name)
click_on 'Personnaliser' click_on 'Personnaliser'
click_button column_name within '.fr-tag-list' do
find("body").native.send_key("Escape") find('.fr-tag', text: column_name).find('button').click
end
click_button "Enregistrer" click_button "Enregistrer"
end end
end end

Some files were not shown because too many files have changed in this diff Show more