Merge branch 'main' of github.com:betagouv/demarches-simplifiees.fr into instructeur-invitation-include-typo-suggestion-ldu
This commit is contained in:
commit
42633c0012
138 changed files with 2056 additions and 3526 deletions
|
@ -28,3 +28,7 @@ body {
|
|||
.container {
|
||||
@extend %container;
|
||||
}
|
||||
|
||||
react-fragment {
|
||||
display: block;
|
||||
}
|
||||
|
|
|
@ -10,10 +10,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.map-style-control {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
|
|
|
@ -32,29 +32,87 @@ trix-editor.fr-input {
|
|||
}
|
||||
|
||||
.fr-ds-combobox {
|
||||
.fr-menu {
|
||||
width: 100%;
|
||||
|
||||
.fr-menu__list {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
||||
.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) {
|
||||
.fr-ds-combobox .fr-menu .fr-menu__list {
|
||||
z-index: calc(var(--ground) + 1000);
|
||||
background-color: var(--background-default-grey);
|
||||
--idle: transparent;
|
||||
--hover: var(--background-overlap-grey-hover);
|
||||
--active: var(--background-overlap-grey-active);
|
||||
filter: drop-shadow(var(--overlap-shadow));
|
||||
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
||||
.fr-ds-combobox__menu {
|
||||
&.fr-menu .fr-menu__list {
|
||||
z-index: calc(var(--ground) + 1000);
|
||||
background-color: var(--background-default-grey);
|
||||
--idle: transparent;
|
||||
--hover: var(--background-overlap-grey-hover);
|
||||
--active: var(--background-overlap-grey-active);
|
||||
filter: drop-shadow(var(--overlap-shadow));
|
||||
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -356,41 +356,6 @@
|
|||
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 {
|
||||
&:not(.editable-champ-carte) .algolia-autocomplete {
|
||||
margin-bottom: 2 * $default-padding;
|
||||
|
@ -524,91 +489,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-react-component-value^="ComboMultiple"] {
|
||||
.fr-ds-combobox__multiple {
|
||||
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 {
|
||||
|
@ -634,10 +516,6 @@ textarea::placeholder {
|
|||
.fr-menu__item {
|
||||
list-style-type: none;
|
||||
margin-bottom: $default-spacer;
|
||||
|
||||
&[aria-selected] {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,36 +1,5 @@
|
|||
@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 {
|
||||
display: none;
|
||||
}
|
||||
|
@ -70,4 +39,79 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,41 +9,7 @@
|
|||
margin-left: 16px;
|
||||
}
|
||||
|
||||
[data-react-component-value^="ComboMultiple"] {
|
||||
.fr-ds-combobox__multiple {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,45 +45,8 @@
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
[data-react-component-value^="ComboMultiple"] {
|
||||
.fr-ds-combobox__multiple {
|
||||
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
|
||||
|
|
|
@ -10,7 +10,7 @@ class Attachment::EditComponent < ApplicationComponent
|
|||
|
||||
EXTENSIONS_ORDER = ['jpeg', 'png', 'pdf', 'zip'].freeze
|
||||
|
||||
def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], **kwargs)
|
||||
def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], max: nil, **kwargs)
|
||||
@champ = champ
|
||||
@attached_file = attached_file
|
||||
@direct_upload = direct_upload
|
||||
|
@ -24,6 +24,7 @@ class Attachment::EditComponent < ApplicationComponent
|
|||
@attachments = attachments.presence || (kwargs.key?(:attachment) ? [kwargs.delete(:attachment)] : [])
|
||||
@attachments << attached_file.attachment if attached_file.respond_to?(:attachment) && @attachments.empty?
|
||||
@attachments.compact!
|
||||
@max = max
|
||||
|
||||
# Utilisation du premier attachement comme référence pour la rétrocompatibilité
|
||||
@attachment = @attachments.first
|
||||
|
@ -54,7 +55,7 @@ class Attachment::EditComponent < ApplicationComponent
|
|||
end
|
||||
|
||||
def destroy_attachment_path
|
||||
attachment_path(champ_id: champ&.public_id)
|
||||
attachment_path(dossier_id: champ&.dossier_id, stable_id: champ&.stable_id, row_id: champ&.row_id)
|
||||
end
|
||||
|
||||
def attachment_input_class
|
||||
|
@ -63,6 +64,7 @@ class Attachment::EditComponent < ApplicationComponent
|
|||
|
||||
def file_field_options
|
||||
track_issue_with_missing_validators if missing_validators?
|
||||
|
||||
options = {
|
||||
class: class_names("fr-upload attachment-input": true, "#{attachment_input_class}": true, "hidden": persisted?),
|
||||
direct_upload: @direct_upload,
|
||||
|
@ -76,6 +78,7 @@ class Attachment::EditComponent < ApplicationComponent
|
|||
|
||||
options.merge!(has_content_type_validator? ? { accept: accept_content_type } : {})
|
||||
options[:multiple] = true if as_multiple?
|
||||
options[:disabled] = true if @max && @index >= @max
|
||||
|
||||
options
|
||||
end
|
||||
|
|
|
@ -30,10 +30,6 @@ class Attachment::MultipleComponent < ApplicationComponent
|
|||
@attachments.each_with_index(&block)
|
||||
end
|
||||
|
||||
def can_attach_next?
|
||||
@attachments.count < @max
|
||||
end
|
||||
|
||||
def empty_component_id
|
||||
champ.present? ? "attachment-multiple-empty-#{champ.public_id}" : "attachment-multiple-empty-generic"
|
||||
end
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
%li{ id: dom_id(attachment) }
|
||||
= render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, view_as:, user_can_destroy:, form_object_name:)
|
||||
|
||||
%div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?), data: { turbo_force: :server } }
|
||||
= render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:)
|
||||
%div{ id: empty_component_id, data: { turbo_force: :server } }
|
||||
= render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:, max: @max)
|
||||
|
||||
// single poll and refresh message for all attachments
|
||||
= render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:, context: poll_context)
|
||||
|
|
|
@ -8,10 +8,6 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
|
|||
|
||||
attr_reader :procedure, :procedure_presentation, :statut, :field_id
|
||||
|
||||
def filterable_fields_for_select
|
||||
procedure_presentation.filterable_fields_options
|
||||
end
|
||||
|
||||
def field_type
|
||||
return :text if field_id.nil?
|
||||
procedure_presentation.field_type(field_id)
|
||||
|
@ -20,4 +16,16 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
|
|||
def options_for_select_of_field
|
||||
procedure_presentation.field_enum(field_id)
|
||||
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
|
||||
|
|
|
@ -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
|
||||
.fr-select-group
|
||||
= 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,
|
||||
options: filterable_fields_for_select,
|
||||
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' } }
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/SingleComboBox", **filter_react_props
|
||||
|
||||
%input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } }
|
||||
|
||||
|
|
|
@ -2,6 +2,17 @@
|
|||
class Dsfr::AlertComponent < ApplicationComponent
|
||||
renders_one :body
|
||||
|
||||
attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level
|
||||
|
||||
def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3')
|
||||
@state = state
|
||||
@title = title
|
||||
@size = size
|
||||
@block = block
|
||||
@extra_class_names = extra_class_names
|
||||
@heading_level = heading_level
|
||||
end
|
||||
|
||||
def prefix_for_state
|
||||
case state
|
||||
when :error then "Erreur : "
|
||||
|
@ -19,19 +30,4 @@ class Dsfr::AlertComponent < ApplicationComponent
|
|||
extra_class_names => true
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3')
|
||||
@state = state
|
||||
@title = title
|
||||
@size = size
|
||||
@block = block
|
||||
@extra_class_names = extra_class_names
|
||||
@heading_level = heading_level
|
||||
end
|
||||
|
||||
attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level
|
||||
|
||||
private
|
||||
end
|
||||
|
|
|
@ -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"
|
|
@ -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é"
|
|
@ -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
|
|
@ -73,22 +73,26 @@ module Dsfr
|
|||
}
|
||||
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[: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({
|
||||
'fr-password__input': password?,
|
||||
'fr-input': true,
|
||||
'fr-input': !react,
|
||||
'fr-mb-0': true
|
||||
}.merge(input_error_class_names)))
|
||||
if errors_on_attribute?
|
||||
@opts.deep_merge!(aria: { describedby: describedby_id })
|
||||
@opts.deep_merge!('aria-describedby': describedby_id)
|
||||
elsif hintable?
|
||||
@opts.deep_merge!(aria: { describedby: hint_id })
|
||||
@opts.deep_merge!('aria-describedby': hint_id)
|
||||
end
|
||||
|
||||
if @required
|
||||
@opts[:required] = true
|
||||
@opts[react ? :is_required : :required] = true
|
||||
end
|
||||
|
||||
if email?
|
||||
|
|
|
@ -2,4 +2,15 @@ class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponen
|
|||
def dsfr_input_classname
|
||||
'fr-select'
|
||||
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
|
||||
|
|
|
@ -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
|
||||
= @form.hidden_field :external_id, data: { value_slot: 'value' }
|
||||
= @form.hidden_field :feature, data: { value_slot: 'data' }
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
|
||||
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: @form.field_name(:feature)
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent
|
||||
class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
|
||||
def dsfr_input_classname
|
||||
'fr-input'
|
||||
'fr-select'
|
||||
end
|
||||
|
||||
def react_input_opts
|
||||
opts = input_opts(id: @champ.input_id, required: @champ.required?, aria: { describedby: @champ.describedby_id })
|
||||
opts[:className] = "#{opts.delete(:class)} fr-mt-1w"
|
||||
|
||||
opts
|
||||
def react_props
|
||||
react_input_opts(id: @champ.input_id,
|
||||
class: "fr-mt-1w",
|
||||
name: @form.field_name(:external_id),
|
||||
selected_key: @champ.external_id,
|
||||
items: @champ.selected_items,
|
||||
loader: data_sources_data_source_education_path,
|
||||
minimum_input_length: 3)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
- render_parent
|
||||
|
||||
= @form.hidden_field :value
|
||||
= @form.hidden_field :external_id
|
||||
= react_component("ComboAnnuaireEducationSearch",
|
||||
**react_input_opts,
|
||||
**react_combo_props)
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
|
||||
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :label, name: @form.field_name(:value)
|
||||
|
|
|
@ -4,10 +4,14 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
|
|||
:fieldset
|
||||
end
|
||||
|
||||
def initialize(**args)
|
||||
super(**args)
|
||||
|
||||
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args)
|
||||
def react_props
|
||||
{
|
||||
feature_collection: @champ.to_feature_collection,
|
||||
champ_id: @champ.input_id,
|
||||
url: update_path,
|
||||
adresse_source: data_sources_data_source_adresse_path,
|
||||
options: @champ.render_options
|
||||
}
|
||||
end
|
||||
|
||||
def update_path
|
||||
|
|
|
@ -1,14 +1,6 @@
|
|||
.fr-fieldset__element
|
||||
= render @autocomplete_component
|
||||
|
||||
= 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'})
|
||||
%react-fragment.width-100
|
||||
= render ReactComponent.new "MapEditor", **react_props
|
||||
|
||||
.geo-areas{ id: dom_id(@champ, :geo_areas) }
|
||||
= render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true)
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -4,4 +4,15 @@ class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseCompone
|
|||
def dsfr_input_classname
|
||||
'fr-select'
|
||||
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
|
||||
|
|
|
@ -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
|
||||
= @form.hidden_field :code_postal, data: { value_slot: 'data:string' }
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props
|
||||
|
|
|
@ -23,4 +23,13 @@ class EditableChamp::DropDownListComponent < EditableChamp::EditableChampBaseCom
|
|||
max_length = 100
|
||||
@champ.enabled_non_empty_options.any? { _1.size > max_length }
|
||||
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
|
||||
|
|
|
@ -18,7 +18,8 @@
|
|||
%label.fr-label{ for: dom_id(@champ, "radio_option_other") }
|
||||
= t('shared.champs.drop_down_list.other')
|
||||
- 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
|
||||
= @form.select :value,
|
||||
@champ.enabled_non_empty_options(other: true),
|
||||
|
|
|
@ -14,6 +14,20 @@ class Procedure::ChorusFormComponent < ApplicationComponent
|
|||
}
|
||||
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)
|
||||
case attribute_name
|
||||
when :centre_de_cout
|
||||
|
@ -30,13 +44,23 @@ class Procedure::ChorusFormComponent < ApplicationComponent
|
|||
def format_hidden_value(attribute_name)
|
||||
case attribute_name
|
||||
when :centre_de_cout
|
||||
@chorus_configuration.centre_de_cout.to_json
|
||||
@chorus_configuration.centre_de_cout
|
||||
when :domaine_fonctionnel
|
||||
@chorus_configuration.domaine_fonctionnel.to_json
|
||||
@chorus_configuration.domaine_fonctionnel
|
||||
when :referentiel_de_programmation
|
||||
@chorus_configuration.referentiel_de_programmation.to_json
|
||||
@chorus_configuration.referentiel_de_programmation
|
||||
else
|
||||
raise 'unknown attribute_name'
|
||||
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
|
||||
|
|
|
@ -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|
|
||||
- label_id = "#{chorus_configuration_attribute}-label"
|
||||
.fr-select-group
|
||||
= f.label chorus_configuration_attribute, class: 'fr-label', id: label_id
|
||||
= render Dsfr::ComboboxComponent.new form: f,
|
||||
url: datasource_endpoint,
|
||||
selected: format_displayed_value(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.label chorus_configuration_attribute, class: 'fr-label', id: label_id, for: chorus_configuration_attribute
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props(f.field_name(:chorus_configuration_attribute), chorus_configuration_attribute, datasource_endpoint) do
|
||||
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: f.field_name(chorus_configuration_attribute)
|
||||
|
||||
= f.submit "Enregister", class: 'fr-btn'
|
||||
|
|
21
app/components/procedure_draft_warning_component.rb
Normal file
21
app/components/procedure_draft_warning_component.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ProcedureDraftWarningComponent < ApplicationComponent
|
||||
attr_reader :revision
|
||||
attr_reader :current_administrateur
|
||||
attr_reader :extra_class_names
|
||||
|
||||
def initialize(revision:, current_administrateur:, extra_class_names: nil)
|
||||
@revision = revision
|
||||
@current_administrateur = current_administrateur
|
||||
@extra_class_names = extra_class_names
|
||||
end
|
||||
|
||||
def render?
|
||||
revision.draft?
|
||||
end
|
||||
|
||||
def admin?
|
||||
current_administrateur.present? && revision.procedure.administrateurs.include?(current_administrateur)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
en:
|
||||
title: Procedure in testing
|
||||
intro_procedure_brouillon_html: This procedure is currently in <b>testing</b>
|
||||
intro_revision_draft_html: This page allows you to <b>test</b> a new version of the procedure
|
||||
body_general_html: |
|
||||
and this page is reserved for the administration in charge of its deployment.
|
||||
If you start or submit a file, it may be <b>deleted at any time</b> without notice, even if it is accepted later.
|
||||
body_user: |
|
||||
If this link was shared with you, please contact the service in charge of this procedure
|
||||
to obtain the public link for the procedure in order to submit your application.
|
||||
body_admin_procedure_brouillon: Do not share this link with your users. When you publish the procedure, you will access the public link for the procedure to be shared.
|
||||
body_admin_revision_draft: Do not share this link with your users, but rather the public link for the procedure displayed in your administrator dashboard.
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
fr:
|
||||
title: Démarche en test
|
||||
intro_procedure_brouillon_html: Cette démarche est actuellement en <b>test</b>
|
||||
intro_revision_draft_html: Cette page permet de <b>tester</b> une nouvelle version de la démarche
|
||||
body_general_html: |
|
||||
et cette page est réservée à l’administration en charge de son déploiement.
|
||||
Si vous commencez ou déposez un dossier, il pourra être <b>supprimé à tout moment</b> et sans préavis, même après avoir été accepté.
|
||||
body_user: |
|
||||
Si ce lien vous a été communiqué, contactez le service en charge de cette démarche
|
||||
pour obtenir le lien public de la démarche afin de déposer votre dossier.
|
||||
body_admin_procedure_brouillon: Ne communiquez pas ce lien à vos usagers. Lorsque vous publierez la démarche, vous accéderez au lien public de la démarche à communiquer.
|
||||
body_admin_revision_draft: Ne communiquez pas ce lien à vos usagers, mais le lien public de la démarche affiché dans votre tableau de bord administrateur.
|
|
@ -0,0 +1,10 @@
|
|||
= render Dsfr::AlertComponent.new(state: :warning, extra_class_names:, title: t(".title")) do |c|
|
||||
- c.with_body do
|
||||
%p
|
||||
= revision.procedure.brouillon? ? t(".intro_procedure_brouillon_html") : t(".intro_revision_draft_html")
|
||||
= t(".body_general_html")
|
||||
|
||||
- if admin?
|
||||
%p= revision.procedure.brouillon? ? t(".body_admin_procedure_brouillon") : t(".body_admin_revision_draft")
|
||||
- else
|
||||
%p= t(".body_user")
|
14
app/components/react_component.rb
Normal file
14
app/components/react_component.rb
Normal 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
|
|
@ -527,11 +527,10 @@ module Administrateurs
|
|||
:accuse_lecture,
|
||||
:api_entreprise_token,
|
||||
:duree_conservation_dossiers_dans_ds,
|
||||
{ zone_ids: [] },
|
||||
:lien_dpo,
|
||||
:opendata,
|
||||
:procedure_expires_when_termine_enabled,
|
||||
:tags
|
||||
{ zone_ids: [], tags: [] }
|
||||
]
|
||||
|
||||
editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple?
|
||||
|
@ -544,9 +543,6 @@ module Administrateurs
|
|||
if permited_params[:auto_archive_on].present?
|
||||
permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day
|
||||
end
|
||||
if permited_params[:tags].present?
|
||||
permited_params[:tags] = JSON.parse(permited_params[:tags])
|
||||
end
|
||||
permited_params
|
||||
end
|
||||
|
||||
|
|
|
@ -12,8 +12,8 @@ class AgentConnect::AgentController < ApplicationController
|
|||
def login
|
||||
uri, state, nonce = AgentConnectService.authorization_uri
|
||||
|
||||
cookies.encrypted[STATE_COOKIE_NAME] = state
|
||||
cookies.encrypted[NONCE_COOKIE_NAME] = nonce
|
||||
cookies.encrypted[STATE_COOKIE_NAME] = { value: state, secure: Rails.env.production?, httponly: true }
|
||||
cookies.encrypted[NONCE_COOKIE_NAME] = { value: nonce, secure: Rails.env.production?, httponly: true }
|
||||
|
||||
redirect_to uri, allow_other_host: true
|
||||
end
|
||||
|
|
|
@ -117,7 +117,7 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
def set_locale(locale)
|
||||
if locale && locale.to_sym.in?(I18n.available_locales)
|
||||
cookies[:locale] = locale
|
||||
cookies[:locale] = { value: locale, secure: Rails.env.production?, httponly: true }
|
||||
if user_signed_in?
|
||||
current_user.update(locale: locale)
|
||||
end
|
||||
|
|
|
@ -24,7 +24,8 @@ module ApplicationController::LongLivedAuthenticityToken
|
|||
cookies.signed[COOKIE_NAME] = {
|
||||
value: csrf_token,
|
||||
expires: 1.year.from_now,
|
||||
httponly: true
|
||||
httponly: true,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
session[:_csrf_token] = csrf_token
|
||||
|
||||
|
|
|
@ -21,11 +21,18 @@ class AttachmentsController < ApplicationController
|
|||
@attachment.purge_later
|
||||
flash.notice = 'La pièce jointe a bien été supprimée.'
|
||||
|
||||
@champ_id = params[:champ_id]
|
||||
@champ = find_champ if params[:dossier_id]
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_back(fallback_location: root_url) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_champ
|
||||
dossier = policy_scope(Dossier).includes(:champs).find(params[:dossier_id])
|
||||
dossier.champs.find_by(stable_id: params[:stable_id], row_id: params[:row_id])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,7 +4,7 @@ module CreateAvisConcern
|
|||
private
|
||||
|
||||
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)
|
||||
errors = avis.errors
|
||||
errors.add(:emails, :blank)
|
||||
|
@ -19,8 +19,8 @@ module CreateAvisConcern
|
|||
# the :emails parameter is a 1-element array.
|
||||
# Hence the call to first
|
||||
# https://github.com/rails/rails/issues/17225
|
||||
expert_emails = create_avis_params[:emails].presence || [].to_json
|
||||
expert_emails = JSON.parse(expert_emails).map(&:strip).map(&:downcase)
|
||||
expert_emails = create_avis_params[:emails].presence || []
|
||||
expert_emails = expert_emails.map(&:strip).map(&:downcase)
|
||||
allowed_dossiers = [dossier]
|
||||
|
||||
if create_avis_params[:invite_linked_dossiers].present?
|
||||
|
@ -84,6 +84,6 @@ module CreateAvisConcern
|
|||
end
|
||||
|
||||
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
|
||||
|
|
|
@ -61,11 +61,18 @@ class DataSources::CommuneController < ApplicationController
|
|||
else
|
||||
[item]
|
||||
end.map do |item|
|
||||
{
|
||||
label: "#{item[:name]} (#{item[:postal_code]})",
|
||||
value: item[:code],
|
||||
data: item[:postal_code]
|
||||
}
|
||||
if params[:with_combined_code].present?
|
||||
{
|
||||
label: "#{item[:name]} (#{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
|
||||
|
|
38
app/controllers/data_sources/education_controller.rb
Normal file
38
app/controllers/data_sources/education_controller.rb
Normal 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
|
|
@ -6,8 +6,8 @@ module Gestionnaires
|
|||
end
|
||||
|
||||
def create
|
||||
emails = [params.require(:administrateur)[:email]].to_json
|
||||
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||
emails = [params.require(:administrateur)[:email]].compact
|
||||
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||
|
||||
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)
|
||||
|
|
|
@ -6,8 +6,8 @@ module Gestionnaires
|
|||
end
|
||||
|
||||
def create
|
||||
emails = [params.require(:gestionnaire)[:email]].to_json
|
||||
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||
emails = [params.require(:gestionnaire)[:email]].compact
|
||||
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||
|
||||
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)
|
||||
|
|
|
@ -86,9 +86,9 @@ module Instructeurs
|
|||
end
|
||||
|
||||
def send_to_instructeurs
|
||||
recipients = params['recipients'].presence || [].to_json
|
||||
recipients = params['recipients'].presence || []
|
||||
# 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?
|
||||
recipients.each do |recipient|
|
||||
|
@ -401,6 +401,7 @@ module Instructeurs
|
|||
:value,
|
||||
:value_other,
|
||||
:external_id,
|
||||
:code,
|
||||
:primary_value,
|
||||
:secondary_value,
|
||||
:numero_allocataire,
|
||||
|
|
|
@ -73,7 +73,6 @@ module Instructeurs
|
|||
|
||||
@current_filters = current_filters
|
||||
@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
|
||||
.dossiers_count_summary(groupe_instructeur_ids)
|
||||
.symbolize_keys
|
||||
|
@ -135,8 +134,8 @@ module Instructeurs
|
|||
end
|
||||
|
||||
def update_displayed_fields
|
||||
values = params['values'].presence || [].to_json
|
||||
procedure_presentation.update_displayed_fields(JSON.parse(values))
|
||||
values = params['values'].presence || []
|
||||
procedure_presentation.update_displayed_fields(values)
|
||||
|
||||
redirect_back(fallback_location: instructeur_procedure_url(procedure))
|
||||
end
|
||||
|
@ -248,7 +247,9 @@ module Instructeurs
|
|||
@export_templates = current_instructeur.export_templates_for(@procedure).includes(:groupe_instructeur)
|
||||
cookies.encrypted[cookies_export_key] = {
|
||||
value: DateTime.current,
|
||||
expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT
|
||||
expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT,
|
||||
httponly: true,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
|
||||
respond_to do |format|
|
||||
|
|
|
@ -2,8 +2,8 @@ module Manager
|
|||
class GroupeGestionnairesController < Manager::ApplicationController
|
||||
def add_gestionnaire
|
||||
groupe_gestionnaire = GroupeGestionnaire.find(params[:id])
|
||||
emails = [params['emails'].presence || ''].to_json
|
||||
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||
emails = [params['emails']].compact
|
||||
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||
|
||||
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)
|
||||
|
|
|
@ -111,8 +111,7 @@ module Manager
|
|||
end
|
||||
|
||||
def add_tags
|
||||
tags_h = { tags: JSON.parse(tags_params[:tags]) }
|
||||
if procedure.update(tags_h)
|
||||
if procedure.update(tags: tags_params[:tags])
|
||||
flash.notice = "Le modèle est mis à jour."
|
||||
else
|
||||
flash.alert = procedure.errors.full_messages.join(', ')
|
||||
|
@ -181,7 +180,7 @@ module Manager
|
|||
end
|
||||
|
||||
def tags_params
|
||||
params.require(:procedure).permit(:tags)
|
||||
params.require(:procedure).permit(tags: [])
|
||||
end
|
||||
|
||||
def template_params
|
||||
|
|
|
@ -494,6 +494,7 @@ module Users
|
|||
:value,
|
||||
:value_other,
|
||||
:external_id,
|
||||
:code,
|
||||
:primary_value,
|
||||
:secondary_value,
|
||||
:numero_allocataire,
|
||||
|
|
|
@ -59,10 +59,6 @@ module ApplicationHelper
|
|||
'alert'
|
||||
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
|
||||
current_user&.email ||
|
||||
current_instructeur&.email ||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
347
app/javascript/components/ComboBox.tsx
Normal file
347
app/javascript/components/ComboBox.tsx
Normal file
|
@ -0,0 +1,347 @@
|
|||
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 * as s from 'superstruct';
|
||||
|
||||
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(() => s.create(maybeProps, SingleComboBoxProps), [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(() => s.create(maybeProps, MultiComboBoxProps), [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(() => s.create(maybeProps, RemoteComboBoxProps), [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];
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
12
app/javascript/components/Layout.tsx
Normal file
12
app/javascript/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,33 +1,33 @@
|
|||
import React from 'react';
|
||||
import { fire } from '@utils';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
import ComboAdresseSearch from '../../ComboAdresseSearch';
|
||||
import { ComboSearchProps } from '~/components/ComboSearch';
|
||||
import { RemoteComboBox } from '../../ComboBox';
|
||||
|
||||
export function AddressInput(
|
||||
comboProps: Pick<
|
||||
ComboSearchProps,
|
||||
'screenReaderInstructions' | 'announceTemplateId'
|
||||
> & { featureCollection: FeatureCollection; champId: string }
|
||||
) {
|
||||
export function AddressInput({
|
||||
source,
|
||||
featureCollection,
|
||||
champId
|
||||
}: {
|
||||
source: string;
|
||||
featureCollection: FeatureCollection;
|
||||
champId: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '10px'
|
||||
}}
|
||||
>
|
||||
<ComboAdresseSearch
|
||||
className="fr-input fr-mt-1w"
|
||||
allowInputValues={false}
|
||||
id={comboProps.champId}
|
||||
onChange={(_, feature) => {
|
||||
fire(document, 'map:zoom', {
|
||||
featureCollection: comboProps.featureCollection,
|
||||
feature
|
||||
});
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<RemoteComboBox
|
||||
minimumInputLength={2}
|
||||
id={champId}
|
||||
loader={source}
|
||||
label="Rechercher une Adresse"
|
||||
description="Saisissez au moins 2 caractères"
|
||||
onChange={(item) => {
|
||||
if (item && item.data) {
|
||||
fire(document, 'map:zoom', {
|
||||
featureCollection,
|
||||
feature: item.data
|
||||
});
|
||||
}
|
||||
}}
|
||||
{...comboProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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 invariant from 'tiny-invariant';
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useId } from 'react';
|
||||
import { useState, useId } from 'react';
|
||||
import { fire } from '@utils';
|
||||
import type { Feature, FeatureCollection } from 'geojson';
|
||||
import CoordinateInput from 'react-coordinate-input';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { CursorClickIcon } from '@heroicons/react/outline';
|
||||
import 'maplibre-gl/dist/maplibre-gl.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 { ImportFileInput } from './components/ImportFileInput';
|
||||
import { FlashMessage } from '../shared/FlashMessage';
|
||||
import { ComboSearchProps } from '../ComboSearch';
|
||||
|
||||
export default function MapEditor({
|
||||
featureCollection: initialFeatureCollection,
|
||||
url,
|
||||
adresseSource,
|
||||
options,
|
||||
autocompleteAnnounceTemplateId,
|
||||
autocompleteScreenReaderInstructions,
|
||||
champId
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
url: string;
|
||||
adresseSource: string;
|
||||
options: { layers: string[] };
|
||||
autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId'];
|
||||
autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions'];
|
||||
champId: string;
|
||||
}) {
|
||||
const [cadastreEnabled, setCadastreEnabled] = useState(false);
|
||||
|
@ -41,15 +38,10 @@ export default function MapEditor({
|
|||
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
||||
|
||||
<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
|
||||
source={adresseSource}
|
||||
champId={champId}
|
||||
featureCollection={featureCollection}
|
||||
screenReaderInstructions={autocompleteScreenReaderInstructions}
|
||||
announceTemplateId={autocompleteAnnounceTemplateId}
|
||||
/>
|
||||
|
||||
<MapLibre layers={options.layers}>
|
||||
|
|
|
@ -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 type { Feature, FeatureCollection, Point } from 'geojson';
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
|
|
486
app/javascript/components/react-aria/hooks.ts
Normal file
486
app/javascript/components/react-aria/hooks.ts
Normal file
|
@ -0,0 +1,486 @@
|
|||
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 * as s from 'superstruct';
|
||||
|
||||
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 | null;
|
||||
onChange?: (item: Item | null) => void;
|
||||
}) {
|
||||
const [selectedKey, setSelectedKey] = useState(defaultSelectedKey);
|
||||
const items = useMemo(
|
||||
() => (defaultItems ? distinctBy(defaultItems, 'value') : []),
|
||||
[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 = key
|
||||
? items.find((item) => item.value == key)?.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 ? distinctBy(defaultItems, 'value') : []),
|
||||
[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 [err, result] = s.validate(json, s.array(Item), { coerce: true });
|
||||
if (!err) {
|
||||
const items = matchSorter(result, 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;
|
||||
}
|
||||
|
||||
function distinctBy<T>(array: T[], key: keyof T): T[] {
|
||||
const keys = array.map((item) => item[key]);
|
||||
return array.filter((item, index) => keys.indexOf(item[key]) == index);
|
||||
}
|
82
app/javascript/components/react-aria/props.ts
Normal file
82
app/javascript/components/react-aria/props.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
import type { Loader } from './hooks';
|
||||
|
||||
export const Item = s.object({
|
||||
label: s.string(),
|
||||
value: s.string(),
|
||||
data: s.any()
|
||||
});
|
||||
export type Item = s.Infer<typeof Item>;
|
||||
|
||||
const ArrayOfTuples = s.coerce(
|
||||
s.array(Item),
|
||||
s.array(s.tuple([s.string(), s.union([s.string(), s.number()])])),
|
||||
(items) =>
|
||||
items.map<Item>(([label, value]) => ({
|
||||
label,
|
||||
value: String(value)
|
||||
}))
|
||||
);
|
||||
|
||||
const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) =>
|
||||
items.map<Item>((label) => ({ label, value: label }))
|
||||
);
|
||||
|
||||
const ComboBoxPropsSchema = s.partial(
|
||||
s.object({
|
||||
id: s.string(),
|
||||
className: s.string(),
|
||||
name: s.string(),
|
||||
label: s.string(),
|
||||
description: s.string(),
|
||||
isRequired: s.boolean(),
|
||||
'aria-label': s.string(),
|
||||
'aria-labelledby': s.string(),
|
||||
'aria-describedby': s.string(),
|
||||
items: s.union([s.array(Item), ArrayOfStrings, ArrayOfTuples]),
|
||||
formValue: s.enums(['text', 'key']),
|
||||
form: s.string(),
|
||||
data: s.record(s.string(), s.string())
|
||||
})
|
||||
);
|
||||
export const SingleComboBoxProps = s.assign(
|
||||
ComboBoxPropsSchema,
|
||||
s.partial(
|
||||
s.object({
|
||||
selectedKey: s.nullable(s.string()),
|
||||
emptyFilterKey: s.nullable(s.string())
|
||||
})
|
||||
)
|
||||
);
|
||||
export const MultiComboBoxProps = s.assign(
|
||||
ComboBoxPropsSchema,
|
||||
s.partial(
|
||||
s.object({
|
||||
selectedKeys: s.array(s.string()),
|
||||
allowsCustomValue: s.boolean(),
|
||||
valueSeparator: s.string()
|
||||
})
|
||||
)
|
||||
);
|
||||
export const RemoteComboBoxProps = s.assign(
|
||||
ComboBoxPropsSchema,
|
||||
s.partial(
|
||||
s.object({
|
||||
selectedKey: s.nullable(s.string()),
|
||||
minimumInputLength: s.number(),
|
||||
limit: s.number(),
|
||||
allowsCustomValue: s.boolean()
|
||||
})
|
||||
)
|
||||
);
|
||||
export type SingleComboBoxProps = s.Infer<typeof SingleComboBoxProps> & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
export type MultiComboBoxProps = s.Infer<typeof MultiComboBoxProps>;
|
||||
export type RemoteComboBoxProps = s.Infer<typeof RemoteComboBoxProps> & {
|
||||
children?: ReactNode;
|
||||
loader: Loader | string;
|
||||
onChange?: (item: Item | null) => void;
|
||||
};
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
|
|
|
@ -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}"]`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import React, {
|
||||
import {
|
||||
useState,
|
||||
useContext,
|
||||
useRef,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useId } from 'react';
|
||||
import { useState, useId } from 'react';
|
||||
import { Popover, RadioGroup } from '@headlessui/react';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { MapIcon } from '@heroicons/react/outline';
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { Editor, type JSONContent } from '@tiptap/core';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { isButtonElement, isHTMLElement } from '@coldwired/utils';
|
||||
import { z } from 'zod';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
import { ApplicationController } from '../application_controller';
|
||||
import { getAction } from '../../shared/tiptap/actions';
|
||||
|
@ -61,7 +61,7 @@ export class TiptapController extends ApplicationController {
|
|||
|
||||
insertTag(event: MouseEvent) {
|
||||
if (this.#editor && isHTMLElement(event.target)) {
|
||||
const tag = tagSchema.parse(event.target.dataset);
|
||||
const tag = s.create(event.target.dataset, tagSchema);
|
||||
const editor = this.#editor
|
||||
.chain()
|
||||
.focus()
|
||||
|
@ -77,12 +77,12 @@ export class TiptapController extends ApplicationController {
|
|||
private get content() {
|
||||
const value = this.inputTarget.value;
|
||||
if (value) {
|
||||
return jsonContentSchema.parse(JSON.parse(value));
|
||||
return s.create(JSON.parse(value), jsonContentSchema);
|
||||
}
|
||||
}
|
||||
|
||||
private get tags(): TagSchema[] {
|
||||
return this.tagTargets.map((tag) => tagSchema.parse(tag.dataset));
|
||||
return this.tagTargets.map((tag) => s.create(tag.dataset, tagSchema));
|
||||
}
|
||||
|
||||
private get menuButtons() {
|
||||
|
@ -92,13 +92,24 @@ export class TiptapController extends ApplicationController {
|
|||
}
|
||||
}
|
||||
|
||||
const jsonContentSchema: z.ZodType<JSONContent> = z.object({
|
||||
type: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
attrs: z.record(z.any()).optional(),
|
||||
marks: z
|
||||
.object({ type: z.string(), attrs: z.record(z.any()).optional() })
|
||||
.array()
|
||||
.optional(),
|
||||
content: z.lazy(() => z.array(jsonContentSchema).optional())
|
||||
const Attrs = s.record(s.string(), s.any());
|
||||
const Marks = s.array(
|
||||
s.type({
|
||||
type: s.string(),
|
||||
attrs: s.optional(Attrs)
|
||||
})
|
||||
);
|
||||
type JSONContent = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
attrs?: s.Infer<typeof Attrs>;
|
||||
marks?: s.Infer<typeof Marks>;
|
||||
content?: JSONContent[];
|
||||
};
|
||||
const jsonContentSchema: s.Describe<JSONContent> = s.type({
|
||||
type: s.optional(s.string()),
|
||||
text: s.optional(s.string()),
|
||||
attrs: s.optional(Attrs),
|
||||
marks: s.optional(Marks),
|
||||
content: s.lazy(() => s.optional(s.array(jsonContentSchema)))
|
||||
});
|
||||
|
|
|
@ -86,6 +86,7 @@ export class MenuButtonController extends ApplicationController {
|
|||
target.isConnected &&
|
||||
!this.element.contains(target) &&
|
||||
!target.closest('reach-portal') &&
|
||||
!target.closest('#rac-portal') &&
|
||||
this.isOpen
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import { Actions } from '@coldwired/actions';
|
||||
import { parseTurboStream } from '@coldwired/turbo-stream';
|
||||
import { createRoot, createReactPlugin, type Root } from '@coldwired/react';
|
||||
import invariant from 'tiny-invariant';
|
||||
import { session as TurboSession, type StreamElement } from '@hotwired/turbo';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { ApplicationController } from './application_controller';
|
||||
|
||||
|
@ -20,6 +22,7 @@ export class TurboController extends ApplicationController {
|
|||
|
||||
#submitting = false;
|
||||
#actions?: Actions;
|
||||
#root?: Root;
|
||||
|
||||
// `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
|
||||
|
@ -32,6 +35,17 @@ export class TurboController extends ApplicationController {
|
|||
}
|
||||
|
||||
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({
|
||||
element: document.body,
|
||||
schema: {
|
||||
|
@ -40,6 +54,7 @@ export class TurboController extends ApplicationController {
|
|||
focusDirectionAttribute: 'data-turbo-focus-direction',
|
||||
hiddenClassName: 'hidden'
|
||||
},
|
||||
plugins: [plugin],
|
||||
debug: false
|
||||
});
|
||||
|
||||
|
@ -47,6 +62,10 @@ export class TurboController extends ApplicationController {
|
|||
// They allow us to preserve certain HTML changes across mutations.
|
||||
this.#actions.observe();
|
||||
|
||||
this.#actions.ready().then(() => {
|
||||
document.body.classList.add('dom-ready');
|
||||
});
|
||||
|
||||
// setup spinner events
|
||||
this.onGlobal('turbo:submit-start', () => this.startSpinner());
|
||||
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() {
|
||||
this.#submitting = true;
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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 d’aç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 d’aç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 d’açaï');
|
||||
expect(currentState.inputValue).toEqual('Baies d’aç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 d’açaï',
|
||||
'Baies de genièvre',
|
||||
'Baies de sureau'
|
||||
]);
|
||||
expect(currentState.focused).toBeNull();
|
||||
combobox.keyboard('ArrowDown');
|
||||
expect(currentState.focused?.label).toBe('Baies d’aç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 d’aç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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { Editor } from '@tiptap/core';
|
||||
import { z } from 'zod';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
type EditorAction = {
|
||||
run(): void;
|
||||
|
@ -11,7 +11,7 @@ export function getAction(
|
|||
editor: Editor,
|
||||
button: HTMLButtonElement
|
||||
): EditorAction {
|
||||
return tiptapActionSchema.parse(button.dataset)(editor);
|
||||
return s.create(button.dataset, tiptapActionSchema)(editor);
|
||||
}
|
||||
|
||||
const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = {
|
||||
|
@ -109,8 +109,15 @@ const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = {
|
|||
})
|
||||
};
|
||||
|
||||
const tiptapActionSchema = z
|
||||
.object({
|
||||
tiptapAction: z.enum(Object.keys(EDITOR_ACTIONS) as [string, ...string[]])
|
||||
})
|
||||
.transform(({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]);
|
||||
const EditorActionFn = s.define<(editor: Editor) => EditorAction>(
|
||||
'EditorActionFn',
|
||||
(fn) => typeof fn == 'function'
|
||||
);
|
||||
|
||||
const tiptapActionSchema = s.coerce(
|
||||
EditorActionFn,
|
||||
s.type({
|
||||
tiptapAction: s.enums(Object.keys(EDITOR_ACTIONS) as [string, ...string[]])
|
||||
}),
|
||||
({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]
|
||||
);
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion';
|
||||
import { z } from 'zod';
|
||||
import * as s from 'superstruct';
|
||||
import tippy, { type Instance as TippyInstance } from 'tippy.js';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
|
||||
export const tagSchema = z
|
||||
.object({ tagLabel: z.string(), tagId: z.string() })
|
||||
.transform(({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId }));
|
||||
export type TagSchema = z.infer<typeof tagSchema>;
|
||||
export const tagSchema = s.coerce(
|
||||
s.object({ label: s.string(), id: s.string() }),
|
||||
s.type({
|
||||
tagLabel: s.string(),
|
||||
tagId: s.string()
|
||||
}),
|
||||
({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId })
|
||||
);
|
||||
export type TagSchema = s.Infer<typeof tagSchema>;
|
||||
|
||||
class SuggestionMenu {
|
||||
#selectedIndex = 0;
|
||||
|
|
|
@ -1,72 +1,87 @@
|
|||
import { session } from '@hotwired/turbo';
|
||||
import { z } from 'zod';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
const Gon = z
|
||||
.object({
|
||||
autosave: z
|
||||
.object({
|
||||
debounce_delay: z.number().default(0),
|
||||
status_visible_duration: z.number().default(0)
|
||||
})
|
||||
.default({}),
|
||||
autocomplete: z
|
||||
.object({
|
||||
api_geo_url: z.string().optional(),
|
||||
api_adresse_url: z.string().optional(),
|
||||
api_education_url: z.string().optional()
|
||||
})
|
||||
.default({}),
|
||||
locale: z.string().default('fr'),
|
||||
matomo: z
|
||||
.object({
|
||||
cookieDomain: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
enabled: z.boolean().default(false),
|
||||
host: z.string().optional(),
|
||||
key: z.string().or(z.number()).nullish()
|
||||
})
|
||||
.default({}),
|
||||
sentry: z
|
||||
.object({
|
||||
key: z.string().nullish(),
|
||||
enabled: z.boolean().default(false),
|
||||
environment: z.string().optional(),
|
||||
user: z.object({ id: z.string() }).default({ id: '' }),
|
||||
browser: z.object({ modern: z.boolean() }).default({ modern: false }),
|
||||
release: z.string().nullish()
|
||||
})
|
||||
.default({}),
|
||||
crisp: z
|
||||
.object({
|
||||
key: z.string().nullish(),
|
||||
enabled: z.boolean().default(false),
|
||||
administrateur: z
|
||||
.object({
|
||||
email: z.string(),
|
||||
DS_SIGN_IN_COUNT: z.number(),
|
||||
DS_NB_DEMARCHES_BROUILLONS: z.number(),
|
||||
DS_NB_DEMARCHES_ACTIVES: z.number(),
|
||||
DS_NB_DEMARCHES_ARCHIVES: z.number(),
|
||||
DS_ID: z.number()
|
||||
})
|
||||
.default({
|
||||
function nullish<T, S>(struct: s.Struct<T, S>) {
|
||||
return s.optional(s.union([s.literal(null), struct]));
|
||||
}
|
||||
|
||||
const Gon = s.defaulted(
|
||||
s.type({
|
||||
autosave: s.defaulted(
|
||||
s.type({
|
||||
debounce_delay: s.defaulted(s.number(), 0),
|
||||
status_visible_duration: s.defaulted(s.number(), 0)
|
||||
}),
|
||||
{}
|
||||
),
|
||||
autocomplete: s.defaulted(
|
||||
s.partial(
|
||||
s.type({
|
||||
api_geo_url: s.string(),
|
||||
api_adresse_url: s.string(),
|
||||
api_education_url: s.string()
|
||||
})
|
||||
),
|
||||
{}
|
||||
),
|
||||
locale: s.defaulted(s.string(), 'fr'),
|
||||
matomo: s.defaulted(
|
||||
s.type({
|
||||
cookieDomain: s.optional(s.string()),
|
||||
domain: s.optional(s.string()),
|
||||
enabled: s.defaulted(s.boolean(), false),
|
||||
host: s.optional(s.string()),
|
||||
key: nullish(s.union([s.string(), s.number()]))
|
||||
}),
|
||||
{}
|
||||
),
|
||||
sentry: s.defaulted(
|
||||
s.type({
|
||||
key: nullish(s.string()),
|
||||
enabled: s.defaulted(s.boolean(), false),
|
||||
environment: s.optional(s.string()),
|
||||
user: s.defaulted(s.type({ id: s.string() }), { id: '' }),
|
||||
browser: s.defaulted(s.type({ modern: s.boolean() }), {
|
||||
modern: false
|
||||
}),
|
||||
release: nullish(s.string())
|
||||
}),
|
||||
{}
|
||||
),
|
||||
crisp: s.defaulted(
|
||||
s.type({
|
||||
key: nullish(s.string()),
|
||||
enabled: s.defaulted(s.boolean(), false),
|
||||
administrateur: s.defaulted(
|
||||
s.type({
|
||||
email: s.string(),
|
||||
DS_SIGN_IN_COUNT: s.number(),
|
||||
DS_NB_DEMARCHES_BROUILLONS: s.number(),
|
||||
DS_NB_DEMARCHES_ACTIVES: s.number(),
|
||||
DS_NB_DEMARCHES_ARCHIVES: s.number(),
|
||||
DS_ID: s.number()
|
||||
}),
|
||||
{
|
||||
email: '',
|
||||
DS_SIGN_IN_COUNT: 0,
|
||||
DS_NB_DEMARCHES_BROUILLONS: 0,
|
||||
DS_NB_DEMARCHES_ACTIVES: 0,
|
||||
DS_NB_DEMARCHES_ARCHIVES: 0,
|
||||
DS_ID: 0
|
||||
})
|
||||
})
|
||||
.default({}),
|
||||
defaultQuery: z.string().optional(),
|
||||
defaultVariables: z.string().optional()
|
||||
})
|
||||
.default({});
|
||||
}
|
||||
)
|
||||
}),
|
||||
{}
|
||||
),
|
||||
defaultQuery: s.optional(s.string()),
|
||||
defaultVariables: s.optional(s.string())
|
||||
}),
|
||||
{}
|
||||
);
|
||||
declare const window: Window & typeof globalThis & { gon: unknown };
|
||||
|
||||
export function getConfig() {
|
||||
return Gon.parse(window.gon);
|
||||
return s.create(window.gon, Gon);
|
||||
}
|
||||
|
||||
export function show(el: Element | null) {
|
||||
|
|
|
@ -107,11 +107,6 @@ class Champ < ApplicationRecord
|
|||
[to_s]
|
||||
end
|
||||
|
||||
def valid_value
|
||||
return unless valid_champ_value?
|
||||
value
|
||||
end
|
||||
|
||||
def to_s
|
||||
TypeDeChamp.champ_value(type_champ, self)
|
||||
end
|
||||
|
@ -285,7 +280,7 @@ class Champ < ApplicationRecord
|
|||
return if value.nil?
|
||||
return if value.present? && !value.include?("\u0000")
|
||||
|
||||
self.value = value.delete("\u0000")
|
||||
write_attribute(:value, value.delete("\u0000"))
|
||||
end
|
||||
|
||||
class NotImplemented < ::StandardError
|
||||
|
|
|
@ -3,10 +3,6 @@ class Champs::AddressChamp < Champs::TextChamp
|
|||
data.present?
|
||||
end
|
||||
|
||||
def feature
|
||||
data.to_json if full_address?
|
||||
end
|
||||
|
||||
def feature=(value)
|
||||
if value.blank?
|
||||
self.data = nil
|
||||
|
@ -22,6 +18,14 @@ class Champs::AddressChamp < Champs::TextChamp
|
|||
self.data = nil
|
||||
end
|
||||
|
||||
def selected_items
|
||||
if value.present?
|
||||
[{ value:, label: value, data: full_address? ? data : nil }]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def address
|
||||
full_address? ? data : nil
|
||||
end
|
||||
|
|
|
@ -7,14 +7,11 @@ class Champs::AnnuaireEducationChamp < Champs::TextChamp
|
|||
APIEducation::AnnuaireEducationAdapter.new(external_id).to_params
|
||||
end
|
||||
|
||||
def update_with_external_data!(data:)
|
||||
if data&.is_a?(Hash) && data['nom_etablissement'].present? && data['nom_commune'].present? && data['identifiant_de_l_etablissement'].present?
|
||||
update!(
|
||||
data: data,
|
||||
value: "#{data['nom_etablissement']}, #{data['nom_commune']} (#{data['identifiant_de_l_etablissement']})"
|
||||
)
|
||||
def selected_items
|
||||
if external_id.present?
|
||||
[{ value: external_id, label: value }]
|
||||
else
|
||||
update!(data: data)
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -50,7 +50,7 @@ class Champs::COJOChamp < Champ
|
|||
|
||||
def update_external_id
|
||||
if accreditation_number_changed? || accreditation_birthdate_changed?
|
||||
if accreditation_number.present? && accreditation_birthdate.present? && /\A\d+\z/.match?(accreditation_number)
|
||||
if accreditation_number.present? && accreditation_birthdate.present? && /\A[\d-]+\z/.match?(accreditation_number)
|
||||
self.external_id = { accreditation_number:, accreditation_birthdate: }.to_json
|
||||
else
|
||||
self.external_id = nil
|
||||
|
|
|
@ -28,10 +28,6 @@ class Champs::CommuneChamp < Champs::TextChamp
|
|||
code_postal.present?
|
||||
end
|
||||
|
||||
def code_postal=(value)
|
||||
super(value&.gsub(/[[:space:]]/, ''))
|
||||
end
|
||||
|
||||
alias postal_code code_postal
|
||||
|
||||
def name
|
||||
|
@ -43,7 +39,36 @@ class Champs::CommuneChamp < Champs::TextChamp
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def communes
|
||||
|
@ -54,12 +79,6 @@ class Champs::CommuneChamp < Champs::TextChamp
|
|||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def safe_to_s
|
||||
value.present? ? value.to_s : ''
|
||||
end
|
||||
|
||||
def on_codes_change
|
||||
return if !code?
|
||||
|
||||
|
|
|
@ -22,6 +22,6 @@ class Champs::DecimalNumberChamp < Champ
|
|||
def format_value
|
||||
return if value.blank?
|
||||
|
||||
self.value = value.tr(",", ".")
|
||||
self.value = value.tr(",", ".").gsub(/[[:space:]]/, "")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Champs::IntegerNumberChamp < Champ
|
||||
before_validation :format_value
|
||||
|
||||
validates :value, numericality: {
|
||||
only_integer: true,
|
||||
allow_nil: true,
|
||||
|
@ -8,4 +10,10 @@ class Champs::IntegerNumberChamp < Champ
|
|||
object.errors.generate_message(:value, :not_an_integer)
|
||||
}
|
||||
}, if: :validate_champ_value_or_prefill?
|
||||
|
||||
def format_value
|
||||
return if value.blank?
|
||||
|
||||
self.value = value.gsub(/[[:space:]]/, "")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,7 +8,8 @@ module TrustedDeviceConcern
|
|||
cookies.encrypted[TRUSTED_DEVICE_COOKIE_NAME] = {
|
||||
value: JSON.generate({ created_at: start_at }),
|
||||
expires: start_at + TRUSTED_DEVICE_PERIOD,
|
||||
httponly: true
|
||||
httponly: true,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -641,8 +641,7 @@ class TypeDeChamp < ApplicationRecord
|
|||
# We should refresh all champs after update except for champs using react or custom refresh
|
||||
# logic (RNA, SIRET, etc.)
|
||||
case type_champ
|
||||
when type_champs.fetch(:annuaire_education),
|
||||
type_champs.fetch(:carte),
|
||||
when type_champs.fetch(:carte),
|
||||
type_champs.fetch(:piece_justificative),
|
||||
type_champs.fetch(:titre_identite),
|
||||
type_champs.fetch(:rna),
|
||||
|
|
|
@ -20,7 +20,7 @@ class TypesDeChamp::DecimalNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase
|
|||
private
|
||||
|
||||
def champ_formatted_value(champ)
|
||||
champ.valid_value&.to_f
|
||||
champ.value&.to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,7 +20,7 @@ class TypesDeChamp::IntegerNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase
|
|||
private
|
||||
|
||||
def champ_formatted_value(champ)
|
||||
champ.valid_value&.to_i
|
||||
champ.value&.to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -66,12 +66,12 @@ class TypesDeChamp::TypeDeChampBase
|
|||
when 2
|
||||
champ_value(champ)
|
||||
else
|
||||
champ.valid_value.presence || champ_default_api_value(version)
|
||||
champ.value.presence || champ_default_api_value(version)
|
||||
end
|
||||
end
|
||||
|
||||
def champ_value_for_export(champ, path = :value)
|
||||
path == :value ? champ.valid_value.presence : champ_default_export_value(path)
|
||||
path == :value ? champ.value.presence : champ_default_export_value(path)
|
||||
end
|
||||
|
||||
def champ_value_for_tag(champ, path = :value)
|
||||
|
|
|
@ -65,15 +65,13 @@
|
|||
|
||||
.instructeur-wrapper
|
||||
%p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie
|
||||
= hidden_field_tag :emails, nil
|
||||
= react_component("ComboMultiple",
|
||||
options: [],
|
||||
selected: [], disabled: [],
|
||||
group: '.instructeur-wrapper',
|
||||
name: 'emails',
|
||||
label: 'Emails',
|
||||
describedby: 'experts-emails',
|
||||
acceptNewValues: true)
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/MultiComboBox",
|
||||
id: 'emails',
|
||||
name: 'emails[]',
|
||||
allows_custom_value: true,
|
||||
'aria-label': 'Emails',
|
||||
'aria-describedby': 'experts-emails'
|
||||
|
||||
= f.submit 'Ajouter à la liste', class: 'fr-btn'
|
||||
|
||||
|
|
|
@ -10,14 +10,8 @@
|
|||
- if disabled_as_super_admin
|
||||
= f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails'
|
||||
- else
|
||||
= hidden_field_tag :emails, nil
|
||||
= react_component("ComboMultiple",
|
||||
options: available_instructeur_emails, selected: [], disabled: [],
|
||||
group: '.instructeur-wrapper',
|
||||
id: 'instructeur_emails',
|
||||
name: 'emails',
|
||||
label: 'Emails',
|
||||
acceptNewValues: true)
|
||||
%react-fragment
|
||||
= render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails'
|
||||
|
||||
= f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin
|
||||
|
||||
|
|
|
@ -122,17 +122,16 @@
|
|||
.fr-fieldset__element
|
||||
= 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.
|
||||
= hidden_field_tag 'procedure[tags]', JSON.generate(@procedure.tags)
|
||||
= react_component("ComboMultiple",
|
||||
id: "procedure_tags_combo",
|
||||
options: Procedure.tags,
|
||||
selected: @procedure.tags,
|
||||
disabled: [],
|
||||
label: 'Tags',
|
||||
group: '.procedure_tags_combo',
|
||||
name: 'tags',
|
||||
describedby: 'procedure-tags',
|
||||
acceptNewValues: true)
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/MultiComboBox",
|
||||
id: "procedure_tags_combo",
|
||||
items: Procedure.tags,
|
||||
selected_keys: @procedure.tags,
|
||||
name: 'procedure[tags][]',
|
||||
value_separator: ',|;',
|
||||
allows_custom_value: true,
|
||||
'aria-label': 'Tags',
|
||||
'aria-describedby': 'procedure-tags'
|
||||
|
||||
%details.procedure-form__options-details
|
||||
%summary.procedure-form__options-summary
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
= turbo_stream.remove dom_id(@attachment, :persisted_row)
|
||||
|
||||
- if @champ_id
|
||||
= turbo_stream.show "attachment-multiple-empty-#{@champ_id}"
|
||||
= turbo_stream.focus_all "#attachment-multiple-empty-#{@champ_id} input"
|
||||
|
||||
= turbo_stream.show_all ".attachment-input-#{@attachment.id}"
|
||||
|
||||
- if @champ
|
||||
= fields_for @champ.input_name, @champ do |form|
|
||||
= turbo_stream.replace @champ.input_group_id do
|
||||
= render EditableChamp::EditableChampComponent.new champ: @champ, form: form
|
||||
= turbo_stream.focus_all "#attachment-multiple-empty-#{@champ.public_id} input"
|
||||
|
|
|
@ -13,7 +13,11 @@
|
|||
#{Current.application_name}
|
||||
%li= link_to t('views.shared.account.already_user'), commencer_sign_in_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), class: 'fr-btn fr-btn--secondary'
|
||||
|
||||
= render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w")
|
||||
|
||||
- else
|
||||
= render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w")
|
||||
|
||||
- if @prefilled_dossier
|
||||
= render Dsfr::CalloutComponent.new(title: t(".prefilled_draft"), heading_level: 'h2') do |c|
|
||||
- c.with_body do
|
||||
|
|
|
@ -7,12 +7,7 @@
|
|||
%p.tab-paragrah.mb-1
|
||||
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|
|
||||
= hidden_field_tag :recipients, nil
|
||||
= react_component("ComboMultiple",
|
||||
options: potential_recipients.map{|r| [r.email, r.id]},
|
||||
selected: [], disabled: [],
|
||||
group: '.recipients-form',
|
||||
name: 'recipients',
|
||||
label: 'Emails')
|
||||
%react-fragment
|
||||
= render ReactComponent.new "ComboBox/MultiComboBox", items: potential_recipients.map { [_1.email, _1.id] }, name: 'recipients[]', 'aria-label': 'Emails'
|
||||
|
||||
= f.submit "Envoyer", class: "fr-btn fr-mt-2w"
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue