Merge pull request #10404 from tchak/update-react-and-coldwired
chore(js): update coldwired, react and combobox
This commit is contained in:
commit
fea8d8971b
104 changed files with 1724 additions and 3311 deletions
|
@ -28,3 +28,7 @@ body {
|
||||||
.container {
|
.container {
|
||||||
@extend %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 {
|
.map-style-control {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
|
|
|
@ -32,29 +32,87 @@ trix-editor.fr-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.fr-ds-combobox {
|
.fr-ds-combobox {
|
||||||
.fr-menu {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.fr-menu__list {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fr-autocomplete {
|
.fr-autocomplete {
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fr-ds-combobox__multiple {
|
||||||
|
.fr-tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fr-ds-combobox__menu {
|
||||||
|
&[data-placement=top] {
|
||||||
|
--origin: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-placement=bottom] {
|
||||||
|
--origin: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-placement=right] {
|
||||||
|
--origin: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-placement=left] {
|
||||||
|
--origin: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: popover-slide 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fr-menu {
|
||||||
|
width: var(--trigger-width);
|
||||||
|
top: unset;
|
||||||
|
|
||||||
|
.fr-menu__list {
|
||||||
|
display: block;
|
||||||
|
width: unset;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fr-menu__item {
|
||||||
|
&[data-selected] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-focused] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popover-slide {
|
||||||
|
from {
|
||||||
|
transform: var(--origin);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 62em) {
|
@media (max-width: 62em) {
|
||||||
.fr-ds-combobox .fr-menu .fr-menu__list {
|
.fr-ds-combobox__menu {
|
||||||
z-index: calc(var(--ground) + 1000);
|
&.fr-menu .fr-menu__list {
|
||||||
background-color: var(--background-default-grey);
|
z-index: calc(var(--ground) + 1000);
|
||||||
--idle: transparent;
|
background-color: var(--background-default-grey);
|
||||||
--hover: var(--background-overlap-grey-hover);
|
--idle: transparent;
|
||||||
--active: var(--background-overlap-grey-active);
|
--hover: var(--background-overlap-grey-hover);
|
||||||
filter: drop-shadow(var(--overlap-shadow));
|
--active: var(--background-overlap-grey-active);
|
||||||
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
filter: drop-shadow(var(--overlap-shadow));
|
||||||
|
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -356,41 +356,6 @@
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-reach-combobox-input] {
|
|
||||||
&:not([class^='width-']) {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 50%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: $blue-france-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token-list] {
|
|
||||||
padding: $default-spacer;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token] button {
|
|
||||||
border: solid 1px $border-grey;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: $default-spacer;
|
|
||||||
margin-right: $default-spacer;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token] button:focus {
|
|
||||||
background-color: $black;
|
|
||||||
color: $white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-champ {
|
.editable-champ {
|
||||||
&:not(.editable-champ-carte) .algolia-autocomplete {
|
&:not(.editable-champ-carte) .algolia-autocomplete {
|
||||||
margin-bottom: 2 * $default-padding;
|
margin-bottom: 2 * $default-padding;
|
||||||
|
@ -524,91 +489,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-react-component-value^="ComboMultiple"] {
|
.fr-ds-combobox__multiple {
|
||||||
margin-bottom: $default-fields-spacer;
|
margin-bottom: $default-fields-spacer;
|
||||||
|
|
||||||
[data-reach-combobox-input] {
|
|
||||||
flex-grow: 1;
|
|
||||||
background-image: image-url("icons/chevron-down");
|
|
||||||
background-size: 14px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 10px center;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: solid 1px $border-grey;
|
|
||||||
padding: $default-padding;
|
|
||||||
margin: $default-spacer;
|
|
||||||
margin-top: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-right: $default-spacer;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token-label] {
|
|
||||||
border: 1px solid #CCCCCC;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-option] {
|
|
||||||
font-size: 16px;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-option][aria-selected="true"] {
|
|
||||||
background: $light-blue !important;
|
|
||||||
color: $white;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-separator] {
|
|
||||||
font-size: 16px;
|
|
||||||
color: $dark-grey;
|
|
||||||
background: $light-grey;
|
|
||||||
padding: $default-spacer;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-no-results] {
|
|
||||||
font-size: 16px;
|
|
||||||
color: $dark-grey;
|
|
||||||
background: $light-grey;
|
|
||||||
padding: $default-spacer;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token] button {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: transparent;
|
|
||||||
background-image: none;
|
|
||||||
border: none;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
margin-right: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-input] button:focus {
|
|
||||||
outline-color: $light-blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-fr-theme="dark"] [data-reach-combobox-popover] {
|
|
||||||
border: none;
|
|
||||||
background: var(--background-action-low-blue-france);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-fr-theme="dark"] [data-reach-combobox-option]:hover {
|
|
||||||
background: var(--background-action-low-blue-france-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-popover] {
|
|
||||||
z-index: 20;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fconnect-form {
|
.fconnect-form {
|
||||||
|
@ -634,10 +516,6 @@ textarea::placeholder {
|
||||||
.fr-menu__item {
|
.fr-menu__item {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin-bottom: $default-spacer;
|
margin-bottom: $default-spacer;
|
||||||
|
|
||||||
&[aria-selected] {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,36 +1,5 @@
|
||||||
@import "constants";
|
@import "constants";
|
||||||
|
|
||||||
[data-reach-combobox-token-label] {
|
|
||||||
border: 1px solid #CCCCCC;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form [data-reach-combobox-token-list] {
|
|
||||||
padding: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form [data-reach-combobox-input]:not([class^='width-']) {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 50%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form [data-reach-combobox-token] button {
|
|
||||||
border: solid 1px #CCCCCC;
|
|
||||||
background-color: transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
margin-right: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -70,4 +39,79 @@
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fr-ds-combobox {
|
||||||
|
.fr-autocomplete {
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fr-ds-combobox__multiple {
|
||||||
|
.fr-tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fr-tag {
|
||||||
|
font-size: small;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: solid 1px #dcdcdc;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fr-ds-combobox__menu {
|
||||||
|
&[data-placement=top] {
|
||||||
|
--origin: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-placement=bottom] {
|
||||||
|
--origin: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-placement=right] {
|
||||||
|
--origin: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-placement=left] {
|
||||||
|
--origin: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: popover-slide 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fr-menu {
|
||||||
|
width: var(--trigger-width);
|
||||||
|
top: unset;
|
||||||
|
background-color: white;
|
||||||
|
border: solid 1px #dcdcdc;
|
||||||
|
|
||||||
|
.fr-menu__list {
|
||||||
|
display: block;
|
||||||
|
width: unset;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fr-menu__item {
|
||||||
|
&[data-selected] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-focused] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,41 +9,7 @@
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-react-component-value^="ComboMultiple"] {
|
.fr-ds-combobox__multiple {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
[data-reach-combobox-token-list] {
|
|
||||||
padding: 0.5 * $default-padding;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token] button {
|
|
||||||
border: solid 1px $border-grey;
|
|
||||||
margin-top: 0.5 * $default-padding;
|
|
||||||
margin-bottom: 0.5 * $default-padding;
|
|
||||||
margin-right: 0.5 * $default-padding;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.5 * $default-padding;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token] button:focus {
|
|
||||||
background-color: $black;
|
|
||||||
color: $white;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[data-reach-combobox-input] {
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
flex-grow: 1;
|
|
||||||
margin: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-input]:focus {
|
|
||||||
outline: solid;
|
|
||||||
outline-color: $light-blue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,45 +45,8 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-react-component-value^="ComboMultiple"] {
|
.fr-ds-combobox__multiple {
|
||||||
margin-bottom: $default-fields-spacer;
|
margin-bottom: $default-fields-spacer;
|
||||||
|
|
||||||
[data-reach-combobox-token-list] {
|
|
||||||
padding: 0.25 * $default-padding;
|
|
||||||
display: inline-block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token] button {
|
|
||||||
border: solid 1px $border-grey;
|
|
||||||
margin: 0.25 * $default-padding;
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 0.25 * $default-padding;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-token] button:focus {
|
|
||||||
background-color: $black;
|
|
||||||
color: $white;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[data-reach-combobox-input] {
|
|
||||||
outline: none;
|
|
||||||
flex-grow: 1;
|
|
||||||
margin: $default-spacer;
|
|
||||||
padding: $default-spacer;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: solid 1px $border-grey;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-combobox-input]:focus {
|
|
||||||
border-color: $blue-france-500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fix/dsfr
|
// fix/dsfr
|
||||||
|
|
|
@ -8,10 +8,6 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
|
||||||
|
|
||||||
attr_reader :procedure, :procedure_presentation, :statut, :field_id
|
attr_reader :procedure, :procedure_presentation, :statut, :field_id
|
||||||
|
|
||||||
def filterable_fields_for_select
|
|
||||||
procedure_presentation.filterable_fields_options
|
|
||||||
end
|
|
||||||
|
|
||||||
def field_type
|
def field_type
|
||||||
return :text if field_id.nil?
|
return :text if field_id.nil?
|
||||||
procedure_presentation.field_type(field_id)
|
procedure_presentation.field_type(field_id)
|
||||||
|
@ -20,4 +16,16 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
|
||||||
def options_for_select_of_field
|
def options_for_select_of_field
|
||||||
procedure_presentation.field_enum(field_id)
|
procedure_presentation.field_enum(field_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_react_props
|
||||||
|
{
|
||||||
|
selected_key: @field_id || '',
|
||||||
|
items: procedure_presentation.filterable_fields_options,
|
||||||
|
name: :field,
|
||||||
|
id: 'search-filter',
|
||||||
|
'aria-describedby': 'instructeur-filter-combo-label',
|
||||||
|
form: 'filter-component',
|
||||||
|
data: { no_autosubmit: 'input blur', no_autosubmit_on_empty: 'true', autosubmit_target: 'input' }
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do
|
= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do
|
||||||
.fr-select-group
|
.fr-select-group
|
||||||
= label_tag :field, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter'
|
= label_tag :field, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter'
|
||||||
= render Dsfr::ComboboxComponent.new form: nil,
|
%react-fragment
|
||||||
options: filterable_fields_for_select,
|
= render ReactComponent.new "ComboBox/SingleComboBox", **filter_react_props
|
||||||
selected: field_id,
|
|
||||||
input_html_options: { name: :field, id: 'search-filter', class: 'fr-select', describedby: 'instructeur-filter-combo-label', allows_custom_value: false, form_id: 'filter-component' },
|
|
||||||
hidden_html_options: { data: { no_autosubmit: ['input', 'blur'].join(' '), no_autosubmit_on_empty: "true", autosubmit_target: 'input' } }
|
|
||||||
|
|
||||||
%input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } }
|
%input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } }
|
||||||
|
|
||||||
|
|
|
@ -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
|
end
|
||||||
|
|
||||||
def input_opts(other_opts = {})
|
def react_input_opts(other_opts = {})
|
||||||
|
input_opts(other_opts, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def input_opts(other_opts = {}, react = false)
|
||||||
@opts = @opts.deep_merge!(other_opts)
|
@opts = @opts.deep_merge!(other_opts)
|
||||||
@opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class])
|
@opts[react ? :class_name : :class] = class_names(map_array_to_hash_with_true(@opts[:class])
|
||||||
.merge({
|
.merge({
|
||||||
'fr-password__input': password?,
|
'fr-password__input': password?,
|
||||||
'fr-input': true,
|
'fr-input': !react,
|
||||||
'fr-mb-0': true
|
'fr-mb-0': true
|
||||||
}.merge(input_error_class_names)))
|
}.merge(input_error_class_names)))
|
||||||
if errors_on_attribute?
|
if errors_on_attribute?
|
||||||
@opts.deep_merge!(aria: { describedby: describedby_id })
|
@opts.deep_merge!('aria-describedby': describedby_id)
|
||||||
elsif hintable?
|
elsif hintable?
|
||||||
@opts.deep_merge!(aria: { describedby: hint_id })
|
@opts.deep_merge!('aria-describedby': hint_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
if @required
|
if @required
|
||||||
@opts[:required] = true
|
@opts[react ? :is_required : :required] = true
|
||||||
end
|
end
|
||||||
|
|
||||||
if email?
|
if email?
|
||||||
|
|
|
@ -2,4 +2,15 @@ class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponen
|
||||||
def dsfr_input_classname
|
def dsfr_input_classname
|
||||||
'fr-select'
|
'fr-select'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def react_props
|
||||||
|
react_input_opts(id: @champ.input_id,
|
||||||
|
class: 'fr-mt-1w',
|
||||||
|
name: @form.field_name(:value),
|
||||||
|
selected_key: @champ.value,
|
||||||
|
items: @champ.selected_items,
|
||||||
|
loader: data_sources_data_source_adresse_path,
|
||||||
|
minimum_input_length: 2,
|
||||||
|
allows_custom_value: true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_adresse_path, selected: @champ.value, allows_custom_value: true, input_html_options: { name: :value, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do
|
%react-fragment
|
||||||
= @form.hidden_field :external_id, data: { value_slot: 'value' }
|
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
|
||||||
= @form.hidden_field :feature, data: { value_slot: 'data' }
|
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: @form.field_name(:feature)
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent
|
class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
|
||||||
def dsfr_input_classname
|
def dsfr_input_classname
|
||||||
'fr-input'
|
'fr-select'
|
||||||
end
|
end
|
||||||
|
|
||||||
def react_input_opts
|
def react_props
|
||||||
opts = input_opts(id: @champ.input_id, required: @champ.required?, aria: { describedby: @champ.describedby_id })
|
react_input_opts(id: @champ.input_id,
|
||||||
opts[:className] = "#{opts.delete(:class)} fr-mt-1w"
|
class: "fr-mt-1w",
|
||||||
|
name: @form.field_name(:external_id),
|
||||||
opts
|
selected_key: @champ.external_id,
|
||||||
|
items: @champ.selected_items,
|
||||||
|
loader: data_sources_data_source_education_path,
|
||||||
|
minimum_input_length: 3)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
- render_parent
|
%react-fragment
|
||||||
|
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
|
||||||
= @form.hidden_field :value
|
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :label, name: @form.field_name(:value)
|
||||||
= @form.hidden_field :external_id
|
|
||||||
= react_component("ComboAnnuaireEducationSearch",
|
|
||||||
**react_input_opts,
|
|
||||||
**react_combo_props)
|
|
||||||
|
|
|
@ -4,10 +4,14 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
|
||||||
:fieldset
|
:fieldset
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(**args)
|
def react_props
|
||||||
super(**args)
|
{
|
||||||
|
feature_collection: @champ.to_feature_collection,
|
||||||
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args)
|
champ_id: @champ.input_id,
|
||||||
|
url: update_path,
|
||||||
|
adresse_source: data_sources_data_source_adresse_path,
|
||||||
|
options: @champ.render_options
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_path
|
def update_path
|
||||||
|
|
|
@ -1,14 +1,6 @@
|
||||||
.fr-fieldset__element
|
.fr-fieldset__element
|
||||||
= render @autocomplete_component
|
%react-fragment.width-100
|
||||||
|
= render ReactComponent.new "MapEditor", **react_props
|
||||||
= react_component("MapEditor",
|
|
||||||
{ featureCollection: @champ.to_feature_collection,
|
|
||||||
champId: @champ.input_id,
|
|
||||||
url: update_path,
|
|
||||||
options: @champ.render_options,
|
|
||||||
autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id,
|
|
||||||
autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions") },
|
|
||||||
{class: 'width-100'})
|
|
||||||
|
|
||||||
.geo-areas{ id: dom_id(@champ, :geo_areas) }
|
.geo-areas{ id: dom_id(@champ, :geo_areas) }
|
||||||
= render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true)
|
= render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true)
|
||||||
|
|
|
@ -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
|
def dsfr_input_classname
|
||||||
'fr-select'
|
'fr-select'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def react_props
|
||||||
|
react_input_opts(id: @champ.input_id,
|
||||||
|
class: 'fr-mt-1w',
|
||||||
|
name: @form.field_name(:code),
|
||||||
|
selected_key: @champ.selected,
|
||||||
|
items: @champ.selected_items,
|
||||||
|
loader: data_sources_data_source_commune_path(with_combined_code: true),
|
||||||
|
limit: 20,
|
||||||
|
minimum_input_length: 2)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_commune_path, selected: [@champ.to_s, @champ.selected], limit: 20, input_html_options: { name: :external_id, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do
|
%react-fragment
|
||||||
= @form.hidden_field :code_postal, data: { value_slot: 'data:string' }
|
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props
|
||||||
|
|
|
@ -23,4 +23,13 @@ class EditableChamp::DropDownListComponent < EditableChamp::EditableChampBaseCom
|
||||||
max_length = 100
|
max_length = 100
|
||||||
@champ.enabled_non_empty_options.any? { _1.size > max_length }
|
@champ.enabled_non_empty_options.any? { _1.size > max_length }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def react_props
|
||||||
|
react_input_opts(id: @champ.input_id,
|
||||||
|
class: 'fr-mt-1w',
|
||||||
|
name: @form.field_name(:value),
|
||||||
|
selected_key: @champ.selected,
|
||||||
|
items: @champ.enabled_non_empty_options(other: true).map { _1.is_a?(Array) ? _1 : [_1, _1] },
|
||||||
|
empty_filter_key: @champ.drop_down_other? ? Champs::DropDownListChamp::OTHER : nil)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -18,7 +18,8 @@
|
||||||
%label.fr-label{ for: dom_id(@champ, "radio_option_other") }
|
%label.fr-label{ for: dom_id(@champ, "radio_option_other") }
|
||||||
= t('shared.champs.drop_down_list.other')
|
= t('shared.champs.drop_down_list.other')
|
||||||
- elsif @champ.render_as_combobox?
|
- elsif @champ.render_as_combobox?
|
||||||
= render Dsfr::ComboboxComponent.new form: @form, options: @champ.enabled_non_empty_options(other: true), selected: @champ.selected, input_html_options: { name: :value, id: @champ.input_id, class: select_class_names, describedby: @champ.describedby_id }
|
%react-fragment
|
||||||
|
= render ReactComponent.new "ComboBox/SingleComboBox", **react_props
|
||||||
- else
|
- else
|
||||||
= @form.select :value,
|
= @form.select :value,
|
||||||
@champ.enabled_non_empty_options(other: true),
|
@champ.enabled_non_empty_options(other: true),
|
||||||
|
|
|
@ -14,6 +14,20 @@ class Procedure::ChorusFormComponent < ApplicationComponent
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def selected_key(attribute_name)
|
||||||
|
items(attribute_name).first&.dig(:value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def items(attribute_name)
|
||||||
|
label = format_displayed_value(attribute_name)
|
||||||
|
data = format_hidden_value(attribute_name)
|
||||||
|
if label.present?
|
||||||
|
[{ label:, value: label, data: }]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def format_displayed_value(attribute_name)
|
def format_displayed_value(attribute_name)
|
||||||
case attribute_name
|
case attribute_name
|
||||||
when :centre_de_cout
|
when :centre_de_cout
|
||||||
|
@ -30,13 +44,23 @@ class Procedure::ChorusFormComponent < ApplicationComponent
|
||||||
def format_hidden_value(attribute_name)
|
def format_hidden_value(attribute_name)
|
||||||
case attribute_name
|
case attribute_name
|
||||||
when :centre_de_cout
|
when :centre_de_cout
|
||||||
@chorus_configuration.centre_de_cout.to_json
|
@chorus_configuration.centre_de_cout
|
||||||
when :domaine_fonctionnel
|
when :domaine_fonctionnel
|
||||||
@chorus_configuration.domaine_fonctionnel.to_json
|
@chorus_configuration.domaine_fonctionnel
|
||||||
when :referentiel_de_programmation
|
when :referentiel_de_programmation
|
||||||
@chorus_configuration.referentiel_de_programmation.to_json
|
@chorus_configuration.referentiel_de_programmation
|
||||||
else
|
else
|
||||||
raise 'unknown attribute_name'
|
raise 'unknown attribute_name'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def react_props(name, chorus_configuration_attribute, datasource_endpoint)
|
||||||
|
{
|
||||||
|
name:,
|
||||||
|
selected_key: selected_key(chorus_configuration_attribute),
|
||||||
|
items: items(chorus_configuration_attribute),
|
||||||
|
loader: datasource_endpoint,
|
||||||
|
id: chorus_configuration_attribute
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
= form_for([procedure, @chorus_configuration],url: admin_procedure_chorus_path(procedure), method: :put) do |f|
|
= form_for([procedure, @chorus_configuration], url: admin_procedure_chorus_path(procedure), method: :put) do |f|
|
||||||
- map_attribute_to_autocomplete_endpoint.map do |chorus_configuration_attribute, datasource_endpoint|
|
- map_attribute_to_autocomplete_endpoint.map do |chorus_configuration_attribute, datasource_endpoint|
|
||||||
- label_id = "#{chorus_configuration_attribute}-label"
|
- label_id = "#{chorus_configuration_attribute}-label"
|
||||||
.fr-select-group
|
.fr-select-group
|
||||||
= f.label chorus_configuration_attribute, class: 'fr-label', id: label_id
|
= f.label chorus_configuration_attribute, class: 'fr-label', id: label_id, for: chorus_configuration_attribute
|
||||||
= render Dsfr::ComboboxComponent.new form: f,
|
%react-fragment
|
||||||
url: datasource_endpoint,
|
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props(f.field_name(:chorus_configuration_attribute), chorus_configuration_attribute, datasource_endpoint) do
|
||||||
selected: format_displayed_value(chorus_configuration_attribute),
|
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: f.field_name(chorus_configuration_attribute)
|
||||||
input_html_options: { id: chorus_configuration_attribute, class: 'fr-select', describedby: label_id, name: :chorus_configuration_attribute } do
|
|
||||||
= f.hidden_field chorus_configuration_attribute, data: { value_slot: 'data' }, value: format_hidden_value(chorus_configuration_attribute)
|
|
||||||
|
|
||||||
= f.submit "Enregister", class: 'fr-btn'
|
= f.submit "Enregister", class: 'fr-btn'
|
||||||
|
|
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
|
|
@ -9,8 +9,8 @@ module Administrateurs
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
emails = params['emails'].presence || [].to_json
|
emails = params['emails'].presence || []
|
||||||
emails = JSON.parse(emails).map { EmailSanitizer.sanitize(_1) }
|
emails = emails.map { EmailSanitizer.sanitize(_1) }
|
||||||
@maybe_typos, no_suggestions = emails
|
@maybe_typos, no_suggestions = emails
|
||||||
.map { |email| [email, EmailChecker.check(email:)[:suggestions]&.first] }
|
.map { |email| [email, EmailChecker.check(email:)[:suggestions]&.first] }
|
||||||
.partition { _1[1].present? }
|
.partition { _1[1].present? }
|
||||||
|
|
|
@ -218,8 +218,8 @@ module Administrateurs
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_instructeur
|
def add_instructeur
|
||||||
emails = params['emails'].presence || [].to_json
|
emails = params[:emails].presence || []
|
||||||
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||||
|
|
||||||
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(emails:)
|
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(emails:)
|
||||||
|
|
||||||
|
|
|
@ -527,11 +527,10 @@ module Administrateurs
|
||||||
:accuse_lecture,
|
:accuse_lecture,
|
||||||
:api_entreprise_token,
|
:api_entreprise_token,
|
||||||
:duree_conservation_dossiers_dans_ds,
|
:duree_conservation_dossiers_dans_ds,
|
||||||
{ zone_ids: [] },
|
|
||||||
:lien_dpo,
|
:lien_dpo,
|
||||||
:opendata,
|
:opendata,
|
||||||
:procedure_expires_when_termine_enabled,
|
:procedure_expires_when_termine_enabled,
|
||||||
:tags
|
{ zone_ids: [], tags: [] }
|
||||||
]
|
]
|
||||||
|
|
||||||
editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple?
|
editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple?
|
||||||
|
@ -544,9 +543,6 @@ module Administrateurs
|
||||||
if permited_params[:auto_archive_on].present?
|
if permited_params[:auto_archive_on].present?
|
||||||
permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day
|
permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day
|
||||||
end
|
end
|
||||||
if permited_params[:tags].present?
|
|
||||||
permited_params[:tags] = JSON.parse(permited_params[:tags])
|
|
||||||
end
|
|
||||||
permited_params
|
permited_params
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ module CreateAvisConcern
|
||||||
private
|
private
|
||||||
|
|
||||||
def create_avis_from_params(dossier, instructeur_or_expert, confidentiel = false)
|
def create_avis_from_params(dossier, instructeur_or_expert, confidentiel = false)
|
||||||
if create_avis_params[:emails].empty?
|
if create_avis_params[:emails].blank?
|
||||||
avis = Avis.new(create_avis_params)
|
avis = Avis.new(create_avis_params)
|
||||||
errors = avis.errors
|
errors = avis.errors
|
||||||
errors.add(:emails, :blank)
|
errors.add(:emails, :blank)
|
||||||
|
@ -19,8 +19,8 @@ module CreateAvisConcern
|
||||||
# the :emails parameter is a 1-element array.
|
# the :emails parameter is a 1-element array.
|
||||||
# Hence the call to first
|
# Hence the call to first
|
||||||
# https://github.com/rails/rails/issues/17225
|
# https://github.com/rails/rails/issues/17225
|
||||||
expert_emails = create_avis_params[:emails].presence || [].to_json
|
expert_emails = create_avis_params[:emails].presence || []
|
||||||
expert_emails = JSON.parse(expert_emails).map(&:strip).map(&:downcase)
|
expert_emails = expert_emails.map(&:strip).map(&:downcase)
|
||||||
allowed_dossiers = [dossier]
|
allowed_dossiers = [dossier]
|
||||||
|
|
||||||
if create_avis_params[:invite_linked_dossiers].present?
|
if create_avis_params[:invite_linked_dossiers].present?
|
||||||
|
@ -84,6 +84,6 @@ module CreateAvisConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_avis_params
|
def create_avis_params
|
||||||
params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :emails, :question_label)
|
params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :question_label, emails: [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -61,11 +61,18 @@ class DataSources::CommuneController < ApplicationController
|
||||||
else
|
else
|
||||||
[item]
|
[item]
|
||||||
end.map do |item|
|
end.map do |item|
|
||||||
{
|
if params[:with_combined_code].present?
|
||||||
label: "#{item[:name]} (#{item[:postal_code]})",
|
{
|
||||||
value: item[:code],
|
label: "#{item[:name]} (#{item[:postal_code]})",
|
||||||
data: item[:postal_code]
|
value: "#{item[:code]}-#{item[:postal_code]}"
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
label: "#{item[:name]} (#{item[:postal_code]})",
|
||||||
|
value: item[:code],
|
||||||
|
data: item[:postal_code]
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
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
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
emails = [params.require(:administrateur)[:email]].to_json
|
emails = [params.require(:administrateur)[:email]].compact
|
||||||
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||||
|
|
||||||
administrateurs_to_add, valid_emails, invalid_emails = Administrateur.find_all_by_identifier_with_emails(emails:)
|
administrateurs_to_add, valid_emails, invalid_emails = Administrateur.find_all_by_identifier_with_emails(emails:)
|
||||||
not_found_emails = valid_emails - administrateurs_to_add.map(&:email)
|
not_found_emails = valid_emails - administrateurs_to_add.map(&:email)
|
||||||
|
|
|
@ -6,8 +6,8 @@ module Gestionnaires
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
emails = [params.require(:gestionnaire)[:email]].to_json
|
emails = [params.require(:gestionnaire)[:email]].compact
|
||||||
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||||
|
|
||||||
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
|
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
|
||||||
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)
|
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)
|
||||||
|
|
|
@ -86,9 +86,9 @@ module Instructeurs
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_to_instructeurs
|
def send_to_instructeurs
|
||||||
recipients = params['recipients'].presence || [].to_json
|
recipients = params['recipients'].presence || []
|
||||||
# instructeurs are scoped by groupe_instructeur to avoid enumeration
|
# instructeurs are scoped by groupe_instructeur to avoid enumeration
|
||||||
recipients = dossier.groupe_instructeur.instructeurs.where(id: JSON.parse(recipients))
|
recipients = dossier.groupe_instructeur.instructeurs.where(id: recipients)
|
||||||
|
|
||||||
if recipients.present?
|
if recipients.present?
|
||||||
recipients.each do |recipient|
|
recipients.each do |recipient|
|
||||||
|
@ -401,6 +401,7 @@ module Instructeurs
|
||||||
:value,
|
:value,
|
||||||
:value_other,
|
:value_other,
|
||||||
:external_id,
|
:external_id,
|
||||||
|
:code,
|
||||||
:primary_value,
|
:primary_value,
|
||||||
:secondary_value,
|
:secondary_value,
|
||||||
:numero_allocataire,
|
:numero_allocataire,
|
||||||
|
|
|
@ -73,7 +73,6 @@ module Instructeurs
|
||||||
|
|
||||||
@current_filters = current_filters
|
@current_filters = current_filters
|
||||||
@displayable_fields_for_select, @displayable_fields_selected = procedure_presentation.displayable_fields_for_select
|
@displayable_fields_for_select, @displayable_fields_selected = procedure_presentation.displayable_fields_for_select
|
||||||
@filterable_fields_for_select = procedure_presentation.filterable_fields_options
|
|
||||||
@counts = current_instructeur
|
@counts = current_instructeur
|
||||||
.dossiers_count_summary(groupe_instructeur_ids)
|
.dossiers_count_summary(groupe_instructeur_ids)
|
||||||
.symbolize_keys
|
.symbolize_keys
|
||||||
|
@ -135,8 +134,8 @@ module Instructeurs
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_displayed_fields
|
def update_displayed_fields
|
||||||
values = params['values'].presence || [].to_json
|
values = params['values'].presence || []
|
||||||
procedure_presentation.update_displayed_fields(JSON.parse(values))
|
procedure_presentation.update_displayed_fields(values)
|
||||||
|
|
||||||
redirect_back(fallback_location: instructeur_procedure_url(procedure))
|
redirect_back(fallback_location: instructeur_procedure_url(procedure))
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,8 +2,8 @@ module Manager
|
||||||
class GroupeGestionnairesController < Manager::ApplicationController
|
class GroupeGestionnairesController < Manager::ApplicationController
|
||||||
def add_gestionnaire
|
def add_gestionnaire
|
||||||
groupe_gestionnaire = GroupeGestionnaire.find(params[:id])
|
groupe_gestionnaire = GroupeGestionnaire.find(params[:id])
|
||||||
emails = [params['emails'].presence || ''].to_json
|
emails = [params['emails']].compact
|
||||||
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
|
||||||
|
|
||||||
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
|
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
|
||||||
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)
|
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)
|
||||||
|
|
|
@ -111,8 +111,7 @@ module Manager
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_tags
|
def add_tags
|
||||||
tags_h = { tags: JSON.parse(tags_params[:tags]) }
|
if procedure.update(tags: tags_params[:tags])
|
||||||
if procedure.update(tags_h)
|
|
||||||
flash.notice = "Le modèle est mis à jour."
|
flash.notice = "Le modèle est mis à jour."
|
||||||
else
|
else
|
||||||
flash.alert = procedure.errors.full_messages.join(', ')
|
flash.alert = procedure.errors.full_messages.join(', ')
|
||||||
|
@ -181,7 +180,7 @@ module Manager
|
||||||
end
|
end
|
||||||
|
|
||||||
def tags_params
|
def tags_params
|
||||||
params.require(:procedure).permit(:tags)
|
params.require(:procedure).permit(tags: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def template_params
|
def template_params
|
||||||
|
|
|
@ -494,6 +494,7 @@ module Users
|
||||||
:value,
|
:value,
|
||||||
:value_other,
|
:value_other,
|
||||||
:external_id,
|
:external_id,
|
||||||
|
:code,
|
||||||
:primary_value,
|
:primary_value,
|
||||||
:secondary_value,
|
:secondary_value,
|
||||||
:numero_allocataire,
|
:numero_allocataire,
|
||||||
|
|
|
@ -59,10 +59,6 @@ module ApplicationHelper
|
||||||
'alert'
|
'alert'
|
||||||
end
|
end
|
||||||
|
|
||||||
def react_component(name, props = {}, html = {})
|
|
||||||
tag.div(**html.merge(data: { controller: 'react', react_component_value: name, react_props_value: props.to_json }))
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_email
|
def current_email
|
||||||
current_user&.email ||
|
current_user&.email ||
|
||||||
current_instructeur&.email ||
|
current_instructeur&.email ||
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
346
app/javascript/components/ComboBox.tsx
Normal file
346
app/javascript/components/ComboBox.tsx
Normal file
|
@ -0,0 +1,346 @@
|
||||||
|
import type { ListBoxItemProps } from 'react-aria-components';
|
||||||
|
import {
|
||||||
|
ComboBox as AriaComboBox,
|
||||||
|
ListBox,
|
||||||
|
ListBoxItem,
|
||||||
|
Popover,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
TagGroup,
|
||||||
|
TagList,
|
||||||
|
Tag
|
||||||
|
} from 'react-aria-components';
|
||||||
|
import { useMemo, useRef, createContext, useContext } from 'react';
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { findOrCreateContainerElement } from '@coldwired/react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useLabelledBy,
|
||||||
|
useDispatchChangeEvent,
|
||||||
|
useMultiList,
|
||||||
|
useSingleList,
|
||||||
|
useRemoteList,
|
||||||
|
useOnFormReset,
|
||||||
|
createLoader,
|
||||||
|
type ComboBoxProps
|
||||||
|
} from './react-aria/hooks';
|
||||||
|
import {
|
||||||
|
type Item,
|
||||||
|
SingleComboBoxProps,
|
||||||
|
MultiComboBoxProps,
|
||||||
|
RemoteComboBoxProps
|
||||||
|
} from './react-aria/props';
|
||||||
|
|
||||||
|
const getPortal = () => findOrCreateContainerElement('rac-portal');
|
||||||
|
|
||||||
|
export function ComboBox({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
inputRef,
|
||||||
|
...props
|
||||||
|
}: ComboBoxProps & { inputRef?: RefObject<HTMLInputElement> }) {
|
||||||
|
return (
|
||||||
|
<AriaComboBox
|
||||||
|
{...props}
|
||||||
|
className={`fr-ds-combobox ${className ?? ''}`}
|
||||||
|
shouldFocusWrap={true}
|
||||||
|
>
|
||||||
|
{label ? <Label className="fr-label">{label}</Label> : null}
|
||||||
|
{description ? (
|
||||||
|
<Text slot="description" className="fr-hint-text">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<div className="fr-ds-combobox__input" style={{ position: 'relative' }}>
|
||||||
|
<Input className="fr-select fr-autocomplete" ref={inputRef} />
|
||||||
|
<Button
|
||||||
|
style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
opacity: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Popover
|
||||||
|
className="fr-ds-combobox__menu fr-menu"
|
||||||
|
UNSTABLE_portalContainer={getPortal()!}
|
||||||
|
>
|
||||||
|
<ListBox className="fr-menu__list">{children}</ListBox>
|
||||||
|
</Popover>
|
||||||
|
</AriaComboBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboBoxItem(props: ListBoxItemProps<Item>) {
|
||||||
|
return <ListBoxItem {...props} className="fr-menu__item" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SingleComboBox({
|
||||||
|
children,
|
||||||
|
...maybeProps
|
||||||
|
}: SingleComboBoxProps) {
|
||||||
|
const {
|
||||||
|
'aria-labelledby': ariaLabelledby,
|
||||||
|
items: defaultItems,
|
||||||
|
selectedKey: defaultSelectedKey,
|
||||||
|
emptyFilterKey,
|
||||||
|
name,
|
||||||
|
formValue,
|
||||||
|
form,
|
||||||
|
data,
|
||||||
|
...props
|
||||||
|
} = useMemo(() => SingleComboBoxProps.parse(maybeProps), [maybeProps]);
|
||||||
|
|
||||||
|
const labelledby = useLabelledBy(props.id, ariaLabelledby);
|
||||||
|
const { ref, dispatch } = useDispatchChangeEvent();
|
||||||
|
|
||||||
|
const { selectedItem, onReset, ...comboBoxProps } = useSingleList({
|
||||||
|
defaultItems,
|
||||||
|
defaultSelectedKey,
|
||||||
|
emptyFilterKey,
|
||||||
|
onChange: dispatch
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ComboBox aria-labelledby={labelledby} {...comboBoxProps} {...props}>
|
||||||
|
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
|
||||||
|
</ComboBox>
|
||||||
|
{children || name ? (
|
||||||
|
<span ref={ref}>
|
||||||
|
<SelectedItemProvider value={selectedItem}>
|
||||||
|
{name ? (
|
||||||
|
<ComboBoxValueSlot
|
||||||
|
field={formValue == 'text' ? 'label' : 'value'}
|
||||||
|
name={name}
|
||||||
|
form={form}
|
||||||
|
onReset={onReset}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</SelectedItemProvider>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiComboBox(maybeProps: MultiComboBoxProps) {
|
||||||
|
const {
|
||||||
|
'aria-labelledby': ariaLabelledby,
|
||||||
|
items: defaultItems,
|
||||||
|
selectedKeys: defaultSelectedKeys,
|
||||||
|
name,
|
||||||
|
form,
|
||||||
|
formValue,
|
||||||
|
allowsCustomValue,
|
||||||
|
valueSeparator,
|
||||||
|
...props
|
||||||
|
} = useMemo(() => MultiComboBoxProps.parse(maybeProps), [maybeProps]);
|
||||||
|
|
||||||
|
const labelledby = useLabelledBy(props.id, ariaLabelledby);
|
||||||
|
const { ref, dispatch } = useDispatchChangeEvent();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedItems,
|
||||||
|
hiddenInputValues,
|
||||||
|
onRemove,
|
||||||
|
onReset,
|
||||||
|
...comboBoxProps
|
||||||
|
} = useMultiList({
|
||||||
|
defaultItems,
|
||||||
|
defaultSelectedKeys,
|
||||||
|
onChange: dispatch,
|
||||||
|
formValue,
|
||||||
|
allowsCustomValue,
|
||||||
|
valueSeparator,
|
||||||
|
focusInput: () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const formResetRef = useOnFormReset(onReset);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fr-ds-combobox__multiple">
|
||||||
|
{selectedItems.length > 0 ? (
|
||||||
|
<TagGroup onRemove={onRemove} aria-label={props['aria-label']}>
|
||||||
|
<TagList items={selectedItems} className="fr-tag-list">
|
||||||
|
{selectedItems.map((item) => (
|
||||||
|
<Tag
|
||||||
|
key={item.value}
|
||||||
|
id={item.value}
|
||||||
|
textValue={`Retirer ${item.label}`}
|
||||||
|
className="fr-tag fr-tag--sm"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<Button slot="remove" className="fr-tag--dismiss"></Button>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</TagList>
|
||||||
|
</TagGroup>
|
||||||
|
) : null}
|
||||||
|
<ComboBox
|
||||||
|
aria-labelledby={labelledby}
|
||||||
|
allowsCustomValue={allowsCustomValue}
|
||||||
|
inputRef={inputRef}
|
||||||
|
{...comboBoxProps}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
|
||||||
|
</ComboBox>
|
||||||
|
{name ? (
|
||||||
|
<span ref={ref}>
|
||||||
|
{hiddenInputValues.map((value, i) => (
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
value={value}
|
||||||
|
name={name}
|
||||||
|
form={form}
|
||||||
|
ref={i == 0 ? formResetRef : undefined}
|
||||||
|
key={value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteComboBox({
|
||||||
|
loader,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
...maybeProps
|
||||||
|
}: RemoteComboBoxProps) {
|
||||||
|
const {
|
||||||
|
'aria-labelledby': ariaLabelledby,
|
||||||
|
items: defaultItems,
|
||||||
|
selectedKey: defaultSelectedKey,
|
||||||
|
allowsCustomValue,
|
||||||
|
minimumInputLength,
|
||||||
|
limit,
|
||||||
|
formValue,
|
||||||
|
name,
|
||||||
|
form,
|
||||||
|
data,
|
||||||
|
...props
|
||||||
|
} = useMemo(() => RemoteComboBoxProps.parse(maybeProps), [maybeProps]);
|
||||||
|
|
||||||
|
const labelledby = useLabelledBy(props.id, ariaLabelledby);
|
||||||
|
const { ref, dispatch } = useDispatchChangeEvent();
|
||||||
|
|
||||||
|
const load = useMemo(
|
||||||
|
() =>
|
||||||
|
typeof loader == 'string'
|
||||||
|
? createLoader(loader, { minimumInputLength, limit })
|
||||||
|
: loader,
|
||||||
|
[loader, minimumInputLength, limit]
|
||||||
|
);
|
||||||
|
const { selectedItem, onReset, ...comboBoxProps } = useRemoteList({
|
||||||
|
allowsCustomValue,
|
||||||
|
defaultItems,
|
||||||
|
defaultSelectedKey,
|
||||||
|
load,
|
||||||
|
onChange: (item) => {
|
||||||
|
onChange?.(item);
|
||||||
|
dispatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ComboBox
|
||||||
|
allowsEmptyCollection={comboBoxProps.inputValue.length > 0}
|
||||||
|
allowsCustomValue={allowsCustomValue}
|
||||||
|
aria-labelledby={labelledby}
|
||||||
|
{...comboBoxProps}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
|
||||||
|
</ComboBox>
|
||||||
|
{children || name ? (
|
||||||
|
<span ref={ref}>
|
||||||
|
<SelectedItemProvider value={selectedItem}>
|
||||||
|
{name ? (
|
||||||
|
<ComboBoxValueSlot
|
||||||
|
field={
|
||||||
|
formValue == 'text' || allowsCustomValue ? 'label' : 'value'
|
||||||
|
}
|
||||||
|
name={name}
|
||||||
|
form={form}
|
||||||
|
onReset={onReset}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</SelectedItemProvider>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboBoxValueSlot({
|
||||||
|
field,
|
||||||
|
name,
|
||||||
|
form,
|
||||||
|
onReset,
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
field: 'label' | 'value' | 'data';
|
||||||
|
name: string;
|
||||||
|
form?: string;
|
||||||
|
onReset?: () => void;
|
||||||
|
data?: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const selectedItem = useContext(SelectedItemContext);
|
||||||
|
const value = getSelectedValue(selectedItem, field);
|
||||||
|
const dataProps = Object.fromEntries(
|
||||||
|
Object.entries(data ?? {}).map(([key, value]) => [
|
||||||
|
`data-${key.replace(/_/g, '-')}`,
|
||||||
|
value
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const ref = useOnFormReset(onReset);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={onReset ? ref : undefined}
|
||||||
|
type="hidden"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
form={form}
|
||||||
|
{...dataProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectedItemContext = createContext<Item | null>(null);
|
||||||
|
const SelectedItemProvider = SelectedItemContext.Provider;
|
||||||
|
|
||||||
|
function getSelectedValue(
|
||||||
|
selectedItem: Item | null,
|
||||||
|
field: 'label' | 'value' | 'data'
|
||||||
|
): string {
|
||||||
|
if (selectedItem == null) {
|
||||||
|
return '';
|
||||||
|
} else if (field == 'data') {
|
||||||
|
if (typeof selectedItem.data == 'string') {
|
||||||
|
return selectedItem.data;
|
||||||
|
} else if (!selectedItem.data) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return JSON.stringify(selectedItem.data);
|
||||||
|
}
|
||||||
|
return selectedItem[field];
|
||||||
|
}
|
|
@ -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 { fire } from '@utils';
|
||||||
import type { FeatureCollection } from 'geojson';
|
import type { FeatureCollection } from 'geojson';
|
||||||
|
|
||||||
import ComboAdresseSearch from '../../ComboAdresseSearch';
|
import { RemoteComboBox } from '../../ComboBox';
|
||||||
import { ComboSearchProps } from '~/components/ComboSearch';
|
|
||||||
|
|
||||||
export function AddressInput(
|
export function AddressInput({
|
||||||
comboProps: Pick<
|
source,
|
||||||
ComboSearchProps,
|
featureCollection,
|
||||||
'screenReaderInstructions' | 'announceTemplateId'
|
champId
|
||||||
> & { featureCollection: FeatureCollection; champId: string }
|
}: {
|
||||||
) {
|
source: string;
|
||||||
|
featureCollection: FeatureCollection;
|
||||||
|
champId: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ marginBottom: '10px' }}>
|
||||||
style={{
|
<RemoteComboBox
|
||||||
marginBottom: '10px'
|
minimumInputLength={2}
|
||||||
}}
|
id={champId}
|
||||||
>
|
loader={source}
|
||||||
<ComboAdresseSearch
|
label="Rechercher une Adresse"
|
||||||
className="fr-input fr-mt-1w"
|
description="Saisissez au moins 2 caractères"
|
||||||
allowInputValues={false}
|
onChange={(item) => {
|
||||||
id={comboProps.champId}
|
if (item && item.data) {
|
||||||
onChange={(_, feature) => {
|
fire(document, 'map:zoom', {
|
||||||
fire(document, 'map:zoom', {
|
featureCollection,
|
||||||
featureCollection: comboProps.featureCollection,
|
feature: item.data
|
||||||
feature
|
});
|
||||||
});
|
}
|
||||||
}}
|
}}
|
||||||
{...comboProps}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
|
import { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
|
||||||
import type { FeatureCollection } from 'geojson';
|
import type { FeatureCollection } from 'geojson';
|
||||||
import invariant from 'tiny-invariant';
|
import invariant from 'tiny-invariant';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useId } from 'react';
|
import { useState, useId } from 'react';
|
||||||
import { fire } from '@utils';
|
import { fire } from '@utils';
|
||||||
import type { Feature, FeatureCollection } from 'geojson';
|
import type { Feature, FeatureCollection } from 'geojson';
|
||||||
import CoordinateInput from 'react-coordinate-input';
|
import CoordinateInput from 'react-coordinate-input';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CursorClickIcon } from '@heroicons/react/outline';
|
import { CursorClickIcon } from '@heroicons/react/outline';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||||
|
@ -12,21 +12,18 @@ import { AddressInput } from './components/AddressInput';
|
||||||
import { PointInput } from './components/PointInput';
|
import { PointInput } from './components/PointInput';
|
||||||
import { ImportFileInput } from './components/ImportFileInput';
|
import { ImportFileInput } from './components/ImportFileInput';
|
||||||
import { FlashMessage } from '../shared/FlashMessage';
|
import { FlashMessage } from '../shared/FlashMessage';
|
||||||
import { ComboSearchProps } from '../ComboSearch';
|
|
||||||
|
|
||||||
export default function MapEditor({
|
export default function MapEditor({
|
||||||
featureCollection: initialFeatureCollection,
|
featureCollection: initialFeatureCollection,
|
||||||
url,
|
url,
|
||||||
|
adresseSource,
|
||||||
options,
|
options,
|
||||||
autocompleteAnnounceTemplateId,
|
|
||||||
autocompleteScreenReaderInstructions,
|
|
||||||
champId
|
champId
|
||||||
}: {
|
}: {
|
||||||
featureCollection: FeatureCollection;
|
featureCollection: FeatureCollection;
|
||||||
url: string;
|
url: string;
|
||||||
|
adresseSource: string;
|
||||||
options: { layers: string[] };
|
options: { layers: string[] };
|
||||||
autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId'];
|
|
||||||
autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions'];
|
|
||||||
champId: string;
|
champId: string;
|
||||||
}) {
|
}) {
|
||||||
const [cadastreEnabled, setCadastreEnabled] = useState(false);
|
const [cadastreEnabled, setCadastreEnabled] = useState(false);
|
||||||
|
@ -41,15 +38,10 @@ export default function MapEditor({
|
||||||
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
||||||
|
|
||||||
<ImportFileInput featureCollection={featureCollection} {...actions} />
|
<ImportFileInput featureCollection={featureCollection} {...actions} />
|
||||||
<label className="fr-label" htmlFor={champId}>
|
|
||||||
Rechercher une Adresse
|
|
||||||
<span className="fr-hint-text">Saisissez au moins 2 caractères</span>
|
|
||||||
</label>
|
|
||||||
<AddressInput
|
<AddressInput
|
||||||
|
source={adresseSource}
|
||||||
champId={champId}
|
champId={champId}
|
||||||
featureCollection={featureCollection}
|
featureCollection={featureCollection}
|
||||||
screenReaderInstructions={autocompleteScreenReaderInstructions}
|
|
||||||
announceTemplateId={autocompleteAnnounceTemplateId}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MapLibre layers={options.layers}>
|
<MapLibre layers={options.layers}>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl';
|
import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl';
|
||||||
import type { Feature, FeatureCollection, Point } from 'geojson';
|
import type { Feature, FeatureCollection, Point } from 'geojson';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import type { FeatureCollection } from 'geojson';
|
import type { FeatureCollection } from 'geojson';
|
||||||
|
|
||||||
|
|
474
app/javascript/components/react-aria/hooks.ts
Normal file
474
app/javascript/components/react-aria/hooks.ts
Normal file
|
@ -0,0 +1,474 @@
|
||||||
|
import type {
|
||||||
|
ComboBoxProps as AriaComboBoxProps,
|
||||||
|
TagGroupProps
|
||||||
|
} from 'react-aria-components';
|
||||||
|
import { useAsyncList, type AsyncListOptions } from 'react-stately';
|
||||||
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
|
import type { Key } from 'react';
|
||||||
|
import { matchSorter } from 'match-sorter';
|
||||||
|
import { useDebounceCallback } from 'usehooks-ts';
|
||||||
|
import { useEvent } from 'react-use-event-hook';
|
||||||
|
import isEqual from 'react-fast-compare';
|
||||||
|
|
||||||
|
import { Item } from './props';
|
||||||
|
|
||||||
|
export type Loader = AsyncListOptions<Item, string>['load'];
|
||||||
|
|
||||||
|
export interface ComboBoxProps
|
||||||
|
extends Omit<AriaComboBoxProps<Item>, 'children'> {
|
||||||
|
children: React.ReactNode | ((item: Item) => React.ReactNode);
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputMap = new WeakMap<HTMLInputElement, string>();
|
||||||
|
export function useDispatchChangeEvent() {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
dispatch: () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const input = ref.current?.querySelector('input');
|
||||||
|
if (input) {
|
||||||
|
const value = input.value;
|
||||||
|
const prevValue = inputMap.get(input) || '';
|
||||||
|
if (value != prevValue) {
|
||||||
|
inputMap.set(input, value);
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSingleList({
|
||||||
|
defaultItems,
|
||||||
|
defaultSelectedKey,
|
||||||
|
emptyFilterKey,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
defaultItems?: Item[];
|
||||||
|
defaultSelectedKey?: string | null;
|
||||||
|
emptyFilterKey?: string;
|
||||||
|
onChange?: (item: Item | null) => void;
|
||||||
|
}) {
|
||||||
|
const [selectedKey, setSelectedKey] = useState(defaultSelectedKey);
|
||||||
|
const items = useMemo(() => defaultItems || [], [defaultItems]);
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => items.find((item) => item.value == selectedKey) ?? null,
|
||||||
|
[items, selectedKey]
|
||||||
|
);
|
||||||
|
const [inputValue, setInputValue] = useState(() => selectedItem?.label ?? '');
|
||||||
|
// show fallback item when input value is not matching any items
|
||||||
|
const fallbackItem = useMemo(
|
||||||
|
() => items.find((item) => item.value == emptyFilterKey),
|
||||||
|
[items, emptyFilterKey]
|
||||||
|
);
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (inputValue == '') {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const filteredItems = matchSorter(items, inputValue, { keys: ['label'] });
|
||||||
|
if (filteredItems.length == 0 && fallbackItem) {
|
||||||
|
return [fallbackItem];
|
||||||
|
} else {
|
||||||
|
return filteredItems;
|
||||||
|
}
|
||||||
|
}, [items, inputValue, fallbackItem]);
|
||||||
|
|
||||||
|
const initialSelectedKeyRef = useRef(defaultSelectedKey);
|
||||||
|
|
||||||
|
const setSelection = useEvent((key?: string | null) => {
|
||||||
|
const inputValue = defaultSelectedKey
|
||||||
|
? items.find((item) => item.value == defaultSelectedKey)?.label
|
||||||
|
: '';
|
||||||
|
setSelectedKey(key);
|
||||||
|
setInputValue(inputValue ?? '');
|
||||||
|
});
|
||||||
|
const onSelectionChange = useEvent<
|
||||||
|
NonNullable<ComboBoxProps['onSelectionChange']>
|
||||||
|
>((key) => {
|
||||||
|
setSelection(key ? String(key) : null);
|
||||||
|
const item =
|
||||||
|
typeof key != 'string'
|
||||||
|
? null
|
||||||
|
: selectedItem?.value == key
|
||||||
|
? selectedItem
|
||||||
|
: items.find((item) => item.value == key) ?? null;
|
||||||
|
onChange?.(item);
|
||||||
|
});
|
||||||
|
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
|
||||||
|
(value) => {
|
||||||
|
setInputValue(value);
|
||||||
|
if (value == '') {
|
||||||
|
onSelectionChange(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const onReset = useEvent(() => {
|
||||||
|
setSelectedKey(null);
|
||||||
|
setInputValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset default selected key when props change
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialSelectedKeyRef.current != defaultSelectedKey) {
|
||||||
|
initialSelectedKeyRef.current = defaultSelectedKey;
|
||||||
|
setSelection(defaultSelectedKey);
|
||||||
|
}
|
||||||
|
}, [defaultSelectedKey, setSelection]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedItem,
|
||||||
|
selectedKey,
|
||||||
|
onSelectionChange,
|
||||||
|
inputValue,
|
||||||
|
onInputChange,
|
||||||
|
items: filteredItems,
|
||||||
|
onReset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMultiList({
|
||||||
|
defaultItems,
|
||||||
|
defaultSelectedKeys,
|
||||||
|
allowsCustomValue,
|
||||||
|
valueSeparator,
|
||||||
|
onChange,
|
||||||
|
focusInput,
|
||||||
|
formValue
|
||||||
|
}: {
|
||||||
|
defaultItems?: Item[];
|
||||||
|
defaultSelectedKeys?: string[];
|
||||||
|
allowsCustomValue?: boolean;
|
||||||
|
valueSeparator?: string;
|
||||||
|
onChange?: () => void;
|
||||||
|
focusInput?: () => void;
|
||||||
|
formValue?: 'text' | 'key';
|
||||||
|
}) {
|
||||||
|
const valueSeparatorRegExp = useMemo(
|
||||||
|
() => (valueSeparator ? new RegExp(valueSeparator) : /\s|,|;/),
|
||||||
|
[valueSeparator]
|
||||||
|
);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState(
|
||||||
|
() => new Set(defaultSelectedKeys ?? [])
|
||||||
|
);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const items = useMemo(() => defaultItems || [], [defaultItems]);
|
||||||
|
const itemsIndex = useMemo(() => {
|
||||||
|
const index = new Map<string, Item>();
|
||||||
|
for (const item of items) {
|
||||||
|
index.set(item.value, item);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}, [items]);
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() =>
|
||||||
|
inputValue.length == 0
|
||||||
|
? items
|
||||||
|
: matchSorter(items, inputValue, { keys: ['label'] }),
|
||||||
|
[items, inputValue]
|
||||||
|
);
|
||||||
|
const selectedItems = useMemo(() => {
|
||||||
|
const selectedItems: Item[] = [];
|
||||||
|
for (const key of selectedKeys) {
|
||||||
|
const item = itemsIndex.get(key);
|
||||||
|
if (item) {
|
||||||
|
selectedItems.push(item);
|
||||||
|
} else if (allowsCustomValue) {
|
||||||
|
selectedItems.push({ label: key, value: key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectedItems;
|
||||||
|
}, [itemsIndex, selectedKeys, allowsCustomValue]);
|
||||||
|
const hiddenInputValues = useMemo(() => {
|
||||||
|
const values = selectedItems.map((item) =>
|
||||||
|
formValue == 'text' || allowsCustomValue ? item.label : item.value
|
||||||
|
);
|
||||||
|
if (!allowsCustomValue || inputValue == '') {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
...values,
|
||||||
|
...inputValue.split(valueSeparatorRegExp).filter(Boolean)
|
||||||
|
])
|
||||||
|
];
|
||||||
|
}, [
|
||||||
|
selectedItems,
|
||||||
|
inputValue,
|
||||||
|
valueSeparatorRegExp,
|
||||||
|
allowsCustomValue,
|
||||||
|
formValue
|
||||||
|
]);
|
||||||
|
const isSelectionSetRef = useRef(false);
|
||||||
|
const initialSelectedKeysRef = useRef(defaultSelectedKeys);
|
||||||
|
|
||||||
|
// reset default selected keys when props change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEqual(initialSelectedKeysRef.current, defaultSelectedKeys)) {
|
||||||
|
initialSelectedKeysRef.current = defaultSelectedKeys;
|
||||||
|
setSelectedKeys(new Set(defaultSelectedKeys));
|
||||||
|
}
|
||||||
|
}, [defaultSelectedKeys]);
|
||||||
|
|
||||||
|
const onSelectionChange = useEvent<
|
||||||
|
NonNullable<ComboBoxProps['onSelectionChange']>
|
||||||
|
>((key) => {
|
||||||
|
if (key) {
|
||||||
|
isSelectionSetRef.current = true;
|
||||||
|
setSelectedKeys((keys) => {
|
||||||
|
const selectedKeys = new Set(keys.values());
|
||||||
|
selectedKeys.add(String(key));
|
||||||
|
return selectedKeys;
|
||||||
|
});
|
||||||
|
setInputValue('');
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
|
||||||
|
(value) => {
|
||||||
|
const isSelectionSet = isSelectionSetRef.current;
|
||||||
|
isSelectionSetRef.current = false;
|
||||||
|
if (isSelectionSet) {
|
||||||
|
setInputValue('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allowsCustomValue) {
|
||||||
|
const values = value.split(valueSeparatorRegExp);
|
||||||
|
// if input contains a separator, add all values
|
||||||
|
if (values.length > 1) {
|
||||||
|
const addedKeys = values.filter(Boolean);
|
||||||
|
setSelectedKeys((keys) => {
|
||||||
|
const selectedKeys = new Set(keys.values());
|
||||||
|
for (const key of addedKeys) {
|
||||||
|
selectedKeys.add(key);
|
||||||
|
}
|
||||||
|
return selectedKeys;
|
||||||
|
});
|
||||||
|
setInputValue('');
|
||||||
|
} else {
|
||||||
|
setInputValue(value);
|
||||||
|
}
|
||||||
|
onChange?.();
|
||||||
|
} else {
|
||||||
|
setInputValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRemove = useEvent<NonNullable<TagGroupProps['onRemove']>>(
|
||||||
|
(removedKeys) => {
|
||||||
|
setSelectedKeys((keys) => {
|
||||||
|
const selectedKeys = new Set(keys.values());
|
||||||
|
for (const key of removedKeys) {
|
||||||
|
selectedKeys.delete(String(key));
|
||||||
|
}
|
||||||
|
// focus input when all items are removed
|
||||||
|
if (selectedKeys.size == 0) {
|
||||||
|
focusInput?.();
|
||||||
|
}
|
||||||
|
return selectedKeys;
|
||||||
|
});
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const onReset = useEvent(() => {
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
setInputValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
onRemove,
|
||||||
|
onSelectionChange,
|
||||||
|
onInputChange,
|
||||||
|
selectedItems,
|
||||||
|
items: filteredItems,
|
||||||
|
hiddenInputValues,
|
||||||
|
inputValue,
|
||||||
|
onReset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoteList({
|
||||||
|
load,
|
||||||
|
defaultItems,
|
||||||
|
defaultSelectedKey,
|
||||||
|
onChange,
|
||||||
|
debounce,
|
||||||
|
allowsCustomValue
|
||||||
|
}: {
|
||||||
|
load: Loader;
|
||||||
|
defaultItems?: Item[];
|
||||||
|
defaultSelectedKey?: Key | null;
|
||||||
|
onChange?: (item: Item | null) => void;
|
||||||
|
debounce?: number;
|
||||||
|
allowsCustomValue?: boolean;
|
||||||
|
}) {
|
||||||
|
const [defaultSelectedItem, setSelectedItem] = useState<Item | null>(() => {
|
||||||
|
if (defaultItems) {
|
||||||
|
return (
|
||||||
|
defaultItems.find((item) => item.value == defaultSelectedKey) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const [inputValue, setInputValue] = useState(
|
||||||
|
defaultSelectedItem?.label ?? ''
|
||||||
|
);
|
||||||
|
const selectedItem = useMemo<Item | null>(() => {
|
||||||
|
if (defaultSelectedItem) {
|
||||||
|
return defaultSelectedItem;
|
||||||
|
}
|
||||||
|
if (allowsCustomValue && inputValue != '') {
|
||||||
|
return { label: inputValue, value: inputValue };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [defaultSelectedItem, inputValue, allowsCustomValue]);
|
||||||
|
const list = useAsyncList<Item>({ getKey, load });
|
||||||
|
const setFilterText = useEvent((filterText: string) => {
|
||||||
|
list.setFilterText(filterText);
|
||||||
|
});
|
||||||
|
const debouncedSetFilterText = useDebounceCallback(
|
||||||
|
setFilterText,
|
||||||
|
debounce ?? 300
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectionChange = useEvent<
|
||||||
|
NonNullable<ComboBoxProps['onSelectionChange']>
|
||||||
|
>((key) => {
|
||||||
|
const item =
|
||||||
|
typeof key != 'string'
|
||||||
|
? null
|
||||||
|
: selectedItem?.value == key
|
||||||
|
? selectedItem
|
||||||
|
: list.getItem(key);
|
||||||
|
setSelectedItem(item);
|
||||||
|
if (item) {
|
||||||
|
setInputValue(item.label);
|
||||||
|
} else if (!allowsCustomValue) {
|
||||||
|
setInputValue('');
|
||||||
|
}
|
||||||
|
onChange?.(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
|
||||||
|
(value) => {
|
||||||
|
debouncedSetFilterText(value);
|
||||||
|
setInputValue(value);
|
||||||
|
if (value == '') {
|
||||||
|
onSelectionChange(null);
|
||||||
|
} else if (allowsCustomValue && selectedItem?.label != value) {
|
||||||
|
onChange?.(selectedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const onReset = useEvent(() => {
|
||||||
|
setSelectedItem(null);
|
||||||
|
setInputValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// add to items list current selected item if it's not in the list
|
||||||
|
const items =
|
||||||
|
selectedItem && !list.getItem(selectedItem.value)
|
||||||
|
? [selectedItem, ...list.items]
|
||||||
|
: list.items;
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedItem,
|
||||||
|
selectedKey: selectedItem?.value ?? null,
|
||||||
|
onSelectionChange,
|
||||||
|
inputValue,
|
||||||
|
onInputChange,
|
||||||
|
items,
|
||||||
|
onReset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKey(item: Item) {
|
||||||
|
return item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createLoader: (
|
||||||
|
source: string,
|
||||||
|
options?: {
|
||||||
|
minimumInputLength?: number;
|
||||||
|
limit?: number;
|
||||||
|
param?: string;
|
||||||
|
}
|
||||||
|
) => Loader =
|
||||||
|
(source, options) =>
|
||||||
|
async ({ signal, filterText }) => {
|
||||||
|
const url = new URL(source, location.href);
|
||||||
|
const minimumInputLength = options?.minimumInputLength ?? 2;
|
||||||
|
const param = options?.param ?? 'q';
|
||||||
|
const limit = options?.limit ?? 10;
|
||||||
|
|
||||||
|
if (!filterText || filterText.length < minimumInputLength) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
url.searchParams.set(param, filterText);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const json = await response.json();
|
||||||
|
const result = Item.array().safeParse(json);
|
||||||
|
if (result.success) {
|
||||||
|
const items = matchSorter(result.data, filterText, {
|
||||||
|
keys: ['label']
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: limit ? items.slice(0, limit) : items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { items: [] };
|
||||||
|
} catch {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLabelledBy(id?: string, ariaLabelledby?: string) {
|
||||||
|
return useMemo(
|
||||||
|
() => (ariaLabelledby ? ariaLabelledby : findLabelledbyId(id)),
|
||||||
|
[id, ariaLabelledby]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLabelledbyId(id?: string) {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = document.querySelector(`[for="${id}"]`);
|
||||||
|
if (!label?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return label.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnFormReset(onReset?: () => void) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const onResetListener = useEvent<EventListener>((event) => {
|
||||||
|
if (event.target == ref.current?.form) {
|
||||||
|
onReset?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (onReset) {
|
||||||
|
addEventListener('reset', onResetListener);
|
||||||
|
return () => {
|
||||||
|
removeEventListener('reset', onResetListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [onReset, onResetListener]);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
73
app/javascript/components/react-aria/props.ts
Normal file
73
app/javascript/components/react-aria/props.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { Loader } from './hooks';
|
||||||
|
|
||||||
|
export const Item = z.object({
|
||||||
|
label: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
data: z.any().optional()
|
||||||
|
});
|
||||||
|
export type Item = z.infer<typeof Item>;
|
||||||
|
|
||||||
|
const ComboBoxPropsSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
className: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
isRequired: z.boolean(),
|
||||||
|
'aria-label': z.string(),
|
||||||
|
'aria-labelledby': z.string(),
|
||||||
|
'aria-describedby': z.string(),
|
||||||
|
items: z
|
||||||
|
.array(Item)
|
||||||
|
.or(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.array()
|
||||||
|
.transform((items) =>
|
||||||
|
items.map<Item>((label) => ({ label, value: label }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.or(
|
||||||
|
z
|
||||||
|
.tuple([z.string(), z.string().or(z.number())])
|
||||||
|
.array()
|
||||||
|
.transform((items) =>
|
||||||
|
items.map<Item>(([label, value]) => ({
|
||||||
|
label,
|
||||||
|
value: String(value)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
formValue: z.enum(['text', 'key']),
|
||||||
|
form: z.string(),
|
||||||
|
data: z.record(z.string())
|
||||||
|
})
|
||||||
|
.partial();
|
||||||
|
export const SingleComboBoxProps = ComboBoxPropsSchema.extend({
|
||||||
|
selectedKey: z.string().nullable(),
|
||||||
|
emptyFilterKey: z.string()
|
||||||
|
}).partial();
|
||||||
|
export const MultiComboBoxProps = ComboBoxPropsSchema.extend({
|
||||||
|
selectedKeys: z.string().array(),
|
||||||
|
allowsCustomValue: z.boolean(),
|
||||||
|
valueSeparator: z.string()
|
||||||
|
}).partial();
|
||||||
|
export const RemoteComboBoxProps = ComboBoxPropsSchema.extend({
|
||||||
|
selectedKey: z.string().nullable(),
|
||||||
|
minimumInputLength: z.number(),
|
||||||
|
limit: z.number(),
|
||||||
|
allowsCustomValue: z.boolean()
|
||||||
|
}).partial();
|
||||||
|
export type SingleComboBoxProps = z.infer<typeof SingleComboBoxProps> & {
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
export type MultiComboBoxProps = z.infer<typeof MultiComboBoxProps>;
|
||||||
|
export type RemoteComboBoxProps = z.infer<typeof RemoteComboBoxProps> & {
|
||||||
|
children?: ReactNode;
|
||||||
|
loader: Loader | string;
|
||||||
|
onChange?: (item: Item | null) => void;
|
||||||
|
};
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import invariant from 'tiny-invariant';
|
import invariant from 'tiny-invariant';
|
||||||
|
|
||||||
|
|
|
@ -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,
|
useState,
|
||||||
useContext,
|
useContext,
|
||||||
useRef,
|
useRef,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useId } from 'react';
|
import { useState, useId } from 'react';
|
||||||
import { Popover, RadioGroup } from '@headlessui/react';
|
import { Popover, RadioGroup } from '@headlessui/react';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
import { MapIcon } from '@heroicons/react/outline';
|
import { MapIcon } from '@heroicons/react/outline';
|
||||||
|
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -86,6 +86,7 @@ export class MenuButtonController extends ApplicationController {
|
||||||
target.isConnected &&
|
target.isConnected &&
|
||||||
!this.element.contains(target) &&
|
!this.element.contains(target) &&
|
||||||
!target.closest('reach-portal') &&
|
!target.closest('reach-portal') &&
|
||||||
|
!target.closest('#rac-portal') &&
|
||||||
this.isOpen
|
this.isOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { Actions } from '@coldwired/actions';
|
||||||
import { parseTurboStream } from '@coldwired/turbo-stream';
|
import { parseTurboStream } from '@coldwired/turbo-stream';
|
||||||
|
import { createRoot, createReactPlugin, type Root } from '@coldwired/react';
|
||||||
import invariant from 'tiny-invariant';
|
import invariant from 'tiny-invariant';
|
||||||
import { session as TurboSession, type StreamElement } from '@hotwired/turbo';
|
import { session as TurboSession, type StreamElement } from '@hotwired/turbo';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
import { ApplicationController } from './application_controller';
|
import { ApplicationController } from './application_controller';
|
||||||
|
|
||||||
|
@ -20,6 +22,7 @@ export class TurboController extends ApplicationController {
|
||||||
|
|
||||||
#submitting = false;
|
#submitting = false;
|
||||||
#actions?: Actions;
|
#actions?: Actions;
|
||||||
|
#root?: Root;
|
||||||
|
|
||||||
// `actions` instrface exposes all available actions as methods and also `applyActions` method
|
// `actions` instrface exposes all available actions as methods and also `applyActions` method
|
||||||
// wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also
|
// wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also
|
||||||
|
@ -32,6 +35,17 @@ export class TurboController extends ApplicationController {
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
this.#root = createRoot({
|
||||||
|
layoutComponentName: 'Layout/Layout',
|
||||||
|
loader,
|
||||||
|
schema: {
|
||||||
|
fragmentTagName: 'react-fragment',
|
||||||
|
componentTagName: 'react-component',
|
||||||
|
slotTagName: 'react-slot',
|
||||||
|
loadingClassName: 'loading'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const plugin = createReactPlugin(this.#root);
|
||||||
this.#actions = new Actions({
|
this.#actions = new Actions({
|
||||||
element: document.body,
|
element: document.body,
|
||||||
schema: {
|
schema: {
|
||||||
|
@ -40,6 +54,7 @@ export class TurboController extends ApplicationController {
|
||||||
focusDirectionAttribute: 'data-turbo-focus-direction',
|
focusDirectionAttribute: 'data-turbo-focus-direction',
|
||||||
hiddenClassName: 'hidden'
|
hiddenClassName: 'hidden'
|
||||||
},
|
},
|
||||||
|
plugins: [plugin],
|
||||||
debug: false
|
debug: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -47,6 +62,10 @@ export class TurboController extends ApplicationController {
|
||||||
// They allow us to preserve certain HTML changes across mutations.
|
// They allow us to preserve certain HTML changes across mutations.
|
||||||
this.#actions.observe();
|
this.#actions.observe();
|
||||||
|
|
||||||
|
this.#actions.ready().then(() => {
|
||||||
|
document.body.classList.add('dom-ready');
|
||||||
|
});
|
||||||
|
|
||||||
// setup spinner events
|
// setup spinner events
|
||||||
this.onGlobal('turbo:submit-start', () => this.startSpinner());
|
this.onGlobal('turbo:submit-start', () => this.startSpinner());
|
||||||
this.onGlobal('turbo:submit-end', () => this.stopSpinner());
|
this.onGlobal('turbo:submit-end', () => this.stopSpinner());
|
||||||
|
@ -73,6 +92,11 @@ export class TurboController extends ApplicationController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.#actions?.disconnect();
|
||||||
|
this.#root?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
private startSpinner() {
|
private startSpinner() {
|
||||||
this.#submitting = true;
|
this.#submitting = true;
|
||||||
this.actions.show({ targets: this.spinnerTargets });
|
this.actions.show({ targets: this.spinnerTargets });
|
||||||
|
@ -89,3 +113,24 @@ export class TurboController extends ApplicationController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Loader = (exportName: string) => Promise<ComponentType<unknown>>;
|
||||||
|
const componentsRegistry: Record<string, Loader> = {};
|
||||||
|
const components = import.meta.glob('../components/*.tsx');
|
||||||
|
|
||||||
|
const loader: Loader = (name) => {
|
||||||
|
const [moduleName, exportName] = name.split('/');
|
||||||
|
const loader = componentsRegistry[moduleName];
|
||||||
|
invariant(loader, `Cannot find a React component with name "${name}"`);
|
||||||
|
return loader(exportName ?? 'default');
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [path, loader] of Object.entries(components)) {
|
||||||
|
const [filename] = path.split('/').reverse();
|
||||||
|
const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
|
||||||
|
console.debug(`Registered lazy export for "${componentClassName}" component`);
|
||||||
|
componentsRegistry[componentClassName] = (exportName) =>
|
||||||
|
loader().then(
|
||||||
|
(m) => (m as Record<string, ComponentType<unknown>>)[exportName]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -285,7 +285,7 @@ class Champ < ApplicationRecord
|
||||||
return if value.nil?
|
return if value.nil?
|
||||||
return if value.present? && !value.include?("\u0000")
|
return if value.present? && !value.include?("\u0000")
|
||||||
|
|
||||||
self.value = value.delete("\u0000")
|
write_attribute(:value, value.delete("\u0000"))
|
||||||
end
|
end
|
||||||
|
|
||||||
class NotImplemented < ::StandardError
|
class NotImplemented < ::StandardError
|
||||||
|
|
|
@ -3,10 +3,6 @@ class Champs::AddressChamp < Champs::TextChamp
|
||||||
data.present?
|
data.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def feature
|
|
||||||
data.to_json if full_address?
|
|
||||||
end
|
|
||||||
|
|
||||||
def feature=(value)
|
def feature=(value)
|
||||||
if value.blank?
|
if value.blank?
|
||||||
self.data = nil
|
self.data = nil
|
||||||
|
@ -22,6 +18,14 @@ class Champs::AddressChamp < Champs::TextChamp
|
||||||
self.data = nil
|
self.data = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def selected_items
|
||||||
|
if value.present?
|
||||||
|
[{ value:, label: value, data: full_address? ? data : nil }]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def address
|
def address
|
||||||
full_address? ? data : nil
|
full_address? ? data : nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,14 +7,11 @@ class Champs::AnnuaireEducationChamp < Champs::TextChamp
|
||||||
APIEducation::AnnuaireEducationAdapter.new(external_id).to_params
|
APIEducation::AnnuaireEducationAdapter.new(external_id).to_params
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_with_external_data!(data:)
|
def selected_items
|
||||||
if data&.is_a?(Hash) && data['nom_etablissement'].present? && data['nom_commune'].present? && data['identifiant_de_l_etablissement'].present?
|
if external_id.present?
|
||||||
update!(
|
[{ value: external_id, label: value }]
|
||||||
data: data,
|
|
||||||
value: "#{data['nom_etablissement']}, #{data['nom_commune']} (#{data['identifiant_de_l_etablissement']})"
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
update!(data: data)
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,10 +28,6 @@ class Champs::CommuneChamp < Champs::TextChamp
|
||||||
code_postal.present?
|
code_postal.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def code_postal=(value)
|
|
||||||
super(value&.gsub(/[[:space:]]/, ''))
|
|
||||||
end
|
|
||||||
|
|
||||||
alias postal_code code_postal
|
alias postal_code code_postal
|
||||||
|
|
||||||
def name
|
def name
|
||||||
|
@ -43,7 +39,36 @@ class Champs::CommuneChamp < Champs::TextChamp
|
||||||
end
|
end
|
||||||
|
|
||||||
def selected
|
def selected
|
||||||
code
|
code? ? "#{code}-#{code_postal}" : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def selected_items
|
||||||
|
if code?
|
||||||
|
[{ label: to_s, value: selected }]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def code=(code)
|
||||||
|
if code.blank?
|
||||||
|
self.code_departement = nil
|
||||||
|
self.code_postal = nil
|
||||||
|
self.external_id = nil
|
||||||
|
self.value = nil
|
||||||
|
elsif code.match?(/-/)
|
||||||
|
codes = code.split('-')
|
||||||
|
self.external_id = codes.first
|
||||||
|
self.code_postal = codes.second
|
||||||
|
else
|
||||||
|
self.external_id = code
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def safe_to_s
|
||||||
|
value.present? ? value.to_s : ''
|
||||||
end
|
end
|
||||||
|
|
||||||
def communes
|
def communes
|
||||||
|
@ -54,12 +79,6 @@ class Champs::CommuneChamp < Champs::TextChamp
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def safe_to_s
|
|
||||||
value.present? ? value.to_s : ''
|
|
||||||
end
|
|
||||||
|
|
||||||
def on_codes_change
|
def on_codes_change
|
||||||
return if !code?
|
return if !code?
|
||||||
|
|
||||||
|
|
|
@ -641,8 +641,7 @@ class TypeDeChamp < ApplicationRecord
|
||||||
# We should refresh all champs after update except for champs using react or custom refresh
|
# We should refresh all champs after update except for champs using react or custom refresh
|
||||||
# logic (RNA, SIRET, etc.)
|
# logic (RNA, SIRET, etc.)
|
||||||
case type_champ
|
case type_champ
|
||||||
when type_champs.fetch(:annuaire_education),
|
when type_champs.fetch(:carte),
|
||||||
type_champs.fetch(:carte),
|
|
||||||
type_champs.fetch(:piece_justificative),
|
type_champs.fetch(:piece_justificative),
|
||||||
type_champs.fetch(:titre_identite),
|
type_champs.fetch(:titre_identite),
|
||||||
type_champs.fetch(:rna),
|
type_champs.fetch(:rna),
|
||||||
|
|
|
@ -65,15 +65,13 @@
|
||||||
|
|
||||||
.instructeur-wrapper
|
.instructeur-wrapper
|
||||||
%p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie
|
%p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie
|
||||||
= hidden_field_tag :emails, nil
|
%react-fragment
|
||||||
= react_component("ComboMultiple",
|
= render ReactComponent.new "ComboBox/MultiComboBox",
|
||||||
options: [],
|
id: 'emails',
|
||||||
selected: [], disabled: [],
|
name: 'emails[]',
|
||||||
group: '.instructeur-wrapper',
|
allows_custom_value: true,
|
||||||
name: 'emails',
|
'aria-label': 'Emails',
|
||||||
label: 'Emails',
|
'aria-describedby': 'experts-emails'
|
||||||
describedby: 'experts-emails',
|
|
||||||
acceptNewValues: true)
|
|
||||||
|
|
||||||
= f.submit 'Ajouter à la liste', class: 'fr-btn'
|
= f.submit 'Ajouter à la liste', class: 'fr-btn'
|
||||||
|
|
||||||
|
|
|
@ -9,14 +9,8 @@
|
||||||
- if disabled_as_super_admin
|
- if disabled_as_super_admin
|
||||||
= f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails'
|
= f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails'
|
||||||
- else
|
- else
|
||||||
= hidden_field_tag :emails, nil
|
%react-fragment
|
||||||
= react_component("ComboMultiple",
|
= render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails'
|
||||||
options: available_instructeur_emails, selected: [], disabled: [],
|
|
||||||
group: '.instructeur-wrapper',
|
|
||||||
id: 'instructeur_emails',
|
|
||||||
name: 'emails',
|
|
||||||
label: 'Emails',
|
|
||||||
acceptNewValues: true)
|
|
||||||
|
|
||||||
= f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin
|
= f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin
|
||||||
|
|
||||||
|
|
|
@ -122,17 +122,16 @@
|
||||||
.fr-fieldset__element
|
.fr-fieldset__element
|
||||||
= f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label'
|
= f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label'
|
||||||
%p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs.
|
%p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs.
|
||||||
= hidden_field_tag 'procedure[tags]', JSON.generate(@procedure.tags)
|
%react-fragment
|
||||||
= react_component("ComboMultiple",
|
= render ReactComponent.new "ComboBox/MultiComboBox",
|
||||||
id: "procedure_tags_combo",
|
id: "procedure_tags_combo",
|
||||||
options: Procedure.tags,
|
items: Procedure.tags,
|
||||||
selected: @procedure.tags,
|
selected_keys: @procedure.tags,
|
||||||
disabled: [],
|
name: 'procedure[tags][]',
|
||||||
label: 'Tags',
|
value_separator: ',|;',
|
||||||
group: '.procedure_tags_combo',
|
allows_custom_value: true,
|
||||||
name: 'tags',
|
'aria-label': 'Tags',
|
||||||
describedby: 'procedure-tags',
|
'aria-describedby': 'procedure-tags'
|
||||||
acceptNewValues: true)
|
|
||||||
|
|
||||||
%details.procedure-form__options-details
|
%details.procedure-form__options-details
|
||||||
%summary.procedure-form__options-summary
|
%summary.procedure-form__options-summary
|
||||||
|
|
|
@ -7,12 +7,7 @@
|
||||||
%p.tab-paragrah.mb-1
|
%p.tab-paragrah.mb-1
|
||||||
Le destinataire suivra automatiquement le dossier
|
Le destinataire suivra automatiquement le dossier
|
||||||
= form_for dossier, url: send_to_instructeurs_instructeur_dossier_path(dossier.procedure, dossier), method: :post, html: { class: 'form recipients-form fr-mb-4w' } do |f|
|
= form_for dossier, url: send_to_instructeurs_instructeur_dossier_path(dossier.procedure, dossier), method: :post, html: { class: 'form recipients-form fr-mb-4w' } do |f|
|
||||||
= hidden_field_tag :recipients, nil
|
%react-fragment
|
||||||
= react_component("ComboMultiple",
|
= render ReactComponent.new "ComboBox/MultiComboBox", items: potential_recipients.map { [_1.email, _1.id] }, name: 'recipients[]', 'aria-label': 'Emails'
|
||||||
options: potential_recipients.map{|r| [r.email, r.id]},
|
|
||||||
selected: [], disabled: [],
|
|
||||||
group: '.recipients-form',
|
|
||||||
name: 'recipients',
|
|
||||||
label: 'Emails')
|
|
||||||
|
|
||||||
= f.submit "Envoyer", class: "fr-btn fr-mt-2w"
|
= f.submit "Envoyer", class: "fr-btn fr-mt-2w"
|
||||||
|
|
|
@ -110,14 +110,8 @@
|
||||||
= t('views.instructeurs.dossiers.personalize')
|
= t('views.instructeurs.dossiers.personalize')
|
||||||
- menu.with_form do
|
- menu.with_form do
|
||||||
= form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do
|
= form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do
|
||||||
= hidden_field_tag :values, nil
|
%react-fragment
|
||||||
= react_component("ComboMultiple",
|
= render ReactComponent.new "ComboBox/MultiComboBox", items: @displayable_fields_for_select, selected_keys: @displayable_fields_selected, name: 'values[]', 'aria-label': 'Colonne à afficher'
|
||||||
options: @displayable_fields_for_select,
|
|
||||||
selected: @displayable_fields_selected,
|
|
||||||
disabled: [],
|
|
||||||
label: 'Colonne à afficher',
|
|
||||||
group: '.columns-form',
|
|
||||||
name: 'values')
|
|
||||||
|
|
||||||
= submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary'
|
= submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary'
|
||||||
|
|
||||||
|
|
|
@ -59,7 +59,5 @@
|
||||||
- else
|
- else
|
||||||
= render 'footer'
|
= render 'footer'
|
||||||
|
|
||||||
- if Rails.env.development?
|
|
||||||
= vite_typescript_tag 'axe-core'
|
|
||||||
= yield :charts_js
|
= yield :charts_js
|
||||||
= render Attachment::ProgressBarComponent.new
|
= render Attachment::ProgressBarComponent.new
|
||||||
|
|
|
@ -11,6 +11,8 @@ by providing a `content_for(:javascript)` block.
|
||||||
<%= javascript_include_tag js_path %>
|
<%= javascript_include_tag js_path %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= vite_client_tag %>
|
||||||
|
<%= vite_react_refresh_tag %>
|
||||||
<%= vite_typescript_tag 'manager' %>
|
<%= vite_typescript_tag 'manager' %>
|
||||||
|
|
||||||
<%= yield :javascript %>
|
<%= yield :javascript %>
|
||||||
|
|
|
@ -93,16 +93,15 @@ as well as a link to its edit page.
|
||||||
|
|
||||||
<% elsif attribute.name == 'tags' %>
|
<% elsif attribute.name == 'tags' %>
|
||||||
<%= form_for procedure, url: add_tags_manager_procedure_path(procedure), html: { class: 'form procedure-form__column--form fr-background-alt--blue-france mt-1' } do %>
|
<%= form_for procedure, url: add_tags_manager_procedure_path(procedure), html: { class: 'form procedure-form__column--form fr-background-alt--blue-france mt-1' } do %>
|
||||||
<%= hidden_field_tag 'procedure[tags]', nil %>
|
<react-fragment>
|
||||||
<%= react_component("ComboMultiple",
|
<%= render ReactComponent.new "ComboBox/MultiComboBox",
|
||||||
options: Procedure.tags,
|
items: Procedure.tags,
|
||||||
selected: procedure.tags,
|
selected_keys: procedure.tags,
|
||||||
disabled: [],
|
value_separator: ',|;',
|
||||||
label: 'Tags',
|
allows_custom_value: true,
|
||||||
group: '.procedure-form__column--form',
|
name: 'procedure[tags][]',
|
||||||
name: 'tags',
|
'aria-label': 'Tags' %>
|
||||||
describedby: 'procedure-tags',
|
</react-fragment>
|
||||||
acceptNewValues: true) %>
|
|
||||||
<button class="mt-1">Ajouter des tags</button>
|
<button class="mt-1">Ajouter des tags</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|
|
@ -10,16 +10,9 @@
|
||||||
|
|
||||||
= render NestedForms::FormOwnerComponent.new
|
= render NestedForms::FormOwnerComponent.new
|
||||||
= form_for avis, url: url, html: { multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@dossier, :avis_by_instructeur) } } do |f|
|
= form_for avis, url: url, html: { multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@dossier, :avis_by_instructeur) } } do |f|
|
||||||
= hidden_field_tag 'avis[emails]', nil
|
|
||||||
.fr-input-group
|
.fr-input-group
|
||||||
= react_component("ComboMultiple",
|
%react-fragment
|
||||||
options: current_expert_not_instructeur? ? [] : @experts_emails,
|
= render ReactComponent.new "ComboBox/MultiComboBox", items: current_expert_not_instructeur? ? [] : @experts_emails, name: f.field_name(:emails, multiple: true), id: 'avis_emails', 'aria-label': 'Emails', 'aria-describedby': 'avis-emails-description', allows_custom_value: !@dossier.procedure.experts_require_administrateur_invitation
|
||||||
selected: [], disabled: [],
|
|
||||||
label: 'Emails',
|
|
||||||
group: '.ask-avis',
|
|
||||||
name: 'emails',
|
|
||||||
describedby: 'avis-emails-description',
|
|
||||||
acceptNewValues: !@dossier.procedure.experts_require_administrateur_invitation)
|
|
||||||
|
|
||||||
.fr-input-group
|
.fr-input-group
|
||||||
= f.label :introduction, t('helpers.label.introduction'), class: 'fr-label'
|
= f.label :introduction, t('helpers.label.introduction'), class: 'fr-label'
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
- if champ.geometry?
|
- if champ.geometry?
|
||||||
= react_component("MapReader", { featureCollection: champ.to_feature_collection, options: champ.render_options } )
|
%react-fragment.width-100
|
||||||
|
= render ReactComponent.new "MapReader", feature_collection: champ.to_feature_collection, options: champ.render_options
|
||||||
.geo-areas
|
.geo-areas
|
||||||
= render Dossiers::GeoAreasComponent.new(champ:, editing: false)
|
= render Dossiers::GeoAreasComponent.new(champ:, editing: false)
|
||||||
|
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -251,6 +251,7 @@ Rails.application.routes.draw do
|
||||||
namespace :data_sources do
|
namespace :data_sources do
|
||||||
get :adresse, to: 'adresse#search', as: :data_source_adresse
|
get :adresse, to: 'adresse#search', as: :data_source_adresse
|
||||||
get :commune, to: 'commune#search', as: :data_source_commune
|
get :commune, to: 'commune#search', as: :data_source_commune
|
||||||
|
get :education, to: 'education#search', as: :data_source_education
|
||||||
|
|
||||||
get :search_domaine_fonct, to: 'chorus#search_domaine_fonct', as: :search_domaine_fonct
|
get :search_domaine_fonct, to: 'chorus#search_domaine_fonct', as: :search_domaine_fonct
|
||||||
get :search_centre_couts, to: 'chorus#search_centre_couts', as: :search_centre_couts
|
get :search_centre_couts, to: 'chorus#search_centre_couts', as: :search_centre_couts
|
||||||
|
|
29
package.json
29
package.json
|
@ -1,9 +1,10 @@
|
||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@coldwired/actions": "^0.11.2",
|
"@coldwired/actions": "^0.13.0",
|
||||||
"@coldwired/turbo-stream": "^0.11.1",
|
"@coldwired/react": "^0.15.0",
|
||||||
"@coldwired/utils": "^0.11.4",
|
"@coldwired/turbo-stream": "^0.13.0",
|
||||||
|
"@coldwired/utils": "^0.13.0",
|
||||||
"@frsource/autoresize-textarea": "^2.0.75",
|
"@frsource/autoresize-textarea": "^2.0.75",
|
||||||
"@gouvfr/dsfr": "^1.11.2",
|
"@gouvfr/dsfr": "^1.11.2",
|
||||||
"@graphiql/plugin-explorer": "^3.0.2",
|
"@graphiql/plugin-explorer": "^3.0.2",
|
||||||
|
@ -17,7 +18,6 @@
|
||||||
"@rails/actiontext": "^7.1.3-2",
|
"@rails/actiontext": "^7.1.3-2",
|
||||||
"@rails/activestorage": "^7.1.3-2",
|
"@rails/activestorage": "^7.1.3-2",
|
||||||
"@rails/ujs": "^7.1.3-2",
|
"@rails/ujs": "^7.1.3-2",
|
||||||
"@reach/combobox": "^0.17.0",
|
|
||||||
"@reach/slider": "^0.17.0",
|
"@reach/slider": "^0.17.0",
|
||||||
"@sentry/browser": "8.7.0",
|
"@sentry/browser": "8.7.0",
|
||||||
"@tiptap/core": "^2.2.4",
|
"@tiptap/core": "^2.2.4",
|
||||||
|
@ -50,31 +50,32 @@
|
||||||
"graphiql": "^3.2.3",
|
"graphiql": "^3.2.3",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"highcharts": "^10.3.3",
|
"highcharts": "^10.3.3",
|
||||||
"is-hotkey": "^0.2.0",
|
|
||||||
"lightgallery": "^2.7.2",
|
"lightgallery": "^2.7.2",
|
||||||
"maplibre-gl": "^1.15.2",
|
"maplibre-gl": "^1.15.2",
|
||||||
"match-sorter": "^6.3.4",
|
"match-sorter": "^6.3.4",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.0",
|
||||||
|
"react-aria-components": "^1.2.0",
|
||||||
"react-coordinate-input": "^1.0.0",
|
"react-coordinate-input": "^1.0.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-query": "^3.39.3",
|
"react-use-event-hook": "^0.9.6",
|
||||||
"spectaql": "^2.3.1",
|
"spectaql": "^2.3.1",
|
||||||
"stimulus-use": "^0.52.2",
|
"stimulus-use": "^0.52.2",
|
||||||
"terser": "^5.31.0",
|
"terser": "^5.31.0",
|
||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"trix": "^1.2.3",
|
"trix": "^1.2.3",
|
||||||
"use-debounce": "^9.0.4",
|
"usehooks-ts": "^3.1.0",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild/darwin-arm64": "=0.19.9",
|
"@esbuild/darwin-arm64": "=0.19.9",
|
||||||
"@esbuild/linux-x64": "=0.19.9",
|
"@esbuild/linux-x64": "=0.19.9",
|
||||||
"@esbuild/win32-x64": "=0.19.9",
|
"@esbuild/win32-x64": "=0.19.9",
|
||||||
"@rollup/rollup-linux-x64-gnu": "=4.9.1",
|
"@react-aria/optimize-locales-plugin": "^1.1.0",
|
||||||
"@rollup/rollup-darwin-arm64": "=4.9.1",
|
"@rollup/rollup-darwin-arm64": "=4.9.1",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "=4.9.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "=4.9.1",
|
"@rollup/rollup-win32-x64-msvc": "=4.9.1",
|
||||||
"@types/debounce": "^1.2.4",
|
"@types/debounce": "^1.2.4",
|
||||||
"@types/geojson": "^7946.0.14",
|
"@types/geojson": "^7946.0.14",
|
||||||
|
@ -82,8 +83,8 @@
|
||||||
"@types/mapbox__mapbox-gl-draw": "^1.2.5",
|
"@types/mapbox__mapbox-gl-draw": "^1.2.5",
|
||||||
"@types/rails__activestorage": "^7.1.1",
|
"@types/rails__activestorage": "^7.1.1",
|
||||||
"@types/rails__ujs": "^6.0.4",
|
"@types/rails__ujs": "^6.0.4",
|
||||||
"@types/react": "^17.0.43",
|
"@types/react": "^18.2.79",
|
||||||
"@types/react-dom": "^17.0.14",
|
"@types/react-dom": "^18.2.25",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.11.0",
|
"@typescript-eslint/eslint-plugin": "^7.11.0",
|
||||||
"@typescript-eslint/parser": "^7.11.0",
|
"@typescript-eslint/parser": "^7.11.0",
|
||||||
|
@ -114,7 +115,8 @@
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"coverage": "vitest run --coverage",
|
"coverage": "vitest run --coverage",
|
||||||
"up": "bunx npm-check-updates --root --format group -i"
|
"up": "bunx npm-check-updates --root --format group -i",
|
||||||
|
"vite-bundle-visualizer": "bunx vite-bundle-visualizer"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"string-width": "4.2.2",
|
"string-width": "4.2.2",
|
||||||
|
@ -169,6 +171,7 @@
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
"prettier"
|
"prettier"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|
|
@ -1,112 +0,0 @@
|
||||||
class Dsfr::ComboboxComponentPreview < ViewComponent::Preview
|
|
||||||
OPTIONS = [
|
|
||||||
'Cheddar',
|
|
||||||
'Brie',
|
|
||||||
'Mozzarella',
|
|
||||||
'Gouda',
|
|
||||||
'Swiss',
|
|
||||||
'Parmesan',
|
|
||||||
'Feta',
|
|
||||||
'Blue cheese',
|
|
||||||
'Camembert',
|
|
||||||
'Monterey Jack',
|
|
||||||
'Roquefort',
|
|
||||||
'Provolone',
|
|
||||||
'Colby',
|
|
||||||
'Havarti',
|
|
||||||
'Ricotta',
|
|
||||||
'Pepper Jack',
|
|
||||||
'Muenster',
|
|
||||||
'Fontina',
|
|
||||||
'Limburger',
|
|
||||||
'Asiago',
|
|
||||||
'Cottage cheese',
|
|
||||||
'Emmental',
|
|
||||||
'Mascarpone',
|
|
||||||
'Taleggio',
|
|
||||||
'Gruyere',
|
|
||||||
'Edam',
|
|
||||||
'Pecorino Romano',
|
|
||||||
'Manchego',
|
|
||||||
'Halloumi',
|
|
||||||
'Jarlsberg',
|
|
||||||
'Munster',
|
|
||||||
'Stilton',
|
|
||||||
'Gorgonzola',
|
|
||||||
'Queso blanco',
|
|
||||||
'Queso fresco',
|
|
||||||
'Queso de bola',
|
|
||||||
'Queso de cabra',
|
|
||||||
'Queso panela',
|
|
||||||
'Queso Oaxaca',
|
|
||||||
'Queso Chihuahua',
|
|
||||||
'Queso manchego',
|
|
||||||
'Queso de bola',
|
|
||||||
'Queso de bola de cabra',
|
|
||||||
'Queso de bola de vaca',
|
|
||||||
'Queso de bola de oveja',
|
|
||||||
'Queso de bola de mezcla',
|
|
||||||
'Queso de bola de leche cruda',
|
|
||||||
'Queso de bola de leche pasteurizada',
|
|
||||||
'Queso de bola de leche de cabra',
|
|
||||||
'Queso de bola de leche de vaca',
|
|
||||||
'Queso de bola de leche de oveja',
|
|
||||||
'Queso de bola de leche de mezcla',
|
|
||||||
'Burrata',
|
|
||||||
'Scamorza',
|
|
||||||
'Caciocavallo',
|
|
||||||
'Provolone piccante',
|
|
||||||
'Pecorino sardo',
|
|
||||||
'Pecorino toscano',
|
|
||||||
'Pecorino siciliano',
|
|
||||||
'Pecorino calabrese',
|
|
||||||
'Pecorino moliterno',
|
|
||||||
'Pecorino di fossa',
|
|
||||||
'Pecorino di filiano',
|
|
||||||
'Pecorino di pienza',
|
|
||||||
'Pecorino di grotta',
|
|
||||||
'Pecorino di capra',
|
|
||||||
'Pecorino di mucca',
|
|
||||||
'Pecorino di pecora',
|
|
||||||
'Pecorino di bufala',
|
|
||||||
'Cacio di bosco',
|
|
||||||
'Cacio di roma',
|
|
||||||
'Cacio di fossa',
|
|
||||||
'Cacio di tricarico',
|
|
||||||
'Cacio di cavallo',
|
|
||||||
'Cacio di capra',
|
|
||||||
'Cacio di mucca',
|
|
||||||
'Cacio di pecora',
|
|
||||||
'Cacio di bufala',
|
|
||||||
'Taleggio di capra',
|
|
||||||
'Taleggio di mucca',
|
|
||||||
'Taleggio di pecora',
|
|
||||||
'Taleggio di bufala',
|
|
||||||
'Bel Paese',
|
|
||||||
'Crescenza',
|
|
||||||
'Stracchino',
|
|
||||||
'Robiola',
|
|
||||||
'Toma',
|
|
||||||
'Bra',
|
|
||||||
'Castelmagno',
|
|
||||||
'Raschera',
|
|
||||||
'Montasio',
|
|
||||||
'Piave',
|
|
||||||
'Bitto',
|
|
||||||
'Quartirolo Lombardo',
|
|
||||||
'Formaggella del Luinese',
|
|
||||||
'Formaggella della Val Vigezzo',
|
|
||||||
'Formaggella della Valle Grana',
|
|
||||||
'Formaggella della Val Bognanco',
|
|
||||||
'Formaggella della Val d’Intelvi',
|
|
||||||
'Formaggella della Val Gerola'
|
|
||||||
]
|
|
||||||
|
|
||||||
def simple_select_with_options
|
|
||||||
render Dsfr::ComboboxComponent.new(options: OPTIONS, selected: OPTIONS.sample, input_html_options: { name: :value, id: 'simple-select', class: 'width-33' })
|
|
||||||
end
|
|
||||||
|
|
||||||
def simple_select_with_options_and_allows_custom_value
|
|
||||||
render Dsfr::ComboboxComponent.new(options: OPTIONS, selected: OPTIONS.sample, allows_custom_value: true, input_html_options: { id: 'simple-select', class: 'width-33', name: :value })
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -26,7 +26,7 @@ describe Administrateurs::ExpertsProceduresController, type: :controller do
|
||||||
subject { post :create, params: params }
|
subject { post :create, params: params }
|
||||||
|
|
||||||
context 'when inviting multiple valid experts' do
|
context 'when inviting multiple valid experts' do
|
||||||
let(:params) { { procedure_id: procedure.id, emails: [expert.email, "new@expert.fr"].to_json } }
|
let(:params) { { procedure_id: procedure.id, emails: [expert.email, "new@expert.fr"] } }
|
||||||
|
|
||||||
it 'creates experts' do
|
it 'creates experts' do
|
||||||
subject
|
subject
|
||||||
|
@ -38,7 +38,7 @@ describe Administrateurs::ExpertsProceduresController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when inviting expert using an email with typos' do
|
context 'when inviting expert using an email with typos' do
|
||||||
let(:params) { { procedure_id: procedure.id, emails: ['martin@oraneg.fr'].to_json } }
|
let(:params) { { procedure_id: procedure.id, emails: ['martin@oraneg.fr'] } }
|
||||||
render_views
|
render_views
|
||||||
it 'warns' do
|
it 'warns' do
|
||||||
subject
|
subject
|
||||||
|
|
|
@ -332,14 +332,14 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
|
||||||
describe '#add_instructeur_procedure_non_routee' do
|
describe '#add_instructeur_procedure_non_routee' do
|
||||||
# faire la meme chose sur une procedure non routee
|
# faire la meme chose sur une procedure non routee
|
||||||
let(:procedure_non_routee) { create(:procedure, administrateur: admin) }
|
let(:procedure_non_routee) { create(:procedure, administrateur: admin) }
|
||||||
let(:emails) { ['instructeur_3@ministere_a.gouv.fr', 'instructeur_4@ministere_b.gouv.fr'].to_json }
|
let(:emails) { ['instructeur_3@ministere_a.gouv.fr', 'instructeur_4@ministere_b.gouv.fr'] }
|
||||||
let(:manager) { false }
|
let(:manager) { false }
|
||||||
before {
|
before {
|
||||||
procedure_non_routee.administrateurs_procedures.where(administrateur: admin).update_all(manager:)
|
procedure_non_routee.administrateurs_procedures.where(administrateur: admin).update_all(manager:)
|
||||||
}
|
}
|
||||||
subject { post :add_instructeur, params: { emails: emails, procedure_id: procedure_non_routee.id, id: procedure_non_routee.defaut_groupe_instructeur.id } }
|
subject { post :add_instructeur, params: { emails: emails, procedure_id: procedure_non_routee.id, id: procedure_non_routee.defaut_groupe_instructeur.id } }
|
||||||
context 'when all emails are valid' do
|
context 'when all emails are valid' do
|
||||||
let(:emails) { ['test@b.gouv.fr', 'test2@b.gouv.fr'].to_json }
|
let(:emails) { ['test@b.gouv.fr', 'test2@b.gouv.fr'] }
|
||||||
it do
|
it do
|
||||||
expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee)
|
expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee)
|
||||||
expect(subject.request.flash[:alert]).to be_nil
|
expect(subject.request.flash[:alert]).to be_nil
|
||||||
|
@ -348,7 +348,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when there is at least one bad email' do
|
context 'when there is at least one bad email' do
|
||||||
let(:emails) { ['badmail', 'instructeur2@gmail.com'].to_json }
|
let(:emails) { ['badmail', 'instructeur2@gmail.com'] }
|
||||||
it do
|
it do
|
||||||
expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee)
|
expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee)
|
||||||
expect(subject.request.flash[:alert]).to be_present
|
expect(subject.request.flash[:alert]).to be_present
|
||||||
|
@ -359,7 +359,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
|
||||||
context 'when the admin wants to assign an instructor who is already assigned on this procedure' do
|
context 'when the admin wants to assign an instructor who is already assigned on this procedure' do
|
||||||
let(:instructeur) { create(:instructeur) }
|
let(:instructeur) { create(:instructeur) }
|
||||||
before { procedure_non_routee.groupe_instructeurs.first.add_instructeurs(emails: [instructeur.user.email]) }
|
before { procedure_non_routee.groupe_instructeurs.first.add_instructeurs(emails: [instructeur.user.email]) }
|
||||||
let(:emails) { [instructeur.email].to_json }
|
let(:emails) { [instructeur.email] }
|
||||||
it { expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee) }
|
it { expect(subject).to redirect_to admin_procedure_groupe_instructeurs_path(procedure_non_routee) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -376,7 +376,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do
|
||||||
params: {
|
params: {
|
||||||
procedure_id: procedure.id,
|
procedure_id: procedure.id,
|
||||||
id: gi_1_2.id,
|
id: gi_1_2.id,
|
||||||
emails: new_instructeur_emails.to_json
|
emails: new_instructeur_emails
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ describe Administrateurs::ProceduresController, type: :controller do
|
||||||
let(:lien_site_web) { 'http://mon-site.gouv.fr' }
|
let(:lien_site_web) { 'http://mon-site.gouv.fr' }
|
||||||
let(:zone) { create(:zone) }
|
let(:zone) { create(:zone) }
|
||||||
let(:zone_ids) { [zone.id] }
|
let(:zone_ids) { [zone.id] }
|
||||||
let(:tags) { "[\"planete\",\"environnement\"]" }
|
let(:tags) { ["planete", "environnement"] }
|
||||||
|
|
||||||
describe '#apercu' do
|
describe '#apercu' do
|
||||||
subject { get :apercu, params: { id: procedure.id } }
|
subject { get :apercu, params: { id: procedure.id } }
|
||||||
|
|
|
@ -367,7 +367,7 @@ describe Experts::AvisController, type: :controller do
|
||||||
let(:previous_avis_confidentiel) { false }
|
let(:previous_avis_confidentiel) { false }
|
||||||
let(:previous_revoked_at) { nil }
|
let(:previous_revoked_at) { nil }
|
||||||
let!(:previous_avis) { create(:avis, dossier:, claimant:, experts_procedure:, confidentiel: previous_avis_confidentiel, revoked_at: previous_revoked_at) }
|
let!(:previous_avis) { create(:avis, dossier:, claimant:, experts_procedure:, confidentiel: previous_avis_confidentiel, revoked_at: previous_revoked_at) }
|
||||||
let(:emails) { '["a@b.com"]' }
|
let(:emails) { ["a@b.com"] }
|
||||||
let(:introduction) { 'introduction' }
|
let(:introduction) { 'introduction' }
|
||||||
let(:created_avis) { Avis.last }
|
let(:created_avis) { Avis.last }
|
||||||
let!(:old_avis_count) { Avis.count }
|
let!(:old_avis_count) { Avis.count }
|
||||||
|
@ -394,7 +394,7 @@ describe Experts::AvisController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when an invalid email' do
|
context 'when an invalid email' do
|
||||||
let(:emails) { "[\"toto.fr\"]" }
|
let(:emails) { ["toto.fr"] }
|
||||||
|
|
||||||
it do
|
it do
|
||||||
expect(response).to render_template :instruction
|
expect(response).to render_template :instruction
|
||||||
|
@ -414,7 +414,7 @@ describe Experts::AvisController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'ask review with attachment' do
|
context 'ask review with attachment' do
|
||||||
let(:emails) { "[\"toto@totomail.com\"]" }
|
let(:emails) { ["toto@totomail.com"] }
|
||||||
|
|
||||||
it do
|
it do
|
||||||
expect(created_avis.introduction_file).to be_attached
|
expect(created_avis.introduction_file).to be_attached
|
||||||
|
@ -425,7 +425,7 @@ describe Experts::AvisController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with multiple emails' do
|
context 'with multiple emails' do
|
||||||
let(:emails) { "[\"toto.fr\",\"titi@titimail.com\"]" }
|
let(:emails) { ["toto.fr", "titi@titimail.com"] }
|
||||||
|
|
||||||
it do
|
it do
|
||||||
expect(response).to render_template :instruction
|
expect(response).to render_template :instruction
|
||||||
|
|
|
@ -28,7 +28,7 @@ describe Instructeurs::DossiersController, type: :controller do
|
||||||
post(
|
post(
|
||||||
:send_to_instructeurs,
|
:send_to_instructeurs,
|
||||||
params: {
|
params: {
|
||||||
recipients: [recipient.id].to_json,
|
recipients: [recipient.id],
|
||||||
procedure_id: procedure.id,
|
procedure_id: procedure.id,
|
||||||
dossier_id: dossier.id
|
dossier_id: dossier.id
|
||||||
}
|
}
|
||||||
|
@ -776,7 +776,7 @@ describe Instructeurs::DossiersController, type: :controller do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:emails) { "[\"email@a.com\"]" }
|
let(:emails) { ["email@a.com"] }
|
||||||
|
|
||||||
context "notifications updates" do
|
context "notifications updates" do
|
||||||
context 'when an instructeur follows the dossier' do
|
context 'when an instructeur follows the dossier' do
|
||||||
|
@ -811,7 +811,7 @@ describe Instructeurs::DossiersController, type: :controller do
|
||||||
it { expect(response).to redirect_to(avis_instructeur_dossier_path(dossier.procedure, dossier)) }
|
it { expect(response).to redirect_to(avis_instructeur_dossier_path(dossier.procedure, dossier)) }
|
||||||
|
|
||||||
context "with an invalid email" do
|
context "with an invalid email" do
|
||||||
let(:emails) { "[\"emaila.com\"]" }
|
let(:emails) { ["emaila.com"] }
|
||||||
|
|
||||||
before { subject }
|
before { subject }
|
||||||
|
|
||||||
|
@ -822,7 +822,7 @@ describe Instructeurs::DossiersController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context "with no email" do
|
context "with no email" do
|
||||||
let(:emails) { "" }
|
let(:emails) { [] }
|
||||||
|
|
||||||
before { subject }
|
before { subject }
|
||||||
|
|
||||||
|
@ -833,7 +833,7 @@ describe Instructeurs::DossiersController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with multiple emails' do
|
context 'with multiple emails' do
|
||||||
let(:emails) { "[\"toto.fr\",\"titi@titimail.com\"]" }
|
let(:emails) { ["toto.fr", "titi@titimail.com"] }
|
||||||
|
|
||||||
before { subject }
|
before { subject }
|
||||||
|
|
||||||
|
@ -845,7 +845,7 @@ describe Instructeurs::DossiersController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the expert do not want to receive notification' do
|
context 'when the expert do not want to receive notification' do
|
||||||
let(:emails) { "[\"email@a.com\"]" }
|
let(:emails) { ["email@a.com"] }
|
||||||
let(:experts_procedure) { create(:experts_procedure, expert: expert, procedure: dossier.procedure, notify_on_new_avis: false) }
|
let(:experts_procedure) { create(:experts_procedure, expert: expert, procedure: dossier.procedure, notify_on_new_avis: false) }
|
||||||
|
|
||||||
before { subject }
|
before { subject }
|
||||||
|
|
639
spec/fixtures/cassettes/The_user/fill_a_dossier.yml
vendored
639
spec/fixtures/cassettes/The_user/fill_a_dossier.yml
vendored
File diff suppressed because one or more lines are too long
|
@ -22,21 +22,19 @@ RSpec.describe Champs::AnnuaireEducationChamp do
|
||||||
it_behaves_like "a data updater (without updating the value)", ''
|
it_behaves_like "a data updater (without updating the value)", ''
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when data is inconsistent' do
|
|
||||||
let(:data) { { 'yo' => 'lo' } }
|
|
||||||
it_behaves_like "a data updater (without updating the value)", { 'yo' => 'lo' }
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when data is consistent' do
|
context 'when data is consistent' do
|
||||||
let(:data) {
|
let(:data) {
|
||||||
{
|
{
|
||||||
'nom_etablissement': "karrigel an ankou",
|
'nom_etablissement' => "karrigel an ankou",
|
||||||
'nom_commune' => 'kumun',
|
'nom_commune' => 'kumun',
|
||||||
'identifiant_de_l_etablissement' => '666667'
|
'identifiant_de_l_etablissement' => '666667'
|
||||||
}.with_indifferent_access
|
}
|
||||||
|
}
|
||||||
|
it_behaves_like "a data updater (without updating the value)", {
|
||||||
|
'nom_etablissement' => "karrigel an ankou",
|
||||||
|
'nom_commune' => 'kumun',
|
||||||
|
'identifiant_de_l_etablissement' => '666667'
|
||||||
}
|
}
|
||||||
it { expect { subject }.to change { champ.reload.data }.to(data) }
|
|
||||||
it { expect { subject }.to change { champ.reload.value }.to('karrigel an ankou, kumun (666667)') }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,7 @@ describe Champs::CommuneChamp do
|
||||||
let(:champ) { create(:champ_communes, code_postal:, external_id: code_insee) }
|
let(:champ) { create(:champ_communes, code_postal:, external_id: code_insee) }
|
||||||
|
|
||||||
describe 'value' do
|
describe 'value' do
|
||||||
it 'with code_postal' do
|
it 'find commune' do
|
||||||
expect(champ.to_s).to eq('Châteldon (63290)')
|
expect(champ.to_s).to eq('Châteldon (63290)')
|
||||||
expect(champ.name).to eq('Châteldon')
|
expect(champ.name).to eq('Châteldon')
|
||||||
expect(champ.external_id).to eq(code_insee)
|
expect(champ.external_id).to eq(code_insee)
|
||||||
|
@ -15,15 +15,22 @@ describe Champs::CommuneChamp do
|
||||||
expect(champ.for_export(:value)).to eq 'Châteldon (63290)'
|
expect(champ.for_export(:value)).to eq 'Châteldon (63290)'
|
||||||
expect(champ.for_export(:code)).to eq '63102'
|
expect(champ.for_export(:code)).to eq '63102'
|
||||||
expect(champ.for_export(:departement)).to eq '63 – Puy-de-Dôme'
|
expect(champ.for_export(:departement)).to eq '63 – Puy-de-Dôme'
|
||||||
expect(champ.communes.size).to eq(8)
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
describe 'code_postal with spaces' do
|
context 'with code' do
|
||||||
let(:code_postal) { ' 63 2 90 ' }
|
let(:champ) { create(:champ_communes, code: '63102-63290') }
|
||||||
|
|
||||||
it 'with code_postal' do
|
it 'find commune' do
|
||||||
expect(champ.communes.size).to eq(8)
|
expect(champ.to_s).to eq('Châteldon (63290)')
|
||||||
|
expect(champ.name).to eq('Châteldon')
|
||||||
|
expect(champ.external_id).to eq(code_insee)
|
||||||
|
expect(champ.code).to eq(code_insee)
|
||||||
|
expect(champ.code_departement).to eq(code_departement)
|
||||||
|
expect(champ.code_postal).to eq(code_postal)
|
||||||
|
expect(champ.for_export(:value)).to eq 'Châteldon (63290)'
|
||||||
|
expect(champ.for_export(:code)).to eq '63102'
|
||||||
|
expect(champ.for_export(:departement)).to eq '63 – Puy-de-Dôme'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -106,25 +106,10 @@ module SystemHelpers
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def select_combobox(libelle, fill_with, value, check: true)
|
def select_combobox(libelle, value, custom_value: false)
|
||||||
fill_in libelle, with: fill_with
|
fill_in libelle, with: custom_value ? "#{value}," : value
|
||||||
find('li[role="option"][data-reach-combobox-option]', text: value, wait: 5).click
|
if !custom_value
|
||||||
if check
|
find_field(libelle).send_keys(:down, :enter)
|
||||||
check_selected_value(libelle, with: value)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_selected_value(libelle, with:)
|
|
||||||
field = find_hidden_field_for(libelle)
|
|
||||||
value = field.value.starts_with?('[') ? JSON.parse(field.value) : field.value
|
|
||||||
if value.is_a?(Array)
|
|
||||||
if with.is_a?(Array)
|
|
||||||
expect(value.sort).to eq(with.sort)
|
|
||||||
else
|
|
||||||
expect(value).to include(with)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
expect(value).to eq(with)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -60,7 +60,7 @@ describe 'Administrateurs can edit procedures', js: true do
|
||||||
procedure.update!(tags: ['social'])
|
procedure.update!(tags: ['social'])
|
||||||
|
|
||||||
visit edit_admin_procedure_path(procedure)
|
visit edit_admin_procedure_path(procedure)
|
||||||
select_combobox('procedure_tags_combo', 'planete', 'planete', check: false)
|
select_combobox('procedure_tags_combo', 'planete', custom_value: true)
|
||||||
click_on 'Enregistrer'
|
click_on 'Enregistrer'
|
||||||
|
|
||||||
expect(procedure.reload.tags).to eq(['social', 'planete'])
|
expect(procedure.reload.tags).to eq(['social', 'planete'])
|
||||||
|
|
|
@ -29,7 +29,8 @@ describe 'Inviting an expert:', js: true do
|
||||||
within('.fr-sidemenu') { click_on 'Demander un avis' }
|
within('.fr-sidemenu') { click_on 'Demander un avis' }
|
||||||
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
|
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
|
||||||
|
|
||||||
page.execute_script("document.querySelector('#avis_emails').value = '[\"#{expert.email}\",\"#{expert2.email}\"]'")
|
fill_in 'Emails', with: "#{expert.email},"
|
||||||
|
fill_in 'Emails', with: expert2.email
|
||||||
fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.'
|
fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.'
|
||||||
check 'avis_invite_linked_dossiers'
|
check 'avis_invite_linked_dossiers'
|
||||||
page.select 'confidentiel', from: 'avis_confidentiel'
|
page.select 'confidentiel', from: 'avis_confidentiel'
|
||||||
|
@ -109,7 +110,7 @@ describe 'Inviting an expert:', js: true do
|
||||||
within('.fr-sidemenu') { click_on 'Demander un avis' }
|
within('.fr-sidemenu') { click_on 'Demander un avis' }
|
||||||
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
|
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
|
||||||
|
|
||||||
fill_in 'Emails', with: "#{expert.email}; #{expert2.email}"
|
select_combobox 'Emails', expert.email
|
||||||
fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.'
|
fill_in 'avis_introduction', with: 'Bonjour, merci de me donner votre avis sur ce dossier.'
|
||||||
check 'avis_invite_linked_dossiers'
|
check 'avis_invite_linked_dossiers'
|
||||||
page.select 'confidentiel', from: 'avis_confidentiel'
|
page.select 'confidentiel', from: 'avis_confidentiel'
|
||||||
|
|
|
@ -160,13 +160,10 @@ describe 'Instructing a dossier:', js: true do
|
||||||
within('.fr-sidemenu') { click_on 'Demander un avis' }
|
within('.fr-sidemenu') { click_on 'Demander un avis' }
|
||||||
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
|
expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier))
|
||||||
|
|
||||||
expert_email_formated = "[\"expert@tps.com\"]"
|
|
||||||
expert_email = 'expert@tps.com'
|
expert_email = 'expert@tps.com'
|
||||||
ask_confidential_avis(expert_email_formated, 'a good introduction')
|
ask_confidential_avis(expert_email, 'a good introduction')
|
||||||
|
|
||||||
expert_email_formated = "[\"#{instructeur2.email}\"]"
|
ask_confidential_avis(instructeur2.email, 'a good introduction')
|
||||||
expert_email = instructeur2.email
|
|
||||||
ask_confidential_avis(expert_email_formated, 'a good introduction')
|
|
||||||
|
|
||||||
click_on 'Personnes impliquées'
|
click_on 'Personnes impliquées'
|
||||||
expect(page).to have_text(expert_email)
|
expect(page).to have_text(expert_email)
|
||||||
|
@ -189,8 +186,8 @@ describe 'Instructing a dossier:', js: true do
|
||||||
|
|
||||||
click_on 'Personnes impliquées'
|
click_on 'Personnes impliquées'
|
||||||
|
|
||||||
select_combobox('Emails', instructeur_2.email, instructeur_2.email, check: false)
|
select_combobox('Emails', instructeur_2.email)
|
||||||
select_combobox('Emails', instructeur_3.email, instructeur_3.email, check: false)
|
select_combobox('Emails', instructeur_3.email)
|
||||||
|
|
||||||
click_on 'Envoyer'
|
click_on 'Envoyer'
|
||||||
|
|
||||||
|
@ -287,7 +284,7 @@ describe 'Instructing a dossier:', js: true do
|
||||||
end
|
end
|
||||||
|
|
||||||
def ask_confidential_avis(to, introduction)
|
def ask_confidential_avis(to, introduction)
|
||||||
page.execute_script("document.querySelector('#avis_emails').value = '#{to}'")
|
fill_in 'avis_emails', with: to
|
||||||
fill_in 'avis_introduction', with: introduction
|
fill_in 'avis_introduction', with: introduction
|
||||||
select 'confidentiel', from: 'avis_confidentiel'
|
select 'confidentiel', from: 'avis_confidentiel'
|
||||||
within('form#new_avis') { click_on 'Demander un avis' }
|
within('form#new_avis') { click_on 'Demander un avis' }
|
||||||
|
|
|
@ -88,33 +88,13 @@ describe "procedure filters" do
|
||||||
|
|
||||||
scenario "should be able to user custom fiters", js: true do
|
scenario "should be able to user custom fiters", js: true do
|
||||||
# use date filter
|
# use date filter
|
||||||
click_on 'Sélectionner un filtre'
|
add_filter("En construction le", "10/10/2010", type: :date)
|
||||||
wait_until { all("#search-filter").size == 1 }
|
|
||||||
find('#search-filter', wait: 5).click
|
|
||||||
find('.fr-menu__item', text: "En construction le", wait: 5).click
|
|
||||||
find("input#value[type=date]", visible: true)
|
|
||||||
fill_in "Valeur", with: "10/10/2010"
|
|
||||||
click_button "Ajouter le filtre"
|
|
||||||
expect(page).to have_no_css("#search-filter", visible: true)
|
|
||||||
|
|
||||||
# use statut dropdown filter
|
# use statut dropdown filter
|
||||||
click_on 'Sélectionner un filtre'
|
add_filter('Statut', 'En construction', type: :enum)
|
||||||
wait_until { all("#search-filter").size == 1 }
|
|
||||||
find('#search-filter', wait: 5).click
|
|
||||||
find('.fr-menu__item', text: "Statut", wait: 5).click
|
|
||||||
find("select#value", visible: false)
|
|
||||||
select 'En construction', from: "Valeur"
|
|
||||||
click_button "Ajouter le filtre"
|
|
||||||
expect(page).to have_no_css("#search-filter", visible: true)
|
|
||||||
|
|
||||||
# use choice dropdown filter
|
# use choice dropdown filter
|
||||||
click_on 'Sélectionner un filtre'
|
add_filter('Choix unique', 'val1', type: :enum)
|
||||||
wait_until { all("#search-filter").size == 1 }
|
|
||||||
find('#search-filter', wait: 5).click
|
|
||||||
find('.fr-menu__item', text: "Choix unique", wait: 5).click
|
|
||||||
find("select#value", visible: false)
|
|
||||||
select 'val1', from: "Valeur"
|
|
||||||
click_button "Ajouter le filtre"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'with a vcr cached cassette' do
|
describe 'with a vcr cached cassette' do
|
||||||
|
@ -124,14 +104,7 @@ describe "procedure filters" do
|
||||||
departement_champ.reload
|
departement_champ.reload
|
||||||
champ_select_value = "#{departement_champ.external_id} – #{departement_champ.value}"
|
champ_select_value = "#{departement_champ.external_id} – #{departement_champ.value}"
|
||||||
|
|
||||||
click_on 'Sélectionner un filtre'
|
add_filter(departement_champ.libelle, champ_select_value, type: :enum)
|
||||||
wait_until { all("#search-filter").size == 1 }
|
|
||||||
find('#search-filter', wait: 5).click
|
|
||||||
find('.fr-menu__item', text: departement_champ.libelle, wait: 5).click
|
|
||||||
find("select#value", visible: true)
|
|
||||||
select champ_select_value, from: "Valeur"
|
|
||||||
click_button "Ajouter le filtre"
|
|
||||||
find("select#value", visible: false) # w8 for filter to be applied
|
|
||||||
expect(page).to have_link(new_unfollow_dossier.id.to_s)
|
expect(page).to have_link(new_unfollow_dossier.id.to_s)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -140,14 +113,7 @@ describe "procedure filters" do
|
||||||
region_champ.update!(value: 'Bretagne', external_id: '53')
|
region_champ.update!(value: 'Bretagne', external_id: '53')
|
||||||
region_champ.reload
|
region_champ.reload
|
||||||
|
|
||||||
click_on 'Sélectionner un filtre'
|
add_filter(region_champ.libelle, region_champ.value, type: :enum)
|
||||||
wait_until { all("#search-filter").size == 1 }
|
|
||||||
find('#search-filter', wait: 5).click
|
|
||||||
find('.fr-menu__item', text: region_champ.libelle, wait: 5).click
|
|
||||||
find("select#value", visible: true)
|
|
||||||
select region_champ.value, from: "Valeur"
|
|
||||||
click_button "Ajouter le filtre"
|
|
||||||
find("select#value", visible: false) # w8 for filter to be applied
|
|
||||||
expect(page).to have_link(new_unfollow_dossier.id.to_s)
|
expect(page).to have_link(new_unfollow_dossier.id.to_s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -155,7 +121,7 @@ describe "procedure filters" do
|
||||||
scenario "should be able to add and remove two filters for the same field", js: true do
|
scenario "should be able to add and remove two filters for the same field", js: true do
|
||||||
add_filter(type_de_champ.libelle, champ.value)
|
add_filter(type_de_champ.libelle, champ.value)
|
||||||
add_filter(type_de_champ.libelle, champ_2.value)
|
add_filter(type_de_champ.libelle, champ_2.value)
|
||||||
add_enum_filter('Groupe instructeur', procedure.groupe_instructeurs.first.label)
|
add_filter('Groupe instructeur', procedure.groupe_instructeurs.first.label, type: :enum)
|
||||||
|
|
||||||
within ".dossiers-table" do
|
within ".dossiers-table" do
|
||||||
expect(page).to have_link(new_unfollow_dossier.id.to_s, exact: true)
|
expect(page).to have_link(new_unfollow_dossier.id.to_s, exact: true)
|
||||||
|
@ -185,40 +151,40 @@ describe "procedure filters" do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def add_filter(column_name, filter_value, type: :text)
|
||||||
|
click_on 'Sélectionner un filtre'
|
||||||
|
wait_until { all("#search-filter").size == 1 }
|
||||||
|
find('#search-filter + button', wait: 5).click
|
||||||
|
find('.fr-menu__item', text: column_name, wait: 5).click
|
||||||
|
case type
|
||||||
|
when :text
|
||||||
|
fill_in "Valeur", with: filter_value
|
||||||
|
when :date
|
||||||
|
find("input#value[type=date]", visible: true)
|
||||||
|
fill_in "Valeur", with: filter_value
|
||||||
|
when :enum
|
||||||
|
find("select#value", visible: false)
|
||||||
|
select filter_value, from: "Valeur"
|
||||||
|
end
|
||||||
|
click_button "Ajouter le filtre"
|
||||||
|
expect(page).to have_no_css("#search-filter", visible: true)
|
||||||
|
end
|
||||||
|
|
||||||
def remove_filter(filter_value)
|
def remove_filter(filter_value)
|
||||||
click_link text: filter_value
|
click_link text: filter_value
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_filter(column_name, filter_value)
|
|
||||||
click_on 'Sélectionner un filtre'
|
|
||||||
wait_until { all("#search-filter").size == 1 }
|
|
||||||
find('#search-filter', wait: 5).click
|
|
||||||
find('.fr-menu__item', text: column_name, wait: 5).click
|
|
||||||
fill_in "Valeur", with: filter_value
|
|
||||||
click_button "Ajouter le filtre"
|
|
||||||
expect(page).to have_no_css("#search-filter", visible: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_enum_filter(column_name, filter_value)
|
|
||||||
click_on 'Sélectionner un filtre'
|
|
||||||
wait_until { all("#search-filter").size == 1 }
|
|
||||||
find('#search-filter', wait: 5).click
|
|
||||||
find('.fr-menu__item', text: column_name, wait: 5).click
|
|
||||||
select filter_value, from: "Valeur"
|
|
||||||
click_button "Ajouter le filtre"
|
|
||||||
expect(page).to have_no_css("#search-filter", visible: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_column(column_name)
|
def add_column(column_name)
|
||||||
click_on 'Personnaliser'
|
click_on 'Personnaliser'
|
||||||
select_combobox('Colonne à afficher', column_name, column_name, check: false)
|
select_combobox('Colonne à afficher', column_name)
|
||||||
click_button "Enregistrer"
|
click_button "Enregistrer"
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove_column(column_name)
|
def remove_column(column_name)
|
||||||
click_on 'Personnaliser'
|
click_on 'Personnaliser'
|
||||||
click_button column_name
|
within '.fr-tag-list' do
|
||||||
find("body").native.send_key("Escape")
|
find('.fr-tag', text: column_name).find('button').click
|
||||||
|
end
|
||||||
click_button "Enregistrer"
|
click_button "Enregistrer"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue