diff --git a/app/assets/stylesheets/01_common.scss b/app/assets/stylesheets/01_common.scss index 00c777a0c..47b94bc1d 100644 --- a/app/assets/stylesheets/01_common.scss +++ b/app/assets/stylesheets/01_common.scss @@ -28,3 +28,7 @@ body { .container { @extend %container; } + +react-fragment { + display: block; +} diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index ec8dd3c48..a9def4820 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -10,10 +10,6 @@ } } -.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] { - margin-bottom: 0; -} - .map-style-control { position: absolute; bottom: 4px; diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index 2b312fc36..1423c8086 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -32,29 +32,87 @@ trix-editor.fr-input { } .fr-ds-combobox { - .fr-menu { - width: 100%; - - .fr-menu__list { - width: 100%; - max-height: 300px; - } - } - .fr-autocomplete { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E"); } } +.fr-ds-combobox__multiple { + .fr-tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.3rem; + } +} + +.fr-ds-combobox__menu { + &[data-placement=top] { + --origin: translateY(8px); + } + + &[data-placement=bottom] { + --origin: translateY(-8px); + } + + &[data-placement=right] { + --origin: translateX(-8px); + } + + &[data-placement=left] { + --origin: translateX(8px); + } + + &[data-entering] { + animation: popover-slide 200ms; + } + + &.fr-menu { + width: var(--trigger-width); + top: unset; + + .fr-menu__list { + display: block; + width: unset; + max-height: 300px; + overflow: auto; + } + + .fr-menu__item { + &[data-selected] { + font-weight: bold; + } + + &[data-focused] { + font-weight: bold; + } + } + } +} + +@keyframes popover-slide { + from { + transform: var(--origin); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + @media (max-width: 62em) { - .fr-ds-combobox .fr-menu .fr-menu__list { - z-index: calc(var(--ground) + 1000); - background-color: var(--background-default-grey); - --idle: transparent; - --hover: var(--background-overlap-grey-hover); - --active: var(--background-overlap-grey-active); - filter: drop-shadow(var(--overlap-shadow)); - box-shadow: inset 0 1px 0 0 var(--border-open-blue-france); + .fr-ds-combobox__menu { + &.fr-menu .fr-menu__list { + z-index: calc(var(--ground) + 1000); + background-color: var(--background-default-grey); + --idle: transparent; + --hover: var(--background-overlap-grey-hover); + --active: var(--background-overlap-grey-active); + filter: drop-shadow(var(--overlap-shadow)); + box-shadow: inset 0 1px 0 0 var(--border-open-blue-france); + } } } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 7b0f484cf..fe0141c6c 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -356,41 +356,6 @@ margin-bottom: 0; } - [data-reach-combobox-input] { - &:not([class^='width-']) { - width: 100%; - min-width: 50%; - max-width: 100%; - } - - &:focus { - border-color: $blue-france-500; - } - } - - [data-reach-combobox-token-list] { - padding: $default-spacer; - display: flex; - flex-wrap: wrap; - align-items: center; - list-style: none; - } - - [data-reach-combobox-token] button { - border: solid 1px $border-grey; - border-radius: 4px; - padding: $default-spacer; - margin-right: $default-spacer; - cursor: pointer; - display: flex; - align-items: center; - } - - [data-reach-combobox-token] button:focus { - background-color: $black; - color: $white; - } - .editable-champ { &:not(.editable-champ-carte) .algolia-autocomplete { margin-bottom: 2 * $default-padding; @@ -524,91 +489,8 @@ } } -[data-react-component-value^="ComboMultiple"] { +.fr-ds-combobox__multiple { margin-bottom: $default-fields-spacer; - - [data-reach-combobox-input] { - flex-grow: 1; - background-image: image-url("icons/chevron-down"); - background-size: 14px; - background-repeat: no-repeat; - background-position: right 10px center; - border-radius: 4px; - border: solid 1px $border-grey; - padding: $default-padding; - margin: $default-spacer; - margin-top: 0; - width: 100%; - } - - ul { - list-style: none; - - li { - margin-right: $default-spacer; - display: inline-block; - } - } -} - -[data-reach-combobox-token-label] { - border: 1px solid #CCCCCC; - border-radius: 4px; - display: flex; - flex-wrap: wrap; -} - -[data-reach-combobox-option] { - font-size: 16px; - list-style-type: none; -} - -[data-reach-combobox-option][aria-selected="true"] { - background: $light-blue !important; - color: $white; -} - -[data-reach-combobox-separator] { - font-size: 16px; - color: $dark-grey; - background: $light-grey; - padding: $default-spacer; -} - -[data-reach-combobox-no-results] { - font-size: 16px; - color: $dark-grey; - background: $light-grey; - padding: $default-spacer; -} - -[data-reach-combobox-token] button { - cursor: pointer; - background-color: transparent; - background-image: none; - border: none; - line-height: 1; - padding: 0; - margin-right: 4px; - display: flex; - align-items: center !important; -} - -[data-reach-combobox-input] button:focus { - outline-color: $light-blue; -} - -[data-fr-theme="dark"] [data-reach-combobox-popover] { - border: none; - background: var(--background-action-low-blue-france); -} - -[data-fr-theme="dark"] [data-reach-combobox-option]:hover { - background: var(--background-action-low-blue-france-hover); -} - -[data-reach-combobox-popover] { - z-index: 20; } .fconnect-form { @@ -634,10 +516,6 @@ textarea::placeholder { .fr-menu__item { list-style-type: none; margin-bottom: $default-spacer; - - &[aria-selected] { - font-weight: bold; - } } } diff --git a/app/assets/stylesheets/manager.scss b/app/assets/stylesheets/manager.scss index 7480fddd7..e6fe59c3b 100644 --- a/app/assets/stylesheets/manager.scss +++ b/app/assets/stylesheets/manager.scss @@ -1,36 +1,5 @@ @import "constants"; -[data-reach-combobox-token-label] { - border: 1px solid #CCCCCC; - border-radius: 4px; - display: flex; - flex-wrap: wrap; -} - -.form [data-reach-combobox-token-list] { - padding: 8px; - display: flex; - align-items: center; - list-style: none; -} - -.form [data-reach-combobox-input]:not([class^='width-']) { - width: 100%; - min-width: 50%; - max-width: 100%; -} - -.form [data-reach-combobox-token] button { - border: solid 1px #CCCCCC; - background-color: transparent; - border-radius: 4px; - padding: 8px; - margin-right: 8px; - cursor: pointer; - display: flex; - align-items: center; -} - .hidden { display: none; } @@ -70,4 +39,79 @@ margin-bottom: 4px; } } + + .fr-ds-combobox { + .fr-autocomplete { + background-repeat: no-repeat; + background-position: right; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + + .fr-ds-combobox__multiple { + .fr-tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.3rem; + } + + .fr-tag { + font-size: small; + padding: 0.5rem; + display: flex; + align-items: center; + border: solid 1px #dcdcdc; + + button { + margin-left: 0.3rem; + } + } + } + + .fr-ds-combobox__menu { + &[data-placement=top] { + --origin: translateY(8px); + } + + &[data-placement=bottom] { + --origin: translateY(-8px); + } + + &[data-placement=right] { + --origin: translateX(-8px); + } + + &[data-placement=left] { + --origin: translateX(8px); + } + + &[data-entering] { + animation: popover-slide 200ms; + } + + &.fr-menu { + width: var(--trigger-width); + top: unset; + background-color: white; + border: solid 1px #dcdcdc; + + .fr-menu__list { + display: block; + width: unset; + max-height: 300px; + overflow: auto; + } + + .fr-menu__item { + &[data-selected] { + font-weight: bold; + } + + &[data-focused] { + font-weight: bold; + } + } + } + } } diff --git a/app/assets/stylesheets/personnes_impliquees.scss b/app/assets/stylesheets/personnes_impliquees.scss index d47aa2755..42f3d07f8 100644 --- a/app/assets/stylesheets/personnes_impliquees.scss +++ b/app/assets/stylesheets/personnes_impliquees.scss @@ -9,41 +9,7 @@ margin-left: 16px; } - [data-react-component-value^="ComboMultiple"] { + .fr-ds-combobox__multiple { margin-bottom: 0; - - [data-reach-combobox-token-list] { - padding: 0.5 * $default-padding; - display: flex; - } - - [data-reach-combobox-token] button { - border: solid 1px $border-grey; - margin-top: 0.5 * $default-padding; - margin-bottom: 0.5 * $default-padding; - margin-right: 0.5 * $default-padding; - border-radius: 4px; - padding: 0.5 * $default-padding; - cursor: pointer; - list-style: none; - } - - [data-reach-combobox-token] button:focus { - background-color: $black; - color: $white; - } - - - [data-reach-combobox-input] { - outline: none; - border: none; - flex-grow: 1; - margin: 0.25rem; - } - - [data-reach-combobox-input]:focus { - outline: solid; - outline-color: $light-blue; - } } } diff --git a/app/assets/stylesheets/procedure_show.scss b/app/assets/stylesheets/procedure_show.scss index 5100162cb..bc3ec8730 100644 --- a/app/assets/stylesheets/procedure_show.scss +++ b/app/assets/stylesheets/procedure_show.scss @@ -45,45 +45,8 @@ display: inline-block; } - [data-react-component-value^="ComboMultiple"] { + .fr-ds-combobox__multiple { margin-bottom: $default-fields-spacer; - - [data-reach-combobox-token-list] { - padding: 0.25 * $default-padding; - display: inline-block; - width: 100%; - } - - [data-reach-combobox-token] button { - border: solid 1px $border-grey; - margin: 0.25 * $default-padding; - border-radius: 2px; - padding: 0.25 * $default-padding; - cursor: pointer; - list-style: none; - display: flex; - align-items: center; - } - - [data-reach-combobox-token] button:focus { - background-color: $black; - color: $white; - } - - - [data-reach-combobox-input] { - outline: none; - flex-grow: 1; - margin: $default-spacer; - padding: $default-spacer; - border-radius: 4px; - border: solid 1px $border-grey; - margin-top: 0; - } - - [data-reach-combobox-input]:focus { - border-color: $blue-france-500; - } } // fix/dsfr diff --git a/app/components/attachment/edit_component.rb b/app/components/attachment/edit_component.rb index e096591ef..117ea2a8c 100644 --- a/app/components/attachment/edit_component.rb +++ b/app/components/attachment/edit_component.rb @@ -10,7 +10,7 @@ class Attachment::EditComponent < ApplicationComponent EXTENSIONS_ORDER = ['jpeg', 'png', 'pdf', 'zip'].freeze - def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], **kwargs) + def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], max: nil, **kwargs) @champ = champ @attached_file = attached_file @direct_upload = direct_upload @@ -24,6 +24,7 @@ class Attachment::EditComponent < ApplicationComponent @attachments = attachments.presence || (kwargs.key?(:attachment) ? [kwargs.delete(:attachment)] : []) @attachments << attached_file.attachment if attached_file.respond_to?(:attachment) && @attachments.empty? @attachments.compact! + @max = max # Utilisation du premier attachement comme référence pour la rétrocompatibilité @attachment = @attachments.first @@ -54,7 +55,7 @@ class Attachment::EditComponent < ApplicationComponent end def destroy_attachment_path - attachment_path(champ_id: champ&.public_id) + attachment_path(dossier_id: champ&.dossier_id, stable_id: champ&.stable_id, row_id: champ&.row_id) end def attachment_input_class @@ -63,6 +64,7 @@ class Attachment::EditComponent < ApplicationComponent def file_field_options track_issue_with_missing_validators if missing_validators? + options = { class: class_names("fr-upload attachment-input": true, "#{attachment_input_class}": true, "hidden": persisted?), direct_upload: @direct_upload, @@ -76,6 +78,7 @@ class Attachment::EditComponent < ApplicationComponent options.merge!(has_content_type_validator? ? { accept: accept_content_type } : {}) options[:multiple] = true if as_multiple? + options[:disabled] = true if @max && @index >= @max options end diff --git a/app/components/attachment/multiple_component.rb b/app/components/attachment/multiple_component.rb index 80d2fd941..f994ed1cd 100644 --- a/app/components/attachment/multiple_component.rb +++ b/app/components/attachment/multiple_component.rb @@ -30,10 +30,6 @@ class Attachment::MultipleComponent < ApplicationComponent @attachments.each_with_index(&block) end - def can_attach_next? - @attachments.count < @max - end - def empty_component_id champ.present? ? "attachment-multiple-empty-#{champ.public_id}" : "attachment-multiple-empty-generic" end diff --git a/app/components/attachment/multiple_component/multiple_component.html.haml b/app/components/attachment/multiple_component/multiple_component.html.haml index 74abdf23c..40b94577f 100644 --- a/app/components/attachment/multiple_component/multiple_component.html.haml +++ b/app/components/attachment/multiple_component/multiple_component.html.haml @@ -7,8 +7,8 @@ %li{ id: dom_id(attachment) } = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, view_as:, user_can_destroy:, form_object_name:) - %div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?), data: { turbo_force: :server } } - = render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:) + %div{ id: empty_component_id, data: { turbo_force: :server } } + = render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:, max: @max) // single poll and refresh message for all attachments = render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:, context: poll_context) diff --git a/app/components/dossiers/instructeur_filter_component.rb b/app/components/dossiers/instructeur_filter_component.rb index 5f55441b1..7e841f5c4 100644 --- a/app/components/dossiers/instructeur_filter_component.rb +++ b/app/components/dossiers/instructeur_filter_component.rb @@ -8,10 +8,6 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent attr_reader :procedure, :procedure_presentation, :statut, :field_id - def filterable_fields_for_select - procedure_presentation.filterable_fields_options - end - def field_type return :text if field_id.nil? procedure_presentation.field_type(field_id) @@ -20,4 +16,16 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent def options_for_select_of_field procedure_presentation.field_enum(field_id) end + + def filter_react_props + { + selected_key: @field_id || '', + items: procedure_presentation.filterable_fields_options, + name: :field, + id: 'search-filter', + 'aria-describedby': 'instructeur-filter-combo-label', + form: 'filter-component', + data: { no_autosubmit: 'input blur', no_autosubmit_on_empty: 'true', autosubmit_target: 'input' } + } + end end diff --git a/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.html.haml b/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.html.haml index 8afad1b45..f98e93700 100644 --- a/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.html.haml +++ b/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.html.haml @@ -1,11 +1,8 @@ = form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do .fr-select-group = label_tag :field, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter' - = render Dsfr::ComboboxComponent.new form: nil, - options: filterable_fields_for_select, - selected: field_id, - input_html_options: { name: :field, id: 'search-filter', class: 'fr-select', describedby: 'instructeur-filter-combo-label', allows_custom_value: false, form_id: 'filter-component' }, - hidden_html_options: { data: { no_autosubmit: ['input', 'blur'].join(' '), no_autosubmit_on_empty: "true", autosubmit_target: 'input' } } + %react-fragment + = render ReactComponent.new "ComboBox/SingleComboBox", **filter_react_props %input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } } diff --git a/app/components/dsfr/alert_component.rb b/app/components/dsfr/alert_component.rb index 7323f639b..dd18f4a14 100644 --- a/app/components/dsfr/alert_component.rb +++ b/app/components/dsfr/alert_component.rb @@ -2,6 +2,17 @@ class Dsfr::AlertComponent < ApplicationComponent renders_one :body + attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level + + def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3') + @state = state + @title = title + @size = size + @block = block + @extra_class_names = extra_class_names + @heading_level = heading_level + end + def prefix_for_state case state when :error then "Erreur : " @@ -19,19 +30,4 @@ class Dsfr::AlertComponent < ApplicationComponent extra_class_names => true ) end - - private - - def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3') - @state = state - @title = title - @size = size - @block = block - @extra_class_names = extra_class_names - @heading_level = heading_level - end - - attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level - - private end diff --git a/app/components/dsfr/combobox_component/combobox_component.en.yml b/app/components/dsfr/combobox_component/combobox_component.en.yml deleted file mode 100644 index e24b49f92..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.en.yml +++ /dev/null @@ -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" diff --git a/app/components/dsfr/combobox_component/combobox_component.fr.yml b/app/components/dsfr/combobox_component/combobox_component.fr.yml deleted file mode 100644 index dc76ad006..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.fr.yml +++ /dev/null @@ -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é" diff --git a/app/components/dsfr/combobox_component/combobox_component.html.haml b/app/components/dsfr/combobox_component/combobox_component.html.haml deleted file mode 100644 index 47dc64b3b..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.html.haml +++ /dev/null @@ -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 diff --git a/app/components/dsfr/input_errorable.rb b/app/components/dsfr/input_errorable.rb index efe7de775..9ae359fd8 100644 --- a/app/components/dsfr/input_errorable.rb +++ b/app/components/dsfr/input_errorable.rb @@ -73,22 +73,26 @@ module Dsfr } end - def input_opts(other_opts = {}) + def react_input_opts(other_opts = {}) + input_opts(other_opts, true) + end + + def input_opts(other_opts = {}, react = false) @opts = @opts.deep_merge!(other_opts) - @opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class]) + @opts[react ? :class_name : :class] = class_names(map_array_to_hash_with_true(@opts[:class]) .merge({ 'fr-password__input': password?, - 'fr-input': true, + 'fr-input': !react, 'fr-mb-0': true }.merge(input_error_class_names))) if errors_on_attribute? - @opts.deep_merge!(aria: { describedby: describedby_id }) + @opts.deep_merge!('aria-describedby': describedby_id) elsif hintable? - @opts.deep_merge!(aria: { describedby: hint_id }) + @opts.deep_merge!('aria-describedby': hint_id) end if @required - @opts[:required] = true + @opts[react ? :is_required : :required] = true end if email? diff --git a/app/components/editable_champ/address_component.rb b/app/components/editable_champ/address_component.rb index 0bcefb5e0..f0c7a8d75 100644 --- a/app/components/editable_champ/address_component.rb +++ b/app/components/editable_champ/address_component.rb @@ -2,4 +2,15 @@ class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponen def dsfr_input_classname 'fr-select' end + + def react_props + react_input_opts(id: @champ.input_id, + class: 'fr-mt-1w', + name: @form.field_name(:value), + selected_key: @champ.value, + items: @champ.selected_items, + loader: data_sources_data_source_adresse_path, + minimum_input_length: 2, + allows_custom_value: true) + end end diff --git a/app/components/editable_champ/address_component/address_component.html.haml b/app/components/editable_champ/address_component/address_component.html.haml index e1029f05a..6df764db4 100644 --- a/app/components/editable_champ/address_component/address_component.html.haml +++ b/app/components/editable_champ/address_component/address_component.html.haml @@ -1,3 +1,3 @@ -= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_adresse_path, selected: @champ.value, allows_custom_value: true, input_html_options: { name: :value, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do - = @form.hidden_field :external_id, data: { value_slot: 'value' } - = @form.hidden_field :feature, data: { value_slot: 'data' } +%react-fragment + = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do + = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: @form.field_name(:feature) diff --git a/app/components/editable_champ/annuaire_education_component.rb b/app/components/editable_champ/annuaire_education_component.rb index 847b6cc21..fc38867bb 100644 --- a/app/components/editable_champ/annuaire_education_component.rb +++ b/app/components/editable_champ/annuaire_education_component.rb @@ -1,12 +1,15 @@ -class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent +class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname - 'fr-input' + 'fr-select' end - def react_input_opts - opts = input_opts(id: @champ.input_id, required: @champ.required?, aria: { describedby: @champ.describedby_id }) - opts[:className] = "#{opts.delete(:class)} fr-mt-1w" - - opts + def react_props + react_input_opts(id: @champ.input_id, + class: "fr-mt-1w", + name: @form.field_name(:external_id), + selected_key: @champ.external_id, + items: @champ.selected_items, + loader: data_sources_data_source_education_path, + minimum_input_length: 3) end end diff --git a/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml b/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml index 00ce7bbba..a3a489e4d 100644 --- a/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml +++ b/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml @@ -1,7 +1,3 @@ -- render_parent - -= @form.hidden_field :value -= @form.hidden_field :external_id -= react_component("ComboAnnuaireEducationSearch", - **react_input_opts, - **react_combo_props) +%react-fragment + = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do + = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :label, name: @form.field_name(:value) diff --git a/app/components/editable_champ/carte_component.rb b/app/components/editable_champ/carte_component.rb index 189fcb76a..b7ecb9a8c 100644 --- a/app/components/editable_champ/carte_component.rb +++ b/app/components/editable_champ/carte_component.rb @@ -4,10 +4,14 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent :fieldset end - def initialize(**args) - super(**args) - - @autocomplete_component = EditableChamp::ComboSearchComponent.new(**args) + def react_props + { + feature_collection: @champ.to_feature_collection, + champ_id: @champ.input_id, + url: update_path, + adresse_source: data_sources_data_source_adresse_path, + options: @champ.render_options + } end def update_path diff --git a/app/components/editable_champ/carte_component/carte_component.html.haml b/app/components/editable_champ/carte_component/carte_component.html.haml index db14600f9..052e979a9 100644 --- a/app/components/editable_champ/carte_component/carte_component.html.haml +++ b/app/components/editable_champ/carte_component/carte_component.html.haml @@ -1,14 +1,6 @@ .fr-fieldset__element - = render @autocomplete_component - - = react_component("MapEditor", - { featureCollection: @champ.to_feature_collection, - champId: @champ.input_id, - url: update_path, - options: @champ.render_options, - autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id, - autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions") }, - {class: 'width-100'}) + %react-fragment.width-100 + = render ReactComponent.new "MapEditor", **react_props .geo-areas{ id: dom_id(@champ, :geo_areas) } = render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true) diff --git a/app/components/editable_champ/combo_search_component.rb b/app/components/editable_champ/combo_search_component.rb deleted file mode 100644 index bbad7a600..000000000 --- a/app/components/editable_champ/combo_search_component.rb +++ /dev/null @@ -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 diff --git a/app/components/editable_champ/combo_search_component/combo_search_component.html.haml b/app/components/editable_champ/combo_search_component/combo_search_component.html.haml deleted file mode 100644 index 9b2e14a56..000000000 --- a/app/components/editable_champ/combo_search_component/combo_search_component.html.haml +++ /dev/null @@ -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) diff --git a/app/components/editable_champ/communes_component.rb b/app/components/editable_champ/communes_component.rb index 80fbb70e9..3728cc30a 100644 --- a/app/components/editable_champ/communes_component.rb +++ b/app/components/editable_champ/communes_component.rb @@ -4,4 +4,15 @@ class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseCompone def dsfr_input_classname 'fr-select' end + + def react_props + react_input_opts(id: @champ.input_id, + class: 'fr-mt-1w', + name: @form.field_name(:code), + selected_key: @champ.selected, + items: @champ.selected_items, + loader: data_sources_data_source_commune_path(with_combined_code: true), + limit: 20, + minimum_input_length: 2) + end end diff --git a/app/components/editable_champ/communes_component/communes_component.html.haml b/app/components/editable_champ/communes_component/communes_component.html.haml index 709c87d93..5c2f652de 100644 --- a/app/components/editable_champ/communes_component/communes_component.html.haml +++ b/app/components/editable_champ/communes_component/communes_component.html.haml @@ -1,2 +1,2 @@ -= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_commune_path, selected: [@champ.to_s, @champ.selected], limit: 20, input_html_options: { name: :external_id, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do - = @form.hidden_field :code_postal, data: { value_slot: 'data:string' } +%react-fragment + = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props diff --git a/app/components/editable_champ/drop_down_list_component.rb b/app/components/editable_champ/drop_down_list_component.rb index 0f92a95bc..04f901550 100644 --- a/app/components/editable_champ/drop_down_list_component.rb +++ b/app/components/editable_champ/drop_down_list_component.rb @@ -23,4 +23,13 @@ class EditableChamp::DropDownListComponent < EditableChamp::EditableChampBaseCom max_length = 100 @champ.enabled_non_empty_options.any? { _1.size > max_length } end + + def react_props + react_input_opts(id: @champ.input_id, + class: 'fr-mt-1w', + name: @form.field_name(:value), + selected_key: @champ.selected, + items: @champ.enabled_non_empty_options(other: true).map { _1.is_a?(Array) ? _1 : [_1, _1] }, + empty_filter_key: @champ.drop_down_other? ? Champs::DropDownListChamp::OTHER : nil) + end end diff --git a/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml b/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml index c2268eba0..3ce73c3ee 100644 --- a/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml +++ b/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml @@ -18,7 +18,8 @@ %label.fr-label{ for: dom_id(@champ, "radio_option_other") } = t('shared.champs.drop_down_list.other') - elsif @champ.render_as_combobox? - = render Dsfr::ComboboxComponent.new form: @form, options: @champ.enabled_non_empty_options(other: true), selected: @champ.selected, input_html_options: { name: :value, id: @champ.input_id, class: select_class_names, describedby: @champ.describedby_id } + %react-fragment + = render ReactComponent.new "ComboBox/SingleComboBox", **react_props - else = @form.select :value, @champ.enabled_non_empty_options(other: true), diff --git a/app/components/procedure/chorus_form_component.rb b/app/components/procedure/chorus_form_component.rb index 0602a9ccd..19298b775 100644 --- a/app/components/procedure/chorus_form_component.rb +++ b/app/components/procedure/chorus_form_component.rb @@ -14,6 +14,20 @@ class Procedure::ChorusFormComponent < ApplicationComponent } end + def selected_key(attribute_name) + items(attribute_name).first&.dig(:value) + end + + def items(attribute_name) + label = format_displayed_value(attribute_name) + data = format_hidden_value(attribute_name) + if label.present? + [{ label:, value: label, data: }] + else + [] + end + end + def format_displayed_value(attribute_name) case attribute_name when :centre_de_cout @@ -30,13 +44,23 @@ class Procedure::ChorusFormComponent < ApplicationComponent def format_hidden_value(attribute_name) case attribute_name when :centre_de_cout - @chorus_configuration.centre_de_cout.to_json + @chorus_configuration.centre_de_cout when :domaine_fonctionnel - @chorus_configuration.domaine_fonctionnel.to_json + @chorus_configuration.domaine_fonctionnel when :referentiel_de_programmation - @chorus_configuration.referentiel_de_programmation.to_json + @chorus_configuration.referentiel_de_programmation else raise 'unknown attribute_name' end end + + def react_props(name, chorus_configuration_attribute, datasource_endpoint) + { + name:, + selected_key: selected_key(chorus_configuration_attribute), + items: items(chorus_configuration_attribute), + loader: datasource_endpoint, + id: chorus_configuration_attribute + } + end end diff --git a/app/components/procedure/chorus_form_component/chorus_form_component.html.haml b/app/components/procedure/chorus_form_component/chorus_form_component.html.haml index 3fe78a9d3..df2a7131e 100644 --- a/app/components/procedure/chorus_form_component/chorus_form_component.html.haml +++ b/app/components/procedure/chorus_form_component/chorus_form_component.html.haml @@ -1,12 +1,10 @@ -= form_for([procedure, @chorus_configuration],url: admin_procedure_chorus_path(procedure), method: :put) do |f| += form_for([procedure, @chorus_configuration], url: admin_procedure_chorus_path(procedure), method: :put) do |f| - map_attribute_to_autocomplete_endpoint.map do |chorus_configuration_attribute, datasource_endpoint| - label_id = "#{chorus_configuration_attribute}-label" .fr-select-group - = f.label chorus_configuration_attribute, class: 'fr-label', id: label_id - = render Dsfr::ComboboxComponent.new form: f, - url: datasource_endpoint, - selected: format_displayed_value(chorus_configuration_attribute), - input_html_options: { id: chorus_configuration_attribute, class: 'fr-select', describedby: label_id, name: :chorus_configuration_attribute } do - = f.hidden_field chorus_configuration_attribute, data: { value_slot: 'data' }, value: format_hidden_value(chorus_configuration_attribute) + = f.label chorus_configuration_attribute, class: 'fr-label', id: label_id, for: chorus_configuration_attribute + %react-fragment + = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props(f.field_name(:chorus_configuration_attribute), chorus_configuration_attribute, datasource_endpoint) do + = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: f.field_name(chorus_configuration_attribute) = f.submit "Enregister", class: 'fr-btn' diff --git a/app/components/procedure_draft_warning_component.rb b/app/components/procedure_draft_warning_component.rb new file mode 100644 index 000000000..10c409600 --- /dev/null +++ b/app/components/procedure_draft_warning_component.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ProcedureDraftWarningComponent < ApplicationComponent + attr_reader :revision + attr_reader :current_administrateur + attr_reader :extra_class_names + + def initialize(revision:, current_administrateur:, extra_class_names: nil) + @revision = revision + @current_administrateur = current_administrateur + @extra_class_names = extra_class_names + end + + def render? + revision.draft? + end + + def admin? + current_administrateur.present? && revision.procedure.administrateurs.include?(current_administrateur) + end +end diff --git a/app/components/procedure_draft_warning_component/procedure_draft_warning_component.en.yml b/app/components/procedure_draft_warning_component/procedure_draft_warning_component.en.yml new file mode 100644 index 000000000..6f8dc61bb --- /dev/null +++ b/app/components/procedure_draft_warning_component/procedure_draft_warning_component.en.yml @@ -0,0 +1,13 @@ +--- +en: + title: Procedure in testing + intro_procedure_brouillon_html: This procedure is currently in testing + intro_revision_draft_html: This page allows you to test a new version of the procedure + body_general_html: | + and this page is reserved for the administration in charge of its deployment. + If you start or submit a file, it may be deleted at any time without notice, even if it is accepted later. + body_user: | + If this link was shared with you, please contact the service in charge of this procedure + to obtain the public link for the procedure in order to submit your application. + body_admin_procedure_brouillon: Do not share this link with your users. When you publish the procedure, you will access the public link for the procedure to be shared. + body_admin_revision_draft: Do not share this link with your users, but rather the public link for the procedure displayed in your administrator dashboard. diff --git a/app/components/procedure_draft_warning_component/procedure_draft_warning_component.fr.yml b/app/components/procedure_draft_warning_component/procedure_draft_warning_component.fr.yml new file mode 100644 index 000000000..5b0bc7f0f --- /dev/null +++ b/app/components/procedure_draft_warning_component/procedure_draft_warning_component.fr.yml @@ -0,0 +1,13 @@ +--- +fr: + title: Démarche en test + intro_procedure_brouillon_html: Cette démarche est actuellement en test + intro_revision_draft_html: Cette page permet de tester une nouvelle version de la démarche + body_general_html: | + et cette page est réservée à l’administration en charge de son déploiement. + Si vous commencez ou déposez un dossier, il pourra être supprimé à tout moment et sans préavis, même après avoir été accepté. + body_user: | + Si ce lien vous a été communiqué, contactez le service en charge de cette démarche + pour obtenir le lien public de la démarche afin de déposer votre dossier. + body_admin_procedure_brouillon: Ne communiquez pas ce lien à vos usagers. Lorsque vous publierez la démarche, vous accéderez au lien public de la démarche à communiquer. + body_admin_revision_draft: Ne communiquez pas ce lien à vos usagers, mais le lien public de la démarche affiché dans votre tableau de bord administrateur. diff --git a/app/components/procedure_draft_warning_component/procedure_draft_warning_component.html.haml b/app/components/procedure_draft_warning_component/procedure_draft_warning_component.html.haml new file mode 100644 index 000000000..929b1e378 --- /dev/null +++ b/app/components/procedure_draft_warning_component/procedure_draft_warning_component.html.haml @@ -0,0 +1,10 @@ += render Dsfr::AlertComponent.new(state: :warning, extra_class_names:, title: t(".title")) do |c| + - c.with_body do + %p + = revision.procedure.brouillon? ? t(".intro_procedure_brouillon_html") : t(".intro_revision_draft_html") + = t(".body_general_html") + + - if admin? + %p= revision.procedure.brouillon? ? t(".body_admin_procedure_brouillon") : t(".body_admin_revision_draft") + - else + %p= t(".body_user") diff --git a/app/components/react_component.rb b/app/components/react_component.rb new file mode 100644 index 000000000..0f643d5ec --- /dev/null +++ b/app/components/react_component.rb @@ -0,0 +1,14 @@ +class ReactComponent < ApplicationComponent + erb_template <<-ERB + <% if content? %> + props="<%= @props.to_json %>"><%= content %> + <% else %> + props="<%= @props.to_json %>"> + <% end %> + ERB + + def initialize(name, **props) + @name = name + @props = props + end +end diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 874290617..98a3073e0 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -527,11 +527,10 @@ module Administrateurs :accuse_lecture, :api_entreprise_token, :duree_conservation_dossiers_dans_ds, - { zone_ids: [] }, :lien_dpo, :opendata, :procedure_expires_when_termine_enabled, - :tags + { zone_ids: [], tags: [] } ] editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple? @@ -544,9 +543,6 @@ module Administrateurs if permited_params[:auto_archive_on].present? permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day end - if permited_params[:tags].present? - permited_params[:tags] = JSON.parse(permited_params[:tags]) - end permited_params end diff --git a/app/controllers/agent_connect/agent_controller.rb b/app/controllers/agent_connect/agent_controller.rb index 6116b1f2b..c8ab572e2 100644 --- a/app/controllers/agent_connect/agent_controller.rb +++ b/app/controllers/agent_connect/agent_controller.rb @@ -12,8 +12,8 @@ class AgentConnect::AgentController < ApplicationController def login uri, state, nonce = AgentConnectService.authorization_uri - cookies.encrypted[STATE_COOKIE_NAME] = state - cookies.encrypted[NONCE_COOKIE_NAME] = nonce + cookies.encrypted[STATE_COOKIE_NAME] = { value: state, secure: Rails.env.production?, httponly: true } + cookies.encrypted[NONCE_COOKIE_NAME] = { value: nonce, secure: Rails.env.production?, httponly: true } redirect_to uri, allow_other_host: true end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bc172fe47..659b44a31 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -117,7 +117,7 @@ class ApplicationController < ActionController::Base def set_locale(locale) if locale && locale.to_sym.in?(I18n.available_locales) - cookies[:locale] = locale + cookies[:locale] = { value: locale, secure: Rails.env.production?, httponly: true } if user_signed_in? current_user.update(locale: locale) end diff --git a/app/controllers/application_controller/long_lived_authenticity_token.rb b/app/controllers/application_controller/long_lived_authenticity_token.rb index cb10c52bd..54eb16f31 100644 --- a/app/controllers/application_controller/long_lived_authenticity_token.rb +++ b/app/controllers/application_controller/long_lived_authenticity_token.rb @@ -24,7 +24,8 @@ module ApplicationController::LongLivedAuthenticityToken cookies.signed[COOKIE_NAME] = { value: csrf_token, expires: 1.year.from_now, - httponly: true + httponly: true, + secure: Rails.env.production? } session[:_csrf_token] = csrf_token diff --git a/app/controllers/attachments_controller.rb b/app/controllers/attachments_controller.rb index 410c64212..721ec2cc2 100644 --- a/app/controllers/attachments_controller.rb +++ b/app/controllers/attachments_controller.rb @@ -21,11 +21,18 @@ class AttachmentsController < ApplicationController @attachment.purge_later flash.notice = 'La pièce jointe a bien été supprimée.' - @champ_id = params[:champ_id] + @champ = find_champ if params[:dossier_id] respond_to do |format| format.turbo_stream format.html { redirect_back(fallback_location: root_url) } end end + + private + + def find_champ + dossier = policy_scope(Dossier).includes(:champs).find(params[:dossier_id]) + dossier.champs.find_by(stable_id: params[:stable_id], row_id: params[:row_id]) + end end diff --git a/app/controllers/concerns/create_avis_concern.rb b/app/controllers/concerns/create_avis_concern.rb index f7b821a1f..ad29b06c0 100644 --- a/app/controllers/concerns/create_avis_concern.rb +++ b/app/controllers/concerns/create_avis_concern.rb @@ -4,7 +4,7 @@ module CreateAvisConcern private def create_avis_from_params(dossier, instructeur_or_expert, confidentiel = false) - if create_avis_params[:emails].empty? + if create_avis_params[:emails].blank? avis = Avis.new(create_avis_params) errors = avis.errors errors.add(:emails, :blank) @@ -19,8 +19,8 @@ module CreateAvisConcern # the :emails parameter is a 1-element array. # Hence the call to first # https://github.com/rails/rails/issues/17225 - expert_emails = create_avis_params[:emails].presence || [].to_json - expert_emails = JSON.parse(expert_emails).map(&:strip).map(&:downcase) + expert_emails = create_avis_params[:emails].presence || [] + expert_emails = expert_emails.map(&:strip).map(&:downcase) allowed_dossiers = [dossier] if create_avis_params[:invite_linked_dossiers].present? @@ -84,6 +84,6 @@ module CreateAvisConcern end def create_avis_params - params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :emails, :question_label) + params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :question_label, emails: []) end end diff --git a/app/controllers/data_sources/commune_controller.rb b/app/controllers/data_sources/commune_controller.rb index 825d3f7c4..49884399e 100644 --- a/app/controllers/data_sources/commune_controller.rb +++ b/app/controllers/data_sources/commune_controller.rb @@ -61,11 +61,18 @@ class DataSources::CommuneController < ApplicationController else [item] end.map do |item| - { - label: "#{item[:name]} (#{item[:postal_code]})", - value: item[:code], - data: item[:postal_code] - } + if params[:with_combined_code].present? + { + label: "#{item[:name]} (#{item[:postal_code]})", + value: "#{item[:code]}-#{item[:postal_code]}" + } + else + { + label: "#{item[:name]} (#{item[:postal_code]})", + value: item[:code], + data: item[:postal_code] + } + end end end end diff --git a/app/controllers/data_sources/education_controller.rb b/app/controllers/data_sources/education_controller.rb new file mode 100644 index 000000000..99722ef33 --- /dev/null +++ b/app/controllers/data_sources/education_controller.rb @@ -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 diff --git a/app/controllers/gestionnaires/groupe_gestionnaire_administrateurs_controller.rb b/app/controllers/gestionnaires/groupe_gestionnaire_administrateurs_controller.rb index da05c8a4b..8b3843167 100644 --- a/app/controllers/gestionnaires/groupe_gestionnaire_administrateurs_controller.rb +++ b/app/controllers/gestionnaires/groupe_gestionnaire_administrateurs_controller.rb @@ -6,8 +6,8 @@ module Gestionnaires end def create - emails = [params.require(:administrateur)[:email]].to_json - emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } + emails = [params.require(:administrateur)[:email]].compact + emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } administrateurs_to_add, valid_emails, invalid_emails = Administrateur.find_all_by_identifier_with_emails(emails:) not_found_emails = valid_emails - administrateurs_to_add.map(&:email) diff --git a/app/controllers/gestionnaires/groupe_gestionnaire_gestionnaires_controller.rb b/app/controllers/gestionnaires/groupe_gestionnaire_gestionnaires_controller.rb index bf7ba9fb9..c098d2d80 100644 --- a/app/controllers/gestionnaires/groupe_gestionnaire_gestionnaires_controller.rb +++ b/app/controllers/gestionnaires/groupe_gestionnaire_gestionnaires_controller.rb @@ -6,8 +6,8 @@ module Gestionnaires end def create - emails = [params.require(:gestionnaire)[:email]].to_json - emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } + emails = [params.require(:gestionnaire)[:email]].compact + emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:) not_found_emails = valid_emails - gestionnaires_to_add.map(&:email) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 189477bff..1605c4de1 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -86,9 +86,9 @@ module Instructeurs end def send_to_instructeurs - recipients = params['recipients'].presence || [].to_json + recipients = params['recipients'].presence || [] # instructeurs are scoped by groupe_instructeur to avoid enumeration - recipients = dossier.groupe_instructeur.instructeurs.where(id: JSON.parse(recipients)) + recipients = dossier.groupe_instructeur.instructeurs.where(id: recipients) if recipients.present? recipients.each do |recipient| @@ -401,6 +401,7 @@ module Instructeurs :value, :value_other, :external_id, + :code, :primary_value, :secondary_value, :numero_allocataire, diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 2b8702cf3..c61b19aa4 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -73,7 +73,6 @@ module Instructeurs @current_filters = current_filters @displayable_fields_for_select, @displayable_fields_selected = procedure_presentation.displayable_fields_for_select - @filterable_fields_for_select = procedure_presentation.filterable_fields_options @counts = current_instructeur .dossiers_count_summary(groupe_instructeur_ids) .symbolize_keys @@ -135,8 +134,8 @@ module Instructeurs end def update_displayed_fields - values = params['values'].presence || [].to_json - procedure_presentation.update_displayed_fields(JSON.parse(values)) + values = params['values'].presence || [] + procedure_presentation.update_displayed_fields(values) redirect_back(fallback_location: instructeur_procedure_url(procedure)) end @@ -248,7 +247,9 @@ module Instructeurs @export_templates = current_instructeur.export_templates_for(@procedure).includes(:groupe_instructeur) cookies.encrypted[cookies_export_key] = { value: DateTime.current, - expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT + expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT, + httponly: true, + secure: Rails.env.production? } respond_to do |format| diff --git a/app/controllers/manager/groupe_gestionnaires_controller.rb b/app/controllers/manager/groupe_gestionnaires_controller.rb index 66bdc82db..f009ae072 100644 --- a/app/controllers/manager/groupe_gestionnaires_controller.rb +++ b/app/controllers/manager/groupe_gestionnaires_controller.rb @@ -2,8 +2,8 @@ module Manager class GroupeGestionnairesController < Manager::ApplicationController def add_gestionnaire groupe_gestionnaire = GroupeGestionnaire.find(params[:id]) - emails = [params['emails'].presence || ''].to_json - emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } + emails = [params['emails']].compact + emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) } gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:) not_found_emails = valid_emails - gestionnaires_to_add.map(&:email) diff --git a/app/controllers/manager/procedures_controller.rb b/app/controllers/manager/procedures_controller.rb index 6fb14e6d2..92d5e321a 100644 --- a/app/controllers/manager/procedures_controller.rb +++ b/app/controllers/manager/procedures_controller.rb @@ -111,8 +111,7 @@ module Manager end def add_tags - tags_h = { tags: JSON.parse(tags_params[:tags]) } - if procedure.update(tags_h) + if procedure.update(tags: tags_params[:tags]) flash.notice = "Le modèle est mis à jour." else flash.alert = procedure.errors.full_messages.join(', ') @@ -181,7 +180,7 @@ module Manager end def tags_params - params.require(:procedure).permit(:tags) + params.require(:procedure).permit(tags: []) end def template_params diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 3e322e618..f22896053 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -494,6 +494,7 @@ module Users :value, :value_other, :external_id, + :code, :primary_value, :secondary_value, :numero_allocataire, diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f5f859032..856cc923e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -59,10 +59,6 @@ module ApplicationHelper 'alert' end - def react_component(name, props = {}, html = {}) - tag.div(**html.merge(data: { controller: 'react', react_component_value: name, react_props_value: props.to_json })) - end - def current_email current_user&.email || current_instructeur&.email || diff --git a/app/javascript/components/ComboAdresseSearch.tsx b/app/javascript/components/ComboAdresseSearch.tsx deleted file mode 100644 index f6a34a322..000000000 --- a/app/javascript/components/ComboAdresseSearch.tsx +++ /dev/null @@ -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; -type AdresseResult = RawResult['features'][0]; -type ComboAdresseSearchProps = Omit< - ComboSearchProps, - 'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope' ->; - -export default function ComboAdresseSearch({ - allowInputValues = true, - ...props -}: ComboAdresseSearchProps) { - return ( - - - {...props} - allowInputValues={allowInputValues} - scope="adresse" - minimumInputLength={2} - transformResult={({ properties: { label } }) => [label, label, label]} - transformResults={(_, result) => (result as RawResult).features} - debounceDelay={300} - /> - - ); -} diff --git a/app/javascript/components/ComboAnnuaireEducationSearch.tsx b/app/javascript/components/ComboAnnuaireEducationSearch.tsx deleted file mode 100644 index 23fc46ec4..000000000 --- a/app/javascript/components/ComboAnnuaireEducationSearch.tsx +++ /dev/null @@ -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 -) { - return ( - - [id, `${nom_etablissement}, ${nom_commune} (${id})`]} - /> - - ); -} diff --git a/app/javascript/components/ComboBox.tsx b/app/javascript/components/ComboBox.tsx new file mode 100644 index 000000000..d93b957e8 --- /dev/null +++ b/app/javascript/components/ComboBox.tsx @@ -0,0 +1,347 @@ +import type { ListBoxItemProps } from 'react-aria-components'; +import { + ComboBox as AriaComboBox, + ListBox, + ListBoxItem, + Popover, + Input, + Label, + Text, + Button, + TagGroup, + TagList, + Tag +} from 'react-aria-components'; +import { useMemo, useRef, createContext, useContext } from 'react'; +import type { RefObject } from 'react'; +import { findOrCreateContainerElement } from '@coldwired/react'; +import * as s from 'superstruct'; + +import { + useLabelledBy, + useDispatchChangeEvent, + useMultiList, + useSingleList, + useRemoteList, + useOnFormReset, + createLoader, + type ComboBoxProps +} from './react-aria/hooks'; +import { + type Item, + SingleComboBoxProps, + MultiComboBoxProps, + RemoteComboBoxProps +} from './react-aria/props'; + +const getPortal = () => findOrCreateContainerElement('rac-portal'); + +export function ComboBox({ + children, + label, + description, + className, + inputRef, + ...props +}: ComboBoxProps & { inputRef?: RefObject }) { + return ( + + {label ? : null} + {description ? ( + + {description} + + ) : null} +
+ + +
+ + {children} + +
+ ); +} + +export function ComboBoxItem(props: ListBoxItemProps) { + return ; +} + +export function SingleComboBox({ + children, + ...maybeProps +}: SingleComboBoxProps) { + const { + 'aria-labelledby': ariaLabelledby, + items: defaultItems, + selectedKey: defaultSelectedKey, + emptyFilterKey, + name, + formValue, + form, + data, + ...props + } = useMemo(() => s.create(maybeProps, SingleComboBoxProps), [maybeProps]); + + const labelledby = useLabelledBy(props.id, ariaLabelledby); + const { ref, dispatch } = useDispatchChangeEvent(); + + const { selectedItem, onReset, ...comboBoxProps } = useSingleList({ + defaultItems, + defaultSelectedKey, + emptyFilterKey, + onChange: dispatch + }); + + return ( + <> + + {(item) => {item.label}} + + {children || name ? ( + + + {name ? ( + + ) : null} + {children} + + + ) : null} + + ); +} + +export function MultiComboBox(maybeProps: MultiComboBoxProps) { + const { + 'aria-labelledby': ariaLabelledby, + items: defaultItems, + selectedKeys: defaultSelectedKeys, + name, + form, + formValue, + allowsCustomValue, + valueSeparator, + ...props + } = useMemo(() => s.create(maybeProps, MultiComboBoxProps), [maybeProps]); + + const labelledby = useLabelledBy(props.id, ariaLabelledby); + const { ref, dispatch } = useDispatchChangeEvent(); + const inputRef = useRef(null); + + const { + selectedItems, + hiddenInputValues, + onRemove, + onReset, + ...comboBoxProps + } = useMultiList({ + defaultItems, + defaultSelectedKeys, + onChange: dispatch, + formValue, + allowsCustomValue, + valueSeparator, + focusInput: () => { + inputRef.current?.focus(); + } + }); + const formResetRef = useOnFormReset(onReset); + + return ( +
+ {selectedItems.length > 0 ? ( + + + {selectedItems.map((item) => ( + + {item.label} + + + ))} + + + ) : null} + + {(item) => {item.label}} + + {name ? ( + + {hiddenInputValues.map((value, i) => ( + + ))} + + ) : null} +
+ ); +} + +export function RemoteComboBox({ + loader, + onChange, + children, + ...maybeProps +}: RemoteComboBoxProps) { + const { + 'aria-labelledby': ariaLabelledby, + items: defaultItems, + selectedKey: defaultSelectedKey, + allowsCustomValue, + minimumInputLength, + limit, + formValue, + name, + form, + data, + ...props + } = useMemo(() => s.create(maybeProps, RemoteComboBoxProps), [maybeProps]); + + const labelledby = useLabelledBy(props.id, ariaLabelledby); + const { ref, dispatch } = useDispatchChangeEvent(); + + const load = useMemo( + () => + typeof loader == 'string' + ? createLoader(loader, { minimumInputLength, limit }) + : loader, + [loader, minimumInputLength, limit] + ); + const { selectedItem, onReset, ...comboBoxProps } = useRemoteList({ + allowsCustomValue, + defaultItems, + defaultSelectedKey, + load, + onChange: (item) => { + onChange?.(item); + dispatch(); + } + }); + + return ( + <> + 0} + allowsCustomValue={allowsCustomValue} + aria-labelledby={labelledby} + {...comboBoxProps} + {...props} + > + {(item) => {item.label}} + + {children || name ? ( + + + {name ? ( + + ) : null} + {children} + + + ) : null} + + ); +} + +export function ComboBoxValueSlot({ + field, + name, + form, + onReset, + data +}: { + field: 'label' | 'value' | 'data'; + name: string; + form?: string; + onReset?: () => void; + data?: Record; +}) { + 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 ( + + ); +} + +const SelectedItemContext = createContext(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]; +} diff --git a/app/javascript/components/ComboMultiple.tsx b/app/javascript/components/ComboMultiple.tsx deleted file mode 100644 index 713e64a8c..000000000 --- a/app/javascript/components/ComboMultiple.tsx +++ /dev/null @@ -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(null); - const [term, setTerm] = useState(''); - const [selections, setSelections] = useState(selected); - const [newValues, setNewValues] = useState([]); - const internalId = useId(); - const inputId = id ?? internalId; - const removedLabelledby = `${inputId}-remove`; - const selectedLabelledby = `${inputId}-selected`; - - const optionsWithLabels = useMemo( - () => - 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 = (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 = (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 ( - - - - désélectionner - -
    - {selections.map((selection) => ( - - {optionLabelByValue(newValues, optionsWithLabels, selection)} - - ))} -
- -
- {results && (results.length > 0 || !acceptNewValues) && ( - - - {results.length === 0 && ( -
  • - Aucun résultat{' '} - -
  • - )} - {results.map(([label, value], index) => { - if (label.startsWith('--')) { - return ; - } - return ( - - {label} - - ); - })} -
    -
    - )} -
    - ); -} - -function ComboboxTokenLabel({ - onRemove, - children -}: { - onRemove: (value: string) => void; - children: ReactNode; -}) { - return ( - -
    {children}
    -
    - ); -} - -function ComboboxSeparator({ value }: { value: string }) { - return ( -
  • - {value.slice(2, -2)} -
  • - ); -} - -function ComboboxToken({ - value, - describedby, - children, - ...props -}: { - value: string; - describedby: string; - children: ReactNode; -}) { - const context = useContext(Context); - invariant(context, 'invalid context'); - const { onRemove } = context; - - return ( -
  • - -
  • - ); -} diff --git a/app/javascript/components/ComboSearch.tsx b/app/javascript/components/ComboSearch.tsx deleted file mode 100644 index 2115d6be6..000000000 --- a/app/javascript/components/ComboSearch.tsx +++ /dev/null @@ -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 = (term: string, results: unknown) => Result[]; -type TransformResult = ( - result: Result -) => [key: string, value: string, label?: string]; - -export type ComboSearchProps = { - onChange?: (value: string | null, result?: Result) => void; - value?: string; - scope: string; - scopeExtra?: string; - minimumInputLength: number; - transformResults?: TransformResults; - transformResult: TransformResult; - 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({ - onChange, - value: controlledValue, - scope, - scopeExtra, - minimumInputLength, - transformResult, - allowInputValues = false, - transformResults = (_, results) => results as Result[], - id, - describedby, - screenReaderInstructions, - announceTemplateId, - debounceDelay = 0, - ...props -}: ComboSearchProps) { - 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 - >({}); - 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 = ({ - 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( - [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>(); - const announceTemplate = document.querySelector( - `#${announceTemplateId}` - ); - invariant(announceTemplate, `Missing #${announceTemplateId}`); - - const announceFragment = useRef( - announceTemplate.content.cloneNode(true) as DocumentFragment - ).current; - - useEffect(() => { - if (isSuccess) { - const slot = announceFragment.querySelector( - 'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]' - ); - - if (!slot) { - return; - } - - const countSlot = - slot.querySelector('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 ( - - - {isSuccess && ( - - {results.length > 0 ? ( - - {results.map((result, index) => { - const label = getLabel(result); - const [key, value] = transformResult(result); - resultsMap.current[label] = { key, value, result }; - return ; - })} - - ) : ( - - Aucun résultat trouvé - - )} - - )} - {!describedby && ( - - {screenReaderInstructions} - - )} -
    - {announceLive} -
    -
    - ); -} - -export default ComboSearch; diff --git a/app/javascript/components/Layout.tsx b/app/javascript/components/Layout.tsx new file mode 100644 index 000000000..39a33aa9e --- /dev/null +++ b/app/javascript/components/Layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/javascript/components/MapEditor/components/AddressInput.tsx b/app/javascript/components/MapEditor/components/AddressInput.tsx index 0e2d2d2a5..0e7c22a22 100644 --- a/app/javascript/components/MapEditor/components/AddressInput.tsx +++ b/app/javascript/components/MapEditor/components/AddressInput.tsx @@ -1,33 +1,33 @@ -import React from 'react'; import { fire } from '@utils'; import type { FeatureCollection } from 'geojson'; -import ComboAdresseSearch from '../../ComboAdresseSearch'; -import { ComboSearchProps } from '~/components/ComboSearch'; +import { RemoteComboBox } from '../../ComboBox'; -export function AddressInput( - comboProps: Pick< - ComboSearchProps, - 'screenReaderInstructions' | 'announceTemplateId' - > & { featureCollection: FeatureCollection; champId: string } -) { +export function AddressInput({ + source, + featureCollection, + champId +}: { + source: string; + featureCollection: FeatureCollection; + champId: string; +}) { return ( -
    - { - fire(document, 'map:zoom', { - featureCollection: comboProps.featureCollection, - feature - }); +
    + { + if (item && item.data) { + fire(document, 'map:zoom', { + featureCollection, + feature: item.data + }); + } }} - {...comboProps} />
    ); diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx index 68be216e2..28122dba5 100644 --- a/app/javascript/components/MapEditor/components/ImportFileInput.tsx +++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; +import { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; import type { FeatureCollection } from 'geojson'; import invariant from 'tiny-invariant'; diff --git a/app/javascript/components/MapEditor/components/PointInput.tsx b/app/javascript/components/MapEditor/components/PointInput.tsx index 4f2fb4ead..5cd2e342f 100644 --- a/app/javascript/components/MapEditor/components/PointInput.tsx +++ b/app/javascript/components/MapEditor/components/PointInput.tsx @@ -1,4 +1,4 @@ -import React, { useState, useId } from 'react'; +import { useState, useId } from 'react'; import { fire } from '@utils'; import type { Feature, FeatureCollection } from 'geojson'; import CoordinateInput from 'react-coordinate-input'; diff --git a/app/javascript/components/MapEditor/index.tsx b/app/javascript/components/MapEditor/index.tsx index 0a918c981..5bea4796c 100644 --- a/app/javascript/components/MapEditor/index.tsx +++ b/app/javascript/components/MapEditor/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { CursorClickIcon } from '@heroicons/react/outline'; import 'maplibre-gl/dist/maplibre-gl.css'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; @@ -12,21 +12,18 @@ import { AddressInput } from './components/AddressInput'; import { PointInput } from './components/PointInput'; import { ImportFileInput } from './components/ImportFileInput'; import { FlashMessage } from '../shared/FlashMessage'; -import { ComboSearchProps } from '../ComboSearch'; export default function MapEditor({ featureCollection: initialFeatureCollection, url, + adresseSource, options, - autocompleteAnnounceTemplateId, - autocompleteScreenReaderInstructions, champId }: { featureCollection: FeatureCollection; url: string; + adresseSource: string; options: { layers: string[] }; - autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId']; - autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions']; champId: string; }) { const [cadastreEnabled, setCadastreEnabled] = useState(false); @@ -41,15 +38,10 @@ export default function MapEditor({ {error && } - diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx index add1dca03..807730cb3 100644 --- a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx +++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl'; import type { Feature, FeatureCollection, Point } from 'geojson'; diff --git a/app/javascript/components/MapReader/index.tsx b/app/javascript/components/MapReader/index.tsx index 1f9fb07d4..a8662f152 100644 --- a/app/javascript/components/MapReader/index.tsx +++ b/app/javascript/components/MapReader/index.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { FeatureCollection } from 'geojson'; diff --git a/app/javascript/components/react-aria/hooks.ts b/app/javascript/components/react-aria/hooks.ts new file mode 100644 index 000000000..e00bff289 --- /dev/null +++ b/app/javascript/components/react-aria/hooks.ts @@ -0,0 +1,486 @@ +import type { + ComboBoxProps as AriaComboBoxProps, + TagGroupProps +} from 'react-aria-components'; +import { useAsyncList, type AsyncListOptions } from 'react-stately'; +import { useMemo, useRef, useState, useEffect } from 'react'; +import type { Key } from 'react'; +import { matchSorter } from 'match-sorter'; +import { useDebounceCallback } from 'usehooks-ts'; +import { useEvent } from 'react-use-event-hook'; +import isEqual from 'react-fast-compare'; +import * as s from 'superstruct'; + +import { Item } from './props'; + +export type Loader = AsyncListOptions['load']; + +export interface ComboBoxProps + extends Omit, 'children'> { + children: React.ReactNode | ((item: Item) => React.ReactNode); + label?: string; + description?: string; +} + +const inputMap = new WeakMap(); +export function useDispatchChangeEvent() { + const ref = useRef(null); + + return { + ref, + dispatch: () => { + requestAnimationFrame(() => { + const input = ref.current?.querySelector('input'); + if (input) { + const value = input.value; + const prevValue = inputMap.get(input) || ''; + if (value != prevValue) { + inputMap.set(input, value); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + } + }); + } + }; +} + +export function useSingleList({ + defaultItems, + defaultSelectedKey, + emptyFilterKey, + onChange +}: { + defaultItems?: Item[]; + defaultSelectedKey?: string | null; + emptyFilterKey?: string | null; + onChange?: (item: Item | null) => void; +}) { + const [selectedKey, setSelectedKey] = useState(defaultSelectedKey); + const items = useMemo( + () => (defaultItems ? distinctBy(defaultItems, 'value') : []), + [defaultItems] + ); + const selectedItem = useMemo( + () => items.find((item) => item.value == selectedKey) ?? null, + [items, selectedKey] + ); + const [inputValue, setInputValue] = useState(() => selectedItem?.label ?? ''); + // show fallback item when input value is not matching any items + const fallbackItem = useMemo( + () => items.find((item) => item.value == emptyFilterKey), + [items, emptyFilterKey] + ); + const filteredItems = useMemo(() => { + if (inputValue == '') { + return items; + } + const filteredItems = matchSorter(items, inputValue, { keys: ['label'] }); + if (filteredItems.length == 0 && fallbackItem) { + return [fallbackItem]; + } else { + return filteredItems; + } + }, [items, inputValue, fallbackItem]); + + const initialSelectedKeyRef = useRef(defaultSelectedKey); + + const setSelection = useEvent((key?: string | null) => { + const inputValue = key + ? items.find((item) => item.value == key)?.label + : ''; + setSelectedKey(key); + setInputValue(inputValue ?? ''); + }); + const onSelectionChange = useEvent< + NonNullable + >((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>( + (value) => { + setInputValue(value); + if (value == '') { + onSelectionChange(null); + } + } + ); + const onReset = useEvent(() => { + setSelectedKey(null); + setInputValue(''); + }); + + // reset default selected key when props change + useEffect(() => { + if (initialSelectedKeyRef.current != defaultSelectedKey) { + initialSelectedKeyRef.current = defaultSelectedKey; + setSelection(defaultSelectedKey); + } + }, [defaultSelectedKey, setSelection]); + + return { + selectedItem, + selectedKey, + onSelectionChange, + inputValue, + onInputChange, + items: filteredItems, + onReset + }; +} + +export function useMultiList({ + defaultItems, + defaultSelectedKeys, + allowsCustomValue, + valueSeparator, + onChange, + focusInput, + formValue +}: { + defaultItems?: Item[]; + defaultSelectedKeys?: string[]; + allowsCustomValue?: boolean; + valueSeparator?: string; + onChange?: () => void; + focusInput?: () => void; + formValue?: 'text' | 'key'; +}) { + const valueSeparatorRegExp = useMemo( + () => (valueSeparator ? new RegExp(valueSeparator) : /\s|,|;/), + [valueSeparator] + ); + const [selectedKeys, setSelectedKeys] = useState( + () => new Set(defaultSelectedKeys ?? []) + ); + const [inputValue, setInputValue] = useState(''); + const items = useMemo( + () => (defaultItems ? distinctBy(defaultItems, 'value') : []), + [defaultItems] + ); + const itemsIndex = useMemo(() => { + const index = new Map(); + 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 + >((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>( + (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>( + (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(() => { + if (defaultItems) { + return ( + defaultItems.find((item) => item.value == defaultSelectedKey) ?? null + ); + } + return null; + }); + const [inputValue, setInputValue] = useState( + defaultSelectedItem?.label ?? '' + ); + const selectedItem = useMemo(() => { + if (defaultSelectedItem) { + return defaultSelectedItem; + } + if (allowsCustomValue && inputValue != '') { + return { label: inputValue, value: inputValue }; + } + return null; + }, [defaultSelectedItem, inputValue, allowsCustomValue]); + const list = useAsyncList({ getKey, load }); + const setFilterText = useEvent((filterText: string) => { + list.setFilterText(filterText); + }); + const debouncedSetFilterText = useDebounceCallback( + setFilterText, + debounce ?? 300 + ); + + const onSelectionChange = useEvent< + NonNullable + >((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>( + (value) => { + debouncedSetFilterText(value); + setInputValue(value); + if (value == '') { + onSelectionChange(null); + } else if (allowsCustomValue && selectedItem?.label != value) { + onChange?.(selectedItem); + } + } + ); + + const onReset = useEvent(() => { + setSelectedItem(null); + setInputValue(''); + }); + + // add to items list current selected item if it's not in the list + const items = + selectedItem && !list.getItem(selectedItem.value) + ? [selectedItem, ...list.items] + : list.items; + + return { + selectedItem, + selectedKey: selectedItem?.value ?? null, + onSelectionChange, + inputValue, + onInputChange, + items, + onReset + }; +} + +function getKey(item: Item) { + return item.value; +} + +export const createLoader: ( + source: string, + options?: { + minimumInputLength?: number; + limit?: number; + param?: string; + } +) => Loader = + (source, options) => + async ({ signal, filterText }) => { + const url = new URL(source, location.href); + const minimumInputLength = options?.minimumInputLength ?? 2; + const param = options?.param ?? 'q'; + const limit = options?.limit ?? 10; + + if (!filterText || filterText.length < minimumInputLength) { + return { items: [] }; + } + url.searchParams.set(param, filterText); + try { + const response = await fetch(url.toString(), { + headers: { accept: 'application/json' }, + signal + }); + if (response.ok) { + const json = await response.json(); + const [err, result] = s.validate(json, s.array(Item), { coerce: true }); + if (!err) { + const items = matchSorter(result, filterText, { + keys: ['label'] + }); + return { + items: limit ? items.slice(0, limit) : items + }; + } + } + return { items: [] }; + } catch { + return { items: [] }; + } + }; + +export function useLabelledBy(id?: string, ariaLabelledby?: string) { + return useMemo( + () => (ariaLabelledby ? ariaLabelledby : findLabelledbyId(id)), + [id, ariaLabelledby] + ); +} + +function findLabelledbyId(id?: string) { + if (!id) { + return; + } + const label = document.querySelector(`[for="${id}"]`); + if (!label?.id) { + return; + } + return label.id; +} + +export function useOnFormReset(onReset?: () => void) { + const ref = useRef(null); + const onResetListener = useEvent((event) => { + if (event.target == ref.current?.form) { + onReset?.(); + } + }); + useEffect(() => { + if (onReset) { + addEventListener('reset', onResetListener); + return () => { + removeEventListener('reset', onResetListener); + }; + } + }, [onReset, onResetListener]); + + return ref; +} + +function distinctBy(array: T[], key: keyof T): T[] { + const keys = array.map((item) => item[key]); + return array.filter((item, index) => keys.indexOf(item[key]) == index); +} diff --git a/app/javascript/components/react-aria/props.ts b/app/javascript/components/react-aria/props.ts new file mode 100644 index 000000000..4932551c5 --- /dev/null +++ b/app/javascript/components/react-aria/props.ts @@ -0,0 +1,82 @@ +import type { ReactNode } from 'react'; +import * as s from 'superstruct'; + +import type { Loader } from './hooks'; + +export const Item = s.object({ + label: s.string(), + value: s.string(), + data: s.any() +}); +export type Item = s.Infer; + +const ArrayOfTuples = s.coerce( + s.array(Item), + s.array(s.tuple([s.string(), s.union([s.string(), s.number()])])), + (items) => + items.map(([label, value]) => ({ + label, + value: String(value) + })) +); + +const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) => + items.map((label) => ({ label, value: label })) +); + +const ComboBoxPropsSchema = s.partial( + s.object({ + id: s.string(), + className: s.string(), + name: s.string(), + label: s.string(), + description: s.string(), + isRequired: s.boolean(), + 'aria-label': s.string(), + 'aria-labelledby': s.string(), + 'aria-describedby': s.string(), + items: s.union([s.array(Item), ArrayOfStrings, ArrayOfTuples]), + formValue: s.enums(['text', 'key']), + form: s.string(), + data: s.record(s.string(), s.string()) + }) +); +export const SingleComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKey: s.nullable(s.string()), + emptyFilterKey: s.nullable(s.string()) + }) + ) +); +export const MultiComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKeys: s.array(s.string()), + allowsCustomValue: s.boolean(), + valueSeparator: s.string() + }) + ) +); +export const RemoteComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKey: s.nullable(s.string()), + minimumInputLength: s.number(), + limit: s.number(), + allowsCustomValue: s.boolean() + }) + ) +); +export type SingleComboBoxProps = s.Infer & { + children?: ReactNode; +}; +export type MultiComboBoxProps = s.Infer; +export type RemoteComboBoxProps = s.Infer & { + children?: ReactNode; + loader: Loader | string; + onChange?: (item: Item | null) => void; +}; diff --git a/app/javascript/components/shared/FlashMessage.tsx b/app/javascript/components/shared/FlashMessage.tsx index 4b358df3c..964a58d56 100644 --- a/app/javascript/components/shared/FlashMessage.tsx +++ b/app/javascript/components/shared/FlashMessage.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { createPortal } from 'react-dom'; import invariant from 'tiny-invariant'; diff --git a/app/javascript/components/shared/hooks.ts b/app/javascript/components/shared/hooks.ts deleted file mode 100644 index 3574455d4..000000000 --- a/app/javascript/components/shared/hooks.ts +++ /dev/null @@ -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( - `${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]` - ); - } -} diff --git a/app/javascript/components/shared/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx index b2045b6d0..d160775f8 100644 --- a/app/javascript/components/shared/maplibre/MapLibre.tsx +++ b/app/javascript/components/shared/maplibre/MapLibre.tsx @@ -1,4 +1,4 @@ -import React, { +import { useState, useContext, useRef, diff --git a/app/javascript/components/shared/maplibre/StyleControl.tsx b/app/javascript/components/shared/maplibre/StyleControl.tsx index ce83b75c1..afd46345c 100644 --- a/app/javascript/components/shared/maplibre/StyleControl.tsx +++ b/app/javascript/components/shared/maplibre/StyleControl.tsx @@ -1,4 +1,4 @@ -import React, { useState, useId } from 'react'; +import { useState, useId } from 'react'; import { Popover, RadioGroup } from '@headlessui/react'; import { usePopper } from 'react-popper'; import { MapIcon } from '@heroicons/react/outline'; diff --git a/app/javascript/components/shared/queryClient.ts b/app/javascript/components/shared/queryClient.ts deleted file mode 100644 index 700dc595d..000000000 --- a/app/javascript/components/shared/queryClient.ts +++ /dev/null @@ -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 = 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 - } - } -}); diff --git a/app/javascript/controllers/combobox_controller.ts b/app/javascript/controllers/combobox_controller.ts deleted file mode 100644 index 36eddca2c..000000000 --- a/app/javascript/controllers/combobox_controller.ts +++ /dev/null @@ -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('input[type="text"]'); - const selectedValueInput = this.element.querySelector( - 'input[type="hidden"]' - ); - const valueSlots = this.element.querySelectorAll( - 'input[type="hidden"][data-value-slot]' - ); - const list = this.element.querySelector('[role=listbox]'); - const item = this.element.querySelector('template'); - const hint = - this.element.querySelector('[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, 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'; - } -} diff --git a/app/javascript/controllers/lazy/tiptap_controller.ts b/app/javascript/controllers/lazy/tiptap_controller.ts index e80d2e599..58336e926 100644 --- a/app/javascript/controllers/lazy/tiptap_controller.ts +++ b/app/javascript/controllers/lazy/tiptap_controller.ts @@ -1,6 +1,6 @@ -import { Editor, type JSONContent } from '@tiptap/core'; +import { Editor } from '@tiptap/core'; import { isButtonElement, isHTMLElement } from '@coldwired/utils'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { ApplicationController } from '../application_controller'; import { getAction } from '../../shared/tiptap/actions'; @@ -61,7 +61,7 @@ export class TiptapController extends ApplicationController { insertTag(event: MouseEvent) { if (this.#editor && isHTMLElement(event.target)) { - const tag = tagSchema.parse(event.target.dataset); + const tag = s.create(event.target.dataset, tagSchema); const editor = this.#editor .chain() .focus() @@ -77,12 +77,12 @@ export class TiptapController extends ApplicationController { private get content() { const value = this.inputTarget.value; if (value) { - return jsonContentSchema.parse(JSON.parse(value)); + return s.create(JSON.parse(value), jsonContentSchema); } } private get tags(): TagSchema[] { - return this.tagTargets.map((tag) => tagSchema.parse(tag.dataset)); + return this.tagTargets.map((tag) => s.create(tag.dataset, tagSchema)); } private get menuButtons() { @@ -92,13 +92,24 @@ export class TiptapController extends ApplicationController { } } -const jsonContentSchema: z.ZodType = z.object({ - type: z.string().optional(), - text: z.string().optional(), - attrs: z.record(z.any()).optional(), - marks: z - .object({ type: z.string(), attrs: z.record(z.any()).optional() }) - .array() - .optional(), - content: z.lazy(() => z.array(jsonContentSchema).optional()) +const Attrs = s.record(s.string(), s.any()); +const Marks = s.array( + s.type({ + type: s.string(), + attrs: s.optional(Attrs) + }) +); +type JSONContent = { + type?: string; + text?: string; + attrs?: s.Infer; + marks?: s.Infer; + content?: JSONContent[]; +}; +const jsonContentSchema: s.Describe = s.type({ + type: s.optional(s.string()), + text: s.optional(s.string()), + attrs: s.optional(Attrs), + marks: s.optional(Marks), + content: s.lazy(() => s.optional(s.array(jsonContentSchema))) }); diff --git a/app/javascript/controllers/menu_button_controller.ts b/app/javascript/controllers/menu_button_controller.ts index c0b407242..a5edf9b53 100644 --- a/app/javascript/controllers/menu_button_controller.ts +++ b/app/javascript/controllers/menu_button_controller.ts @@ -86,6 +86,7 @@ export class MenuButtonController extends ApplicationController { target.isConnected && !this.element.contains(target) && !target.closest('reach-portal') && + !target.closest('#rac-portal') && this.isOpen ); } diff --git a/app/javascript/controllers/react_controller.tsx b/app/javascript/controllers/react_controller.tsx deleted file mode 100644 index cdaff89de..000000000 --- a/app/javascript/controllers/react_controller.tsx +++ /dev/null @@ -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; -type Loader = () => Promise<{ default: FunctionComponent }>; -const componentsRegistry = new Map>(); -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: -//
    -// -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(, node); - } - - private getComponent(componentName: string): FunctionComponent | null { - return componentsRegistry.get(componentName) ?? null; - } -} - -const Spinner = () =>
    ; - -function LoadableComponent(loader: Loader): FunctionComponent { - const LazyComponent = lazy(loader); - const Component: FunctionComponent = (props: Props) => ( - }> - - - ); - return Component; -} diff --git a/app/javascript/controllers/turbo_controller.ts b/app/javascript/controllers/turbo_controller.ts index 6c8374bc5..58e298294 100644 --- a/app/javascript/controllers/turbo_controller.ts +++ b/app/javascript/controllers/turbo_controller.ts @@ -1,7 +1,9 @@ import { Actions } from '@coldwired/actions'; import { parseTurboStream } from '@coldwired/turbo-stream'; +import { createRoot, createReactPlugin, type Root } from '@coldwired/react'; import invariant from 'tiny-invariant'; import { session as TurboSession, type StreamElement } from '@hotwired/turbo'; +import type { ComponentType } from 'react'; import { ApplicationController } from './application_controller'; @@ -20,6 +22,7 @@ export class TurboController extends ApplicationController { #submitting = false; #actions?: Actions; + #root?: Root; // `actions` instrface exposes all available actions as methods and also `applyActions` method // wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also @@ -32,6 +35,17 @@ export class TurboController extends ApplicationController { } connect() { + this.#root = createRoot({ + layoutComponentName: 'Layout/Layout', + loader, + schema: { + fragmentTagName: 'react-fragment', + componentTagName: 'react-component', + slotTagName: 'react-slot', + loadingClassName: 'loading' + } + }); + const plugin = createReactPlugin(this.#root); this.#actions = new Actions({ element: document.body, schema: { @@ -40,6 +54,7 @@ export class TurboController extends ApplicationController { focusDirectionAttribute: 'data-turbo-focus-direction', hiddenClassName: 'hidden' }, + plugins: [plugin], debug: false }); @@ -47,6 +62,10 @@ export class TurboController extends ApplicationController { // They allow us to preserve certain HTML changes across mutations. this.#actions.observe(); + this.#actions.ready().then(() => { + document.body.classList.add('dom-ready'); + }); + // setup spinner events this.onGlobal('turbo:submit-start', () => this.startSpinner()); this.onGlobal('turbo:submit-end', () => this.stopSpinner()); @@ -73,6 +92,11 @@ export class TurboController extends ApplicationController { }); } + disconnect(): void { + this.#actions?.disconnect(); + this.#root?.destroy(); + } + private startSpinner() { this.#submitting = true; this.actions.show({ targets: this.spinnerTargets }); @@ -89,3 +113,24 @@ export class TurboController extends ApplicationController { } } } + +type Loader = (exportName: string) => Promise>; +const componentsRegistry: Record = {}; +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>)[exportName] + ); +} diff --git a/app/javascript/shared/combobox-ui.ts b/app/javascript/shared/combobox-ui.ts deleted file mode 100644 index c55b7359d..000000000 --- a/app/javascript/shared/combobox-ui.ts +++ /dev/null @@ -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; - 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(`#${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('[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((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); - } - }); -} diff --git a/app/javascript/shared/combobox.test.ts b/app/javascript/shared/combobox.test.ts deleted file mode 100644 index 45633b997..000000000 --- a/app/javascript/shared/combobox.test.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/app/javascript/shared/combobox.ts b/app/javascript/shared/combobox.ts deleted file mode 100644 index b5c82c524..000000000 --- a/app/javascript/shared/combobox.ts +++ /dev/null @@ -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; - -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; - } -} diff --git a/app/javascript/shared/tiptap/actions.ts b/app/javascript/shared/tiptap/actions.ts index 727331112..6a1d6fcae 100644 --- a/app/javascript/shared/tiptap/actions.ts +++ b/app/javascript/shared/tiptap/actions.ts @@ -1,5 +1,5 @@ import { Editor } from '@tiptap/core'; -import { z } from 'zod'; +import * as s from 'superstruct'; type EditorAction = { run(): void; @@ -11,7 +11,7 @@ export function getAction( editor: Editor, button: HTMLButtonElement ): EditorAction { - return tiptapActionSchema.parse(button.dataset)(editor); + return s.create(button.dataset, tiptapActionSchema)(editor); } const EDITOR_ACTIONS: Record EditorAction> = { @@ -109,8 +109,15 @@ const EDITOR_ACTIONS: Record EditorAction> = { }) }; -const tiptapActionSchema = z - .object({ - tiptapAction: z.enum(Object.keys(EDITOR_ACTIONS) as [string, ...string[]]) - }) - .transform(({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]); +const EditorActionFn = s.define<(editor: Editor) => EditorAction>( + 'EditorActionFn', + (fn) => typeof fn == 'function' +); + +const tiptapActionSchema = s.coerce( + EditorActionFn, + s.type({ + tiptapAction: s.enums(Object.keys(EDITOR_ACTIONS) as [string, ...string[]]) + }), + ({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction] +); diff --git a/app/javascript/shared/tiptap/tags.ts b/app/javascript/shared/tiptap/tags.ts index 3a76ca1cd..fc79ffeb5 100644 --- a/app/javascript/shared/tiptap/tags.ts +++ b/app/javascript/shared/tiptap/tags.ts @@ -1,12 +1,17 @@ import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'; -import { z } from 'zod'; +import * as s from 'superstruct'; import tippy, { type Instance as TippyInstance } from 'tippy.js'; import { matchSorter } from 'match-sorter'; -export const tagSchema = z - .object({ tagLabel: z.string(), tagId: z.string() }) - .transform(({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId })); -export type TagSchema = z.infer; +export const tagSchema = s.coerce( + s.object({ label: s.string(), id: s.string() }), + s.type({ + tagLabel: s.string(), + tagId: s.string() + }), + ({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId }) +); +export type TagSchema = s.Infer; class SuggestionMenu { #selectedIndex = 0; diff --git a/app/javascript/shared/utils.ts b/app/javascript/shared/utils.ts index 5a756a850..8602ff8e8 100644 --- a/app/javascript/shared/utils.ts +++ b/app/javascript/shared/utils.ts @@ -1,72 +1,87 @@ import { session } from '@hotwired/turbo'; -import { z } from 'zod'; +import * as s from 'superstruct'; -const Gon = z - .object({ - autosave: z - .object({ - debounce_delay: z.number().default(0), - status_visible_duration: z.number().default(0) - }) - .default({}), - autocomplete: z - .object({ - api_geo_url: z.string().optional(), - api_adresse_url: z.string().optional(), - api_education_url: z.string().optional() - }) - .default({}), - locale: z.string().default('fr'), - matomo: z - .object({ - cookieDomain: z.string().optional(), - domain: z.string().optional(), - enabled: z.boolean().default(false), - host: z.string().optional(), - key: z.string().or(z.number()).nullish() - }) - .default({}), - sentry: z - .object({ - key: z.string().nullish(), - enabled: z.boolean().default(false), - environment: z.string().optional(), - user: z.object({ id: z.string() }).default({ id: '' }), - browser: z.object({ modern: z.boolean() }).default({ modern: false }), - release: z.string().nullish() - }) - .default({}), - crisp: z - .object({ - key: z.string().nullish(), - enabled: z.boolean().default(false), - administrateur: z - .object({ - email: z.string(), - DS_SIGN_IN_COUNT: z.number(), - DS_NB_DEMARCHES_BROUILLONS: z.number(), - DS_NB_DEMARCHES_ACTIVES: z.number(), - DS_NB_DEMARCHES_ARCHIVES: z.number(), - DS_ID: z.number() - }) - .default({ +function nullish(struct: s.Struct) { + return s.optional(s.union([s.literal(null), struct])); +} + +const Gon = s.defaulted( + s.type({ + autosave: s.defaulted( + s.type({ + debounce_delay: s.defaulted(s.number(), 0), + status_visible_duration: s.defaulted(s.number(), 0) + }), + {} + ), + autocomplete: s.defaulted( + s.partial( + s.type({ + api_geo_url: s.string(), + api_adresse_url: s.string(), + api_education_url: s.string() + }) + ), + {} + ), + locale: s.defaulted(s.string(), 'fr'), + matomo: s.defaulted( + s.type({ + cookieDomain: s.optional(s.string()), + domain: s.optional(s.string()), + enabled: s.defaulted(s.boolean(), false), + host: s.optional(s.string()), + key: nullish(s.union([s.string(), s.number()])) + }), + {} + ), + sentry: s.defaulted( + s.type({ + key: nullish(s.string()), + enabled: s.defaulted(s.boolean(), false), + environment: s.optional(s.string()), + user: s.defaulted(s.type({ id: s.string() }), { id: '' }), + browser: s.defaulted(s.type({ modern: s.boolean() }), { + modern: false + }), + release: nullish(s.string()) + }), + {} + ), + crisp: s.defaulted( + s.type({ + key: nullish(s.string()), + enabled: s.defaulted(s.boolean(), false), + administrateur: s.defaulted( + s.type({ + email: s.string(), + DS_SIGN_IN_COUNT: s.number(), + DS_NB_DEMARCHES_BROUILLONS: s.number(), + DS_NB_DEMARCHES_ACTIVES: s.number(), + DS_NB_DEMARCHES_ARCHIVES: s.number(), + DS_ID: s.number() + }), + { email: '', DS_SIGN_IN_COUNT: 0, DS_NB_DEMARCHES_BROUILLONS: 0, DS_NB_DEMARCHES_ACTIVES: 0, DS_NB_DEMARCHES_ARCHIVES: 0, DS_ID: 0 - }) - }) - .default({}), - defaultQuery: z.string().optional(), - defaultVariables: z.string().optional() - }) - .default({}); + } + ) + }), + {} + ), + defaultQuery: s.optional(s.string()), + defaultVariables: s.optional(s.string()) + }), + {} +); declare const window: Window & typeof globalThis & { gon: unknown }; export function getConfig() { - return Gon.parse(window.gon); + return s.create(window.gon, Gon); } export function show(el: Element | null) { diff --git a/app/models/champ.rb b/app/models/champ.rb index 093829236..0f3eb9525 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -107,11 +107,6 @@ class Champ < ApplicationRecord [to_s] end - def valid_value - return unless valid_champ_value? - value - end - def to_s TypeDeChamp.champ_value(type_champ, self) end @@ -285,7 +280,7 @@ class Champ < ApplicationRecord return if value.nil? return if value.present? && !value.include?("\u0000") - self.value = value.delete("\u0000") + write_attribute(:value, value.delete("\u0000")) end class NotImplemented < ::StandardError diff --git a/app/models/champs/address_champ.rb b/app/models/champs/address_champ.rb index 1c4d9e78f..c07ced241 100644 --- a/app/models/champs/address_champ.rb +++ b/app/models/champs/address_champ.rb @@ -3,10 +3,6 @@ class Champs::AddressChamp < Champs::TextChamp data.present? end - def feature - data.to_json if full_address? - end - def feature=(value) if value.blank? self.data = nil @@ -22,6 +18,14 @@ class Champs::AddressChamp < Champs::TextChamp self.data = nil end + def selected_items + if value.present? + [{ value:, label: value, data: full_address? ? data : nil }] + else + [] + end + end + def address full_address? ? data : nil end diff --git a/app/models/champs/annuaire_education_champ.rb b/app/models/champs/annuaire_education_champ.rb index e691b707b..a36437902 100644 --- a/app/models/champs/annuaire_education_champ.rb +++ b/app/models/champs/annuaire_education_champ.rb @@ -7,14 +7,11 @@ class Champs::AnnuaireEducationChamp < Champs::TextChamp APIEducation::AnnuaireEducationAdapter.new(external_id).to_params end - def update_with_external_data!(data:) - if data&.is_a?(Hash) && data['nom_etablissement'].present? && data['nom_commune'].present? && data['identifiant_de_l_etablissement'].present? - update!( - data: data, - value: "#{data['nom_etablissement']}, #{data['nom_commune']} (#{data['identifiant_de_l_etablissement']})" - ) + def selected_items + if external_id.present? + [{ value: external_id, label: value }] else - update!(data: data) + [] end end end diff --git a/app/models/champs/cojo_champ.rb b/app/models/champs/cojo_champ.rb index 514db2a8e..3a356e838 100644 --- a/app/models/champs/cojo_champ.rb +++ b/app/models/champs/cojo_champ.rb @@ -50,7 +50,7 @@ class Champs::COJOChamp < Champ def update_external_id if accreditation_number_changed? || accreditation_birthdate_changed? - if accreditation_number.present? && accreditation_birthdate.present? && /\A\d+\z/.match?(accreditation_number) + if accreditation_number.present? && accreditation_birthdate.present? && /\A[\d-]+\z/.match?(accreditation_number) self.external_id = { accreditation_number:, accreditation_birthdate: }.to_json else self.external_id = nil diff --git a/app/models/champs/commune_champ.rb b/app/models/champs/commune_champ.rb index be62ce968..37d8ad70e 100644 --- a/app/models/champs/commune_champ.rb +++ b/app/models/champs/commune_champ.rb @@ -28,10 +28,6 @@ class Champs::CommuneChamp < Champs::TextChamp code_postal.present? end - def code_postal=(value) - super(value&.gsub(/[[:space:]]/, '')) - end - alias postal_code code_postal def name @@ -43,7 +39,36 @@ class Champs::CommuneChamp < Champs::TextChamp end def selected - code + code? ? "#{code}-#{code_postal}" : nil + end + + def selected_items + if code? + [{ label: to_s, value: selected }] + else + [] + end + end + + def code=(code) + if code.blank? + self.code_departement = nil + self.code_postal = nil + self.external_id = nil + self.value = nil + elsif code.match?(/-/) + codes = code.split('-') + self.external_id = codes.first + self.code_postal = codes.second + else + self.external_id = code + end + end + + private + + def safe_to_s + value.present? ? value.to_s : '' end def communes @@ -54,12 +79,6 @@ class Champs::CommuneChamp < Champs::TextChamp end end - private - - def safe_to_s - value.present? ? value.to_s : '' - end - def on_codes_change return if !code? diff --git a/app/models/champs/decimal_number_champ.rb b/app/models/champs/decimal_number_champ.rb index 57091da53..dae0ce3aa 100644 --- a/app/models/champs/decimal_number_champ.rb +++ b/app/models/champs/decimal_number_champ.rb @@ -22,6 +22,6 @@ class Champs::DecimalNumberChamp < Champ def format_value return if value.blank? - self.value = value.tr(",", ".") + self.value = value.tr(",", ".").gsub(/[[:space:]]/, "") end end diff --git a/app/models/champs/integer_number_champ.rb b/app/models/champs/integer_number_champ.rb index 39adbb5fc..bd4945a16 100644 --- a/app/models/champs/integer_number_champ.rb +++ b/app/models/champs/integer_number_champ.rb @@ -1,4 +1,6 @@ class Champs::IntegerNumberChamp < Champ + before_validation :format_value + validates :value, numericality: { only_integer: true, allow_nil: true, @@ -8,4 +10,10 @@ class Champs::IntegerNumberChamp < Champ object.errors.generate_message(:value, :not_an_integer) } }, if: :validate_champ_value_or_prefill? + + def format_value + return if value.blank? + + self.value = value.gsub(/[[:space:]]/, "") + end end diff --git a/app/models/concerns/trusted_device_concern.rb b/app/models/concerns/trusted_device_concern.rb index 2aa895893..1765f565c 100644 --- a/app/models/concerns/trusted_device_concern.rb +++ b/app/models/concerns/trusted_device_concern.rb @@ -8,7 +8,8 @@ module TrustedDeviceConcern cookies.encrypted[TRUSTED_DEVICE_COOKIE_NAME] = { value: JSON.generate({ created_at: start_at }), expires: start_at + TRUSTED_DEVICE_PERIOD, - httponly: true + httponly: true, + secure: Rails.env.production? } end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 5ee6bc037..abc959a7b 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -641,8 +641,7 @@ class TypeDeChamp < ApplicationRecord # We should refresh all champs after update except for champs using react or custom refresh # logic (RNA, SIRET, etc.) case type_champ - when type_champs.fetch(:annuaire_education), - type_champs.fetch(:carte), + when type_champs.fetch(:carte), type_champs.fetch(:piece_justificative), type_champs.fetch(:titre_identite), type_champs.fetch(:rna), diff --git a/app/models/types_de_champ/decimal_number_type_de_champ.rb b/app/models/types_de_champ/decimal_number_type_de_champ.rb index 9455283f5..486c4e5c8 100644 --- a/app/models/types_de_champ/decimal_number_type_de_champ.rb +++ b/app/models/types_de_champ/decimal_number_type_de_champ.rb @@ -20,7 +20,7 @@ class TypesDeChamp::DecimalNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase private def champ_formatted_value(champ) - champ.valid_value&.to_f + champ.value&.to_f end end end diff --git a/app/models/types_de_champ/integer_number_type_de_champ.rb b/app/models/types_de_champ/integer_number_type_de_champ.rb index 7c2d3ef58..515f475aa 100644 --- a/app/models/types_de_champ/integer_number_type_de_champ.rb +++ b/app/models/types_de_champ/integer_number_type_de_champ.rb @@ -20,7 +20,7 @@ class TypesDeChamp::IntegerNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase private def champ_formatted_value(champ) - champ.valid_value&.to_i + champ.value&.to_i end end end diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 81217caa2..02570c8d1 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -66,12 +66,12 @@ class TypesDeChamp::TypeDeChampBase when 2 champ_value(champ) else - champ.valid_value.presence || champ_default_api_value(version) + champ.value.presence || champ_default_api_value(version) end end def champ_value_for_export(champ, path = :value) - path == :value ? champ.valid_value.presence : champ_default_export_value(path) + path == :value ? champ.value.presence : champ_default_export_value(path) end def champ_value_for_tag(champ, path = :value) diff --git a/app/views/administrateurs/experts_procedures/index.html.haml b/app/views/administrateurs/experts_procedures/index.html.haml index bffd9f792..865d1cb67 100644 --- a/app/views/administrateurs/experts_procedures/index.html.haml +++ b/app/views/administrateurs/experts_procedures/index.html.haml @@ -65,15 +65,13 @@ .instructeur-wrapper %p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie - = hidden_field_tag :emails, nil - = react_component("ComboMultiple", - options: [], - selected: [], disabled: [], - group: '.instructeur-wrapper', - name: 'emails', - label: 'Emails', - describedby: 'experts-emails', - acceptNewValues: true) + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", + id: 'emails', + name: 'emails[]', + allows_custom_value: true, + 'aria-label': 'Emails', + 'aria-describedby': 'experts-emails' = f.submit 'Ajouter à la liste', class: 'fr-btn' diff --git a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml index 77470a328..4c8c4371d 100644 --- a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml @@ -10,14 +10,8 @@ - if disabled_as_super_admin = f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails' - else - = hidden_field_tag :emails, nil - = react_component("ComboMultiple", - options: available_instructeur_emails, selected: [], disabled: [], - group: '.instructeur-wrapper', - id: 'instructeur_emails', - name: 'emails', - label: 'Emails', - acceptNewValues: true) + %react-fragment + = render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails' = f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index a3d361a52..98458512c 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -122,17 +122,16 @@ .fr-fieldset__element = f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label' %p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs. - = hidden_field_tag 'procedure[tags]', JSON.generate(@procedure.tags) - = react_component("ComboMultiple", - id: "procedure_tags_combo", - options: Procedure.tags, - selected: @procedure.tags, - disabled: [], - label: 'Tags', - group: '.procedure_tags_combo', - name: 'tags', - describedby: 'procedure-tags', - acceptNewValues: true) + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", + id: "procedure_tags_combo", + items: Procedure.tags, + selected_keys: @procedure.tags, + name: 'procedure[tags][]', + value_separator: ',|;', + allows_custom_value: true, + 'aria-label': 'Tags', + 'aria-describedby': 'procedure-tags' %details.procedure-form__options-details %summary.procedure-form__options-summary diff --git a/app/views/attachments/destroy.turbo_stream.haml b/app/views/attachments/destroy.turbo_stream.haml index 48cee0bc3..e66582528 100644 --- a/app/views/attachments/destroy.turbo_stream.haml +++ b/app/views/attachments/destroy.turbo_stream.haml @@ -1,7 +1,9 @@ = turbo_stream.remove dom_id(@attachment, :persisted_row) -- if @champ_id - = turbo_stream.show "attachment-multiple-empty-#{@champ_id}" - = turbo_stream.focus_all "#attachment-multiple-empty-#{@champ_id} input" - = turbo_stream.show_all ".attachment-input-#{@attachment.id}" + +- if @champ + = fields_for @champ.input_name, @champ do |form| + = turbo_stream.replace @champ.input_group_id do + = render EditableChamp::EditableChampComponent.new champ: @champ, form: form + = turbo_stream.focus_all "#attachment-multiple-empty-#{@champ.public_id} input" diff --git a/app/views/commencer/show.html.haml b/app/views/commencer/show.html.haml index 55b7a35c9..545789f98 100644 --- a/app/views/commencer/show.html.haml +++ b/app/views/commencer/show.html.haml @@ -13,7 +13,11 @@ #{Current.application_name} %li= link_to t('views.shared.account.already_user'), commencer_sign_in_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), class: 'fr-btn fr-btn--secondary' + = render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w") + - else + = render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w") + - if @prefilled_dossier = render Dsfr::CalloutComponent.new(title: t(".prefilled_draft"), heading_level: 'h2') do |c| - c.with_body do diff --git a/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml b/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml index b60bf1c32..9c5a6bb78 100644 --- a/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml +++ b/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml @@ -7,12 +7,7 @@ %p.tab-paragrah.mb-1 Le destinataire suivra automatiquement le dossier = form_for dossier, url: send_to_instructeurs_instructeur_dossier_path(dossier.procedure, dossier), method: :post, html: { class: 'form recipients-form fr-mb-4w' } do |f| - = hidden_field_tag :recipients, nil - = react_component("ComboMultiple", - options: potential_recipients.map{|r| [r.email, r.id]}, - selected: [], disabled: [], - group: '.recipients-form', - name: 'recipients', - label: 'Emails') + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", items: potential_recipients.map { [_1.email, _1.id] }, name: 'recipients[]', 'aria-label': 'Emails' = f.submit "Envoyer", class: "fr-btn fr-mt-2w" diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index e3ec7a5a0..a5bd8de97 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -110,14 +110,8 @@ = t('views.instructeurs.dossiers.personalize') - menu.with_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_component("ComboMultiple", - options: @displayable_fields_for_select, - selected: @displayable_fields_selected, - disabled: [], - label: 'Colonne à afficher', - group: '.columns-form', - name: 'values') + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", items: @displayable_fields_for_select, selected_keys: @displayable_fields_selected, name: 'values[]', 'aria-label': 'Colonne à afficher' = submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary' diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index ceb84ff47..91fc4699e 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -59,7 +59,5 @@ - else = render 'footer' - - if Rails.env.development? - = vite_typescript_tag 'axe-core' = yield :charts_js = render Attachment::ProgressBarComponent.new diff --git a/app/views/manager/application/_javascript.html.erb b/app/views/manager/application/_javascript.html.erb index 570441489..56f773546 100644 --- a/app/views/manager/application/_javascript.html.erb +++ b/app/views/manager/application/_javascript.html.erb @@ -11,6 +11,8 @@ by providing a `content_for(:javascript)` block. <%= javascript_include_tag js_path %> <% end %> +<%= vite_client_tag %> +<%= vite_react_refresh_tag %> <%= vite_typescript_tag 'manager' %> <%= yield :javascript %> diff --git a/app/views/manager/procedures/show.html.erb b/app/views/manager/procedures/show.html.erb index 38e6919e9..17f815bdd 100644 --- a/app/views/manager/procedures/show.html.erb +++ b/app/views/manager/procedures/show.html.erb @@ -93,16 +93,15 @@ as well as a link to its edit page. <% 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 %> - <%= hidden_field_tag 'procedure[tags]', nil %> - <%= react_component("ComboMultiple", - options: Procedure.tags, - selected: procedure.tags, - disabled: [], - label: 'Tags', - group: '.procedure-form__column--form', - name: 'tags', - describedby: 'procedure-tags', - acceptNewValues: true) %> + + <%= render ReactComponent.new "ComboBox/MultiComboBox", + items: Procedure.tags, + selected_keys: procedure.tags, + value_separator: ',|;', + allows_custom_value: true, + name: 'procedure[tags][]', + 'aria-label': 'Tags' %> + <% end %> diff --git a/app/views/shared/avis/_form.html.haml b/app/views/shared/avis/_form.html.haml index 43f3dad28..2b5e00b05 100644 --- a/app/views/shared/avis/_form.html.haml +++ b/app/views/shared/avis/_form.html.haml @@ -10,16 +10,9 @@ = 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| - = hidden_field_tag 'avis[emails]', nil .fr-input-group - = react_component("ComboMultiple", - options: current_expert_not_instructeur? ? [] : @experts_emails, - selected: [], disabled: [], - label: 'Emails', - group: '.ask-avis', - name: 'emails', - describedby: 'avis-emails-description', - acceptNewValues: !@dossier.procedure.experts_require_administrateur_invitation) + %react-fragment + = 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 .fr-input-group = f.label :introduction, t('helpers.label.introduction'), class: 'fr-label' diff --git a/app/views/shared/champs/carte/_show.html.haml b/app/views/shared/champs/carte/_show.html.haml index 4b957d132..98534e434 100644 --- a/app/views/shared/champs/carte/_show.html.haml +++ b/app/views/shared/champs/carte/_show.html.haml @@ -1,4 +1,5 @@ - 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 = render Dossiers::GeoAreasComponent.new(champ:, editing: false) diff --git a/app/views/users/dossiers/demande.html.haml b/app/views/users/dossiers/demande.html.haml index 543b3c867..ef21d650e 100644 --- a/app/views/users/dossiers/demande.html.haml +++ b/app/views/users/dossiers/demande.html.haml @@ -6,20 +6,15 @@ .dossier-container.fr-mb-4w = render partial: 'users/dossiers/show/header', locals: { dossier: @dossier } - - if @dossier.en_construction? - .fr-container - .fr-grid-row.fr-grid-row--center - .fr-col-xl-10 - = render Dossiers::EnConstructionNotSubmittedComponent.new(dossier: @dossier, user: current_user) .fr-container .fr-grid-row.fr-grid-row--center .fr-col-md-9 + - if @dossier.en_construction? + = render Dossiers::EnConstructionNotSubmittedComponent.new(dossier: @dossier, user: current_user) + = render partial: 'shared/dossiers/demande', locals: { dossier: @dossier, demande_seen_at: nil, profile: 'usager' } - - - if !@dossier.read_only? - .fr-container.fr-mt-2w - .fr-grid-row.fr-grid-row--center - .fr-col-xl-8.fr-col-offset-xl-2 - %p= link_to t('views.users.dossiers.demande.edit_dossier'), modifier_dossier_path(@dossier), class: 'fr-btn fr-btn-sm', - title: t('views.users.dossiers.demande.edit_dossier_title') + - if !@dossier.read_only? + .fr-px-2w.fr-mt-2w + %p= link_to t('views.users.dossiers.demande.edit_dossier'), modifier_dossier_path(@dossier), class: 'fr-btn fr-btn-sm', + title: t('views.users.dossiers.demande.edit_dossier_title') diff --git a/bun.lockb b/bun.lockb index 7c777a111..beab4b760 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index f110f4cb3..a38b32cae 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,3 +1,3 @@ # Be sure to restart your server when you modify this file. -Rails.application.config.session_store :cookie_store, key: '_DS_session' +Rails.application.config.session_store :cookie_store, key: '_DS_session', secure: Rails.env.production?, httponly: true diff --git a/config/locales/views/layouts/_breadcrumb.fr.yml b/config/locales/views/layouts/_breadcrumb.fr.yml index 9605fb9ef..2cb0ed409 100644 --- a/config/locales/views/layouts/_breadcrumb.fr.yml +++ b/config/locales/views/layouts/_breadcrumb.fr.yml @@ -10,7 +10,7 @@ fr: since: "depuis le %{date}" closed: "Close" published: "Publiée" - draft: "En test" + draft: "En test" more_info_on_test: "Pour plus d’information sur la phase de test" go_to_FAQ: "consulter la FAQ" url_FAQ: "/faq#accordion-administrateur-2" diff --git a/config/routes.rb b/config/routes.rb index 06f613da0..2c2c86ed0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -251,6 +251,7 @@ Rails.application.routes.draw do namespace :data_sources do get :adresse, to: 'adresse#search', as: :data_source_adresse 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_centre_couts, to: 'chorus#search_centre_couts', as: :search_centre_couts diff --git a/config/zones.yml b/config/zones.yml index 6607aa601..11cd6e655 100644 --- a/config/zones.yml +++ b/config/zones.yml @@ -123,3 +123,9 @@ ministeres: labels: - '2022-05-20': "Non attribué" - '2020-07-06': "Ministère des Outre-mer" + - AI: + labels: + - '2024-07-04': "Autorité indépendante" + - CP: + labels: + - '2024-07-04': "Chambre parlementaire" diff --git a/package.json b/package.json index edad7ebc6..d301bee84 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "type": "module", "dependencies": { - "@coldwired/actions": "^0.11.2", - "@coldwired/turbo-stream": "^0.11.1", - "@coldwired/utils": "^0.11.4", + "@coldwired/actions": "^0.13.0", + "@coldwired/react": "^0.15.0", + "@coldwired/turbo-stream": "^0.13.0", + "@coldwired/utils": "^0.13.0", "@frsource/autoresize-textarea": "^2.0.75", "@gouvfr/dsfr": "^1.11.2", "@graphiql/plugin-explorer": "^3.0.2", @@ -17,7 +18,6 @@ "@rails/actiontext": "^7.1.3-2", "@rails/activestorage": "^7.1.3-2", "@rails/ujs": "^7.1.3-2", - "@reach/combobox": "^0.17.0", "@reach/slider": "^0.17.0", "@sentry/browser": "8.7.0", "@tiptap/core": "^2.2.4", @@ -50,31 +50,32 @@ "graphiql": "^3.2.3", "graphql": "^16.8.1", "highcharts": "^10.3.3", - "is-hotkey": "^0.2.0", "lightgallery": "^2.7.2", "maplibre-gl": "^1.15.2", "match-sorter": "^6.3.4", "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-dom": "^18.2.0", + "react-dom": "^18.3.0", "react-popper": "^2.3.0", - "react-query": "^3.39.3", + "react-use-event-hook": "^0.9.6", "spectaql": "^2.3.1", "stimulus-use": "^0.52.2", + "superstruct": "^1.0.4", "terser": "^5.31.0", "tiny-invariant": "^1.3.3", "tippy.js": "^6.3.7", "trix": "^1.2.3", - "use-debounce": "^9.0.4", - "zod": "^3.20.2" + "usehooks-ts": "^3.1.0" }, "devDependencies": { "@esbuild/darwin-arm64": "=0.19.9", "@esbuild/linux-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-linux-x64-gnu": "=4.9.1", "@rollup/rollup-win32-x64-msvc": "=4.9.1", "@types/debounce": "^1.2.4", "@types/geojson": "^7946.0.14", @@ -82,8 +83,8 @@ "@types/mapbox__mapbox-gl-draw": "^1.2.5", "@types/rails__activestorage": "^7.1.1", "@types/rails__ujs": "^6.0.4", - "@types/react": "^17.0.43", - "@types/react-dom": "^17.0.14", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "^7.11.0", "@typescript-eslint/parser": "^7.11.0", @@ -114,7 +115,8 @@ "postinstall": "patch-package", "test": "vitest", "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": { "string-width": "4.2.2", @@ -169,6 +171,7 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended", + "plugin:react/jsx-runtime", "prettier" ], "rules": { diff --git a/spec/components/attachment/multiple_component_spec.rb b/spec/components/attachment/multiple_component_spec.rb index 26dfacd2c..143831280 100644 --- a/spec/components/attachment/multiple_component_spec.rb +++ b/spec/components/attachment/multiple_component_spec.rb @@ -75,8 +75,8 @@ RSpec.describe Attachment::MultipleComponent, type: :component do context 'max attachments' do let(:kwargs) { { max: 1 } } - it 'does not render visible input file where max attachments has been reached' do - expect(subject).to have_selector('.hidden input[type=file]') + it 'renders a disabled input file where max attachments has been reached' do + expect(subject).to have_selector('input[type=file][disabled]') end end diff --git a/spec/components/previews/dsfr/combobox_component_preview.rb b/spec/components/previews/dsfr/combobox_component_preview.rb deleted file mode 100644 index 57367fd28..000000000 --- a/spec/components/previews/dsfr/combobox_component_preview.rb +++ /dev/null @@ -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 diff --git a/spec/controllers/administrateurs/experts_procedures_controller_spec.rb b/spec/controllers/administrateurs/experts_procedures_controller_spec.rb index 7c99bbbc6..1807a0cd5 100644 --- a/spec/controllers/administrateurs/experts_procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/experts_procedures_controller_spec.rb @@ -26,7 +26,7 @@ describe Administrateurs::ExpertsProceduresController, type: :controller do subject { post :create, params: params } 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 subject @@ -38,7 +38,7 @@ describe Administrateurs::ExpertsProceduresController, type: :controller do end 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 it 'warns' do subject diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index 21cf35fb5..86fb07db8 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -332,14 +332,14 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do describe '#add_instructeur_procedure_non_routee' do # faire la meme chose sur une procedure non routee 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 } before { 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 } } 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 expect(subject).to render_template(:index) expect(subject.request.flash[:alert]).to be_nil @@ -348,7 +348,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do end 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 expect(subject).to render_template(:index) expect(subject.request.flash[:alert]).to be_present @@ -376,7 +376,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do params: { procedure_id: procedure.id, id: gi_1_2.id, - emails: new_instructeur_emails.to_json + emails: new_instructeur_emails } end diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index d5198d952..3259d3d0b 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -13,7 +13,7 @@ describe Administrateurs::ProceduresController, type: :controller do let(:lien_site_web) { 'http://mon-site.gouv.fr' } let(:zone) { create(:zone) } let(:zone_ids) { [zone.id] } - let(:tags) { "[\"planete\",\"environnement\"]" } + let(:tags) { ["planete", "environnement"] } describe '#apercu' do subject { get :apercu, params: { id: procedure.id } } diff --git a/spec/controllers/experts/avis_controller_spec.rb b/spec/controllers/experts/avis_controller_spec.rb index e58b4d2bb..56865796e 100644 --- a/spec/controllers/experts/avis_controller_spec.rb +++ b/spec/controllers/experts/avis_controller_spec.rb @@ -367,7 +367,7 @@ describe Experts::AvisController, type: :controller do let(:previous_avis_confidentiel) { false } let(:previous_revoked_at) { nil } 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(:created_avis) { Avis.last } let!(:old_avis_count) { Avis.count } @@ -394,7 +394,7 @@ describe Experts::AvisController, type: :controller do end context 'when an invalid email' do - let(:emails) { "[\"toto.fr\"]" } + let(:emails) { ["toto.fr"] } it do expect(response).to render_template :instruction @@ -414,7 +414,7 @@ describe Experts::AvisController, type: :controller do end context 'ask review with attachment' do - let(:emails) { "[\"toto@totomail.com\"]" } + let(:emails) { ["toto@totomail.com"] } it do expect(created_avis.introduction_file).to be_attached @@ -425,7 +425,7 @@ describe Experts::AvisController, type: :controller do end context 'with multiple emails' do - let(:emails) { "[\"toto.fr\",\"titi@titimail.com\"]" } + let(:emails) { ["toto.fr", "titi@titimail.com"] } it do expect(response).to render_template :instruction diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 47cadd89c..198c75a13 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -28,7 +28,7 @@ describe Instructeurs::DossiersController, type: :controller do post( :send_to_instructeurs, params: { - recipients: [recipient.id].to_json, + recipients: [recipient.id], procedure_id: procedure.id, dossier_id: dossier.id } @@ -776,7 +776,7 @@ describe Instructeurs::DossiersController, type: :controller do } end - let(:emails) { "[\"email@a.com\"]" } + let(:emails) { ["email@a.com"] } context "notifications updates" 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)) } context "with an invalid email" do - let(:emails) { "[\"emaila.com\"]" } + let(:emails) { ["emaila.com"] } before { subject } @@ -822,7 +822,7 @@ describe Instructeurs::DossiersController, type: :controller do end context "with no email" do - let(:emails) { "" } + let(:emails) { [] } before { subject } @@ -833,7 +833,7 @@ describe Instructeurs::DossiersController, type: :controller do end context 'with multiple emails' do - let(:emails) { "[\"toto.fr\",\"titi@titimail.com\"]" } + let(:emails) { ["toto.fr", "titi@titimail.com"] } before { subject } @@ -845,7 +845,7 @@ describe Instructeurs::DossiersController, type: :controller do end 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) } before { subject } diff --git a/spec/fixtures/cassettes/The_user/fill_a_dossier.yml b/spec/fixtures/cassettes/The_user/fill_a_dossier.yml index 2bf10a941..ea0d2acf2 100644 --- a/spec/fixtures/cassettes/The_user/fill_a_dossier.yml +++ b/spec/fixtures/cassettes/The_user/fill_a_dossier.yml @@ -1,77 +1,5 @@ --- http_interactions: -- request: - method: get - uri: https://geo.api.gouv.fr/communes?boost=population&codePostal=60&limit=50&type=commune-actuelle,arrondissement-municipal - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.10.3 (Ubuntu) - Date: - - Mon, 04 Mar 2024 09:41:10 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '2' - X-Powered-By: - - Express - Vary: - - Origin - Etag: - - W/"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w" - Strict-Transport-Security: - - max-age=15552000 - body: - encoding: ASCII-8BIT - string: "[]" - recorded_at: Mon, 04 Mar 2024 09:41:10 GMT -- request: - method: get - uri: https://geo.api.gouv.fr/communes?boost=population&codePostal=6040&limit=50&type=commune-actuelle,arrondissement-municipal - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.10.3 (Ubuntu) - Date: - - Mon, 04 Mar 2024 09:41:10 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '2' - X-Powered-By: - - Express - Vary: - - Origin - Etag: - - W/"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w" - Strict-Transport-Security: - - max-age=15552000 - body: - encoding: ASCII-8BIT - string: "[]" - recorded_at: Mon, 04 Mar 2024 09:41:10 GMT - request: method: get uri: https://geo.api.gouv.fr/communes?boost=population&codePostal=60400&limit=50&type=commune-actuelle,arrondissement-municipal @@ -91,7 +19,7 @@ http_interactions: Server: - nginx/1.10.3 (Ubuntu) Date: - - Mon, 04 Mar 2024 09:41:11 GMT + - Tue, 02 Jul 2024 13:53:51 GMT Content-Type: - application/json; charset=utf-8 Content-Length: @@ -102,310 +30,14 @@ http_interactions: X-Powered-By: - Express Etag: - - W/"10fd-5D0Cm9Wh2PWHu/iLOAIRod2IvrQ" + - W/"10fd-b5NvAPTb7NhRASMMh9m0aHfdfMU" Strict-Transport-Security: - max-age=15552000 body: encoding: ASCII-8BIT string: !binary |- - W3sibm9tIjoiQXBwaWxseSIsImNvZGUiOiI2MDAyMSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDAyMTYiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo1Mjl9LHsibm9tIjoiQmFixZN1ZiIsImNvZGUiOiI2MDAzNyIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDAzNjQiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo1MTB9LHsibm9tIjoiQmVhdXJhaW5zLWzDqHMtTm95b24iLCJjb2RlIjoiNjAwNTUiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAwNTQ3IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MzQwfSx7Im5vbSI6IkLDqWjDqXJpY291cnQiLCJjb2RlIjoiNjAwNTkiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAwNTg4IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MjAyfSx7Im5vbSI6IkJyw6l0aWdueSIsImNvZGUiOiI2MDEwNSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDEwNTciLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo0Mjd9LHsibm9tIjoiQnVzc3kiLCJjb2RlIjoiNjAxMTciLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAxMTcyIiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MzEwfSx7Im5vbSI6IkNhaXNuZXMiLCJjb2RlIjoiNjAxMTgiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAxMTgwIiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NTA3fSx7Im5vbSI6IkNyaXNvbGxlcyIsImNvZGUiOiI2MDE4MSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDE4MDAiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo5MDh9LHsibm9tIjoiQ3V0cyIsImNvZGUiOiI2MDE4OSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDE4ODMiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo5Njd9LHsibm9tIjoiR2VudnJ5IiwiY29kZSI6IjYwMjcwIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwMjY3NSIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjMzNX0seyJub20iOiJHcmFuZHLDuyIsImNvZGUiOiI2MDI4NyIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDI4NDAiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjozNjB9LHsibm9tIjoiTGFyYnJveWUiLCJjb2RlIjoiNjAzNDgiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAzNDY3IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NTE2fSx7Im5vbSI6Ik1vbmRlc2NvdXJ0IiwiY29kZSI6IjYwNDEwIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNDA2OSIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjI0OX0seyJub20iOiJNb3JsaW5jb3VydCIsImNvZGUiOiI2MDQzMSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDQyNjciLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo1Mzh9LHsibm9tIjoiTmFtcGNlbCIsImNvZGUiOiI2MDQ0NSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDQ0MDgiLCJjb2RlRXBjaSI6IjI0NjAwMDc0OSIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjozMTB9LHsibm9tIjoiTm95b24iLCJjb2RlIjoiNjA0NzEiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA0NjU1IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MTMxOTd9LHsibm9tIjoiUGFzc2VsIiwiY29kZSI6IjYwNDg4IiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNDgyMCIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjI3Mn0seyJub20iOiJQb250LWwnw4l2w6pxdWUiLCJjb2RlIjoiNjA1MDYiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA1MDA5IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6Njc5fSx7Im5vbSI6IlBvbnRvaXNlLWzDqHMtTm95b24iLCJjb2RlIjoiNjA1MDciLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA1MDE3IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NDUzfSx7Im5vbSI6IlBvcnF1w6lyaWNvdXJ0IiwiY29kZSI6IjYwNTExIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNTA1OCIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjQwMX0seyJub20iOiJTYWxlbmN5IiwiY29kZSI6IjYwNjAzIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNTk2NyIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjg5OH0seyJub20iOiJTZW1waWdueSIsImNvZGUiOiI2MDYxMCIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDYwMTUiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo3NjZ9LHsibm9tIjoiU2VybWFpemUiLCJjb2RlIjoiNjA2MTciLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA2MDgwIiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MjczfSx7Im5vbSI6IlN1em95IiwiY29kZSI6IjYwNjI1IiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNjE2MyIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjU3NH0seyJub20iOiJWYXJlc25lcyIsImNvZGUiOiI2MDY1NSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDY0NjAiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjozNjR9LHsibm9tIjoiVmF1Y2hlbGxlcyIsImNvZGUiOiI2MDY1NyIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDY0ODYiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjoyNDZ9LHsibm9tIjoiVmlsbGUiLCJjb2RlIjoiNjA2NzYiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA2Njc2IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NzU1fV0= - recorded_at: Mon, 04 Mar 2024 09:41:10 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20d - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4449' - Vary: - - Origin - Etag: - - W/"1161-ye3cMV7bYtodrssf15NEDin0/8U" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzIuMjY5MjQyLDQ5LjkwNjQ3N119LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGQnQWJiZXZpbGxlIDgwMDAwIEFtaWVucyIsInNjb3JlIjowLjg5NTIyMDkwOTA5MDkwOSwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiODAwMjFfMDA1MF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZCdBYmJldmlsbGUiLCJwb3N0Y29kZSI6IjgwMDAwIiwiY2l0eWNvZGUiOiI4MDAyMSIsIngiOjY0NzQ2My4yNiwieSI6Njk3ODg4Ni41NCwiY2l0eSI6IkFtaWVucyIsImNvbnRleHQiOiI4MCwgU29tbWUsIEhhdXRzLWRlLUZyYW5jZSIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuODQ3NDMsInN0cmVldCI6IlJ1ZSBkJ0FiYmV2aWxsZSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlswLjY4NDM5Niw0Ny4zODczMjddfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBkJ0VudHJhaWd1ZXMgMzcwMDAgVG91cnMiLCJzY29yZSI6MC44OTM2NTcyNzI3MjcyNzI3LCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIzNzI2MV8xNjgwXzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBkJ0VudHJhaWd1ZXMiLCJwb3N0Y29kZSI6IjM3MDAwIiwiY2l0eWNvZGUiOiIzNzI2MSIsIngiOjUyNTMzMi4wNywieSI6NjcwMTExNS4yOCwiY2l0eSI6IlRvdXJzIiwiY29udGV4dCI6IjM3LCBJbmRyZS1ldC1Mb2lyZSwgQ2VudHJlLVZhbCBkZSBMb2lyZSIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuODMwMjMsInN0cmVldCI6IlJ1ZSBkJ0VudHJhaWd1ZXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMy4wNjExMzYsNTAuNjIzNTI0XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZCdBcnRvaXMgNTkwMDAgTGlsbGUiLCJzY29yZSI6MC44OTM1MDk5OTk5OTk5OTk5LCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiI1OTM1MF8wMzkxXzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBkJ0FydG9pcyIsInBvc3Rjb2RlIjoiNTkwMDAiLCJjaXR5Y29kZSI6IjU5MzUwIiwib2xkY2l0eWNvZGUiOiI1OTM1MCIsIngiOjcwNDMzMy41MSwieSI6NzA1ODUwNC4zMywiY2l0eSI6IkxpbGxlIiwib2xkY2l0eSI6IkxpbGxlIiwiY29udGV4dCI6IjU5LCBOb3JkLCBIYXV0cy1kZS1GcmFuY2UiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjgyODYxLCJzdHJlZXQiOiJSdWUgZCdBcnRvaXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNS4wNTEyOTksNDcuMzEyMzA5XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZCdBdXhvbm5lIDIxMDAwIERpam9uIiwic2NvcmUiOjAuODkzMjEwOTA5MDkwOTA5LCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIyMTIzMV8wNjEwXzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBkJ0F1eG9ubmUiLCJwb3N0Y29kZSI6IjIxMDAwIiwiY2l0eWNvZGUiOiIyMTIzMSIsIngiOjg1NDk1Mi40MSwieSI6NjY5MjIzMy41MSwiY2l0eSI6IkRpam9uIiwiY29udGV4dCI6IjIxLCBDw7R0ZS1kJ09yLCBCb3VyZ29nbmUtRnJhbmNoZS1Db210w6kiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjgyNTMyLCJzdHJlZXQiOiJSdWUgZCdBdXhvbm5lIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzEuNDY2NSw0My42MDY1NjVdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBkJ0Fzc2FsaXQgMzE1MDAgVG91bG91c2UiLCJzY29yZSI6MC44OTI2MiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzE1NTVfMDU4MF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZCdBc3NhbGl0IiwicG9zdGNvZGUiOiIzMTUwMCIsImNpdHljb2RlIjoiMzE1NTUiLCJ4Ijo1NzYxNjIuNSwieSI6NjI3OTgxNC45OCwiY2l0eSI6IlRvdWxvdXNlIiwiY29udGV4dCI6IjMxLCBIYXV0ZS1HYXJvbm5lLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjgxODgyLCJzdHJlZXQiOiJSdWUgZCdBc3NhbGl0In19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6Wy0xLjUzODcxNiw0Ny4yMjAyNjldfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBkJ0FsbG9udmlsbGUgNDQwMDAgTmFudGVzIiwic2NvcmUiOjAuODkyNDYzNjM2MzYzNjM2MywiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNDQxMDlfMDE2OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZCdBbGxvbnZpbGxlIiwicG9zdGNvZGUiOiI0NDAwMCIsImNpdHljb2RlIjoiNDQxMDkiLCJ4IjozNTY3MTQuMjcsInkiOjY2ODk4NjUuNTUsImNpdHkiOiJOYW50ZXMiLCJjb250ZXh0IjoiNDQsIExvaXJlLUF0bGFudGlxdWUsIFBheXMgZGUgbGEgTG9pcmUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjgxNzEsInN0cmVldCI6IlJ1ZSBkJ0FsbG9udmlsbGUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMi4zMjQ4NzQsNDguODI4NjEyXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZCdBbMOpc2lhIDc1MDE0IFBhcmlzIiwic2NvcmUiOjAuODkyMTc3MjcyNzI3MjcyNywiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNzUxMTRfMDE0M18wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZCdBbMOpc2lhIiwicG9zdGNvZGUiOiI3NTAxNCIsImNpdHljb2RlIjoiNzUxMTQiLCJ4Ijo2NTA0MzcuNDcsInkiOjY4NTg5NDAuMDgsImNpdHkiOiJQYXJpcyIsImRpc3RyaWN0IjoiUGFyaXMgMTRlIEFycm9uZGlzc2VtZW50IiwiY29udGV4dCI6Ijc1LCBQYXJpcywgw45sZS1kZS1GcmFuY2UiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjgxMzk1LCJzdHJlZXQiOiJSdWUgZCdBbMOpc2lhIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzMuMDQxODM1LDUwLjYyMzI3NV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGQnRXNxdWVybWVzIDU5MDAwIExpbGxlIiwic2NvcmUiOjAuODkyMTcyNzI3MjcyNzI3MiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNTkzNTBfMzE5M18wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZCdFc3F1ZXJtZXMiLCJwb3N0Y29kZSI6IjU5MDAwIiwiY2l0eWNvZGUiOiI1OTM1MCIsIm9sZGNpdHljb2RlIjoiNTkzNTAiLCJ4Ijo3MDI5NjUuNDEsInkiOjcwNTg0NzUuNjksImNpdHkiOiJMaWxsZSIsIm9sZGNpdHkiOiJMaWxsZSIsImNvbnRleHQiOiI1OSwgTm9yZCwgSGF1dHMtZGUtRnJhbmNlIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44MTM5LCJzdHJlZXQiOiJSdWUgZCdFc3F1ZXJtZXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMy40MDM1OTUsNTAuMzQ5NzcyXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgRCA1OTI1NSBIYXZlbHV5Iiwic2NvcmUiOjAuNTEzMzg2MTAzODk2MTAzOCwiaWQiOiI1OTI5Ml9naWk0eTQiLCJuYW1lIjoiUnVlIEQiLCJwb3N0Y29kZSI6IjU5MjU1IiwiY2l0eWNvZGUiOiI1OTI5MiIsIngiOjcyODc2My44NCwieSI6NzAyODA3OC41MywiY2l0eSI6IkhhdmVsdXkiLCJjb250ZXh0IjoiNTksIE5vcmQsIEhhdXRzLWRlLUZyYW5jZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjUwNDM5LCJzdHJlZXQiOiJSdWUgRCJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlszLjA2NjAzMiw1MC42MjExMzhdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlJ1ZSBkJ0FycmFzIDU5MDAwIExpbGxlIiwic2NvcmUiOjAuMzI2MjMwMDY5OTMwMDY5OSwiaWQiOiI1OTM1MF8wMzc2IiwibmFtZSI6IlJ1ZSBkJ0FycmFzIiwicG9zdGNvZGUiOiI1OTAwMCIsImNpdHljb2RlIjoiNTkzNTAiLCJvbGRjaXR5Y29kZSI6IjU5MzUwIiwieCI6NzA0NjgwLjc4LCJ5Ijo3MDU4MjM4Ljc0LCJjaXR5IjoiTGlsbGUiLCJvbGRjaXR5IjoiTGlsbGUiLCJjb250ZXh0IjoiNTksIE5vcmQsIEhhdXRzLWRlLUZyYW5jZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjgxOTMsInN0cmVldCI6IlJ1ZSBkJ0FycmFzIn19XSwiYXR0cmlidXRpb24iOiJCQU4iLCJsaWNlbmNlIjoiRVRBTEFCLTIuMCIsInF1ZXJ5IjoiNzggUnVlIGQiLCJsaW1pdCI6MTB9 - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20R - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '129' - Vary: - - Origin - Etag: - - W/"81-+5qJ3zMojnCP18TiVLMlqIkD8QM" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: '{"type":"FeatureCollection","version":"draft","features":[],"attribution":"BAN","licence":"ETALAB-2.0","query":"78 - R","limit":10}' - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4440' - Vary: - - Origin - Etag: - - W/"1158-z84zpIdEpc3bQAqfMwajMvgGvGQ" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzIuMzA1MzkxLDQ4Ljg0MzU3Nl19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIExlY291cmJlIDc1MDE1IFBhcmlzIiwic2NvcmUiOjAuODk1NzQ5OTk5OTk5OTk5OSwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNzUxMTVfNTQ1Nl8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgTGVjb3VyYmUiLCJwb3N0Y29kZSI6Ijc1MDE1IiwiY2l0eWNvZGUiOiI3NTExNSIsIngiOjY0OTAyMS44NiwieSI6Njg2MDYxNi4zMSwiY2l0eSI6IlBhcmlzIiwiZGlzdHJpY3QiOiJQYXJpcyAxNWUgQXJyb25kaXNzZW1lbnQiLCJjb250ZXh0IjoiNzUsIFBhcmlzLCDDjmxlLWRlLUZyYW5jZSIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuODUzMjUsInN0cmVldCI6IlJ1ZSBMZWNvdXJiZSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlstMC41NjEzMjUsNDQuODIyNjkzXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgUGVsbGVwb3J0IDMzODAwIEJvcmRlYXV4Iiwic2NvcmUiOjAuODk1NDcxODE4MTgxODE4MiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzMwNjNfNzEwNV8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgUGVsbGVwb3J0IiwicG9zdGNvZGUiOiIzMzgwMCIsImNpdHljb2RlIjoiMzMwNjMiLCJ4Ijo0MTg1NzcuMDYsInkiOjY0MjAwNzMuMjYsImNpdHkiOiJCb3JkZWF1eCIsImNvbnRleHQiOiIzMywgR2lyb25kZSwgTm91dmVsbGUtQXF1aXRhaW5lIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44NTAxOSwic3RyZWV0IjoiUnVlIFBlbGxlcG9ydCJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlstMC41ODU1MzIsNDQuODQxMjQ5XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgSnVkYcOvcXVlIDMzMDAwIEJvcmRlYXV4Iiwic2NvcmUiOjAuODk1MjIxODE4MTgxODE4MSwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzMwNjNfNDgxMF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgSnVkYcOvcXVlIiwicG9zdGNvZGUiOiIzMzAwMCIsImNpdHljb2RlIjoiMzMwNjMiLCJ4Ijo0MTY3NTkuMDQsInkiOjY0MjIyMTguNzMsImNpdHkiOiJCb3JkZWF1eCIsImNvbnRleHQiOiIzMywgR2lyb25kZSwgTm91dmVsbGUtQXF1aXRhaW5lIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44NDc0NCwic3RyZWV0IjoiUnVlIEp1ZGHDr3F1ZSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlszLjA1OTc4OSw1MC42MzU5NzhdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBOYXRpb25hbGUgNTkwMDAgTGlsbGUiLCJzY29yZSI6MC44OTUxMiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNTkzNTBfZmtpMWY2XzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBOYXRpb25hbGUiLCJwb3N0Y29kZSI6IjU5MDAwIiwiY2l0eWNvZGUiOiI1OTM1MCIsIm9sZGNpdHljb2RlIjoiNTkzNTAiLCJ4Ijo3MDQyMzYuOTgsInkiOjcwNTk4OTEuOTksImNpdHkiOiJMaWxsZSIsIm9sZGNpdHkiOiJMaWxsZSIsImNvbnRleHQiOiI1OSwgTm9yZCwgSGF1dHMtZGUtRnJhbmNlIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44NDYzMiwic3RyZWV0IjoiUnVlIE5hdGlvbmFsZSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlstMC42MDQ3MTUsNDQuODQ0MzQxXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgUGFzdGV1ciAzMzIwMCBCb3JkZWF1eCIsInNjb3JlIjowLjg5NTA5OTk5OTk5OTk5OTksImhvdXNlbnVtYmVyIjoiNzgiLCJpZCI6IjMzMDYzXzY5NzVfMDAwNzgiLCJuYW1lIjoiNzggUnVlIFBhc3RldXIiLCJwb3N0Y29kZSI6IjMzMjAwIiwiY2l0eWNvZGUiOiIzMzA2MyIsIngiOjQxNTI2MC4zOSwieSI6NjQyMjYzMC43OSwiY2l0eSI6IkJvcmRlYXV4IiwiY29udGV4dCI6IjMzLCBHaXJvbmRlLCBOb3V2ZWxsZS1BcXVpdGFpbmUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjg0NjEsInN0cmVldCI6IlJ1ZSBQYXN0ZXVyIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzEuNDE3MTE5LDQzLjU4NzI2MV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIFZlc3RyZXBhaW4gMzExMDAgVG91bG91c2UiLCJzY29yZSI6MC44OTQ5NzcyNzI3MjcyNzI3LCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIzMTU1NV84ODA0XzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBWZXN0cmVwYWluIiwicG9zdGNvZGUiOiIzMTEwMCIsImNpdHljb2RlIjoiMzE1NTUiLCJ4Ijo1NzIxMzIuMjcsInkiOjYyNzc3NDguNjUsImNpdHkiOiJUb3Vsb3VzZSIsImNvbnRleHQiOiIzMSwgSGF1dGUtR2Fyb25uZSwgT2NjaXRhbmllIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44NDQ3NSwic3RyZWV0IjoiUnVlIFZlc3RyZXBhaW4ifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMS40NjA5MDYsNDMuNTc5MDg5XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgQm9ubmF0IDMxNDAwIFRvdWxvdXNlIiwic2NvcmUiOjAuODk0OTQxODE4MTgxODE4MiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzE1NTVfMTI0NF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgQm9ubmF0IiwicG9zdGNvZGUiOiIzMTQwMCIsImNpdHljb2RlIjoiMzE1NTUiLCJ4Ijo1NzU2NTEuMywieSI6NjI3Njc3MC42NCwiY2l0eSI6IlRvdWxvdXNlIiwiY29udGV4dCI6IjMxLCBIYXV0ZS1HYXJvbm5lLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjg0NDM2LCJzdHJlZXQiOiJSdWUgQm9ubmF0In19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6Wy0wLjU2Mjk4OSw0NC44MjUxODddfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBNYWxiZWMgMzM4MDAgQm9yZGVhdXgiLCJzY29yZSI6MC44OTQ5MDE4MTgxODE4MTgxLCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIzMzA2M181ODYwXzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBNYWxiZWMiLCJwb3N0Y29kZSI6IjMzODAwIiwiY2l0eWNvZGUiOiIzMzA2MyIsIngiOjQxODQ1OC4xNSwieSI6NjQyMDM1NS45MiwiY2l0eSI6IkJvcmRlYXV4IiwiY29udGV4dCI6IjMzLCBHaXJvbmRlLCBOb3V2ZWxsZS1BcXVpdGFpbmUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjg0MzkyLCJzdHJlZXQiOiJSdWUgTWFsYmVjIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6Wy0wLjU4Njk5OSw0NC44MzM5MDddfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBMZWNvY3EgMzMwMDAgQm9yZGVhdXgiLCJzY29yZSI6MC44OTQ1NzgxODE4MTgxODE3LCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIzMzA2M181MzIwXzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBMZWNvY3EiLCJwb3N0Y29kZSI6IjMzMDAwIiwiY2l0eWNvZGUiOiIzMzA2MyIsIngiOjQxNjYwNi4yLCJ5Ijo2NDIxNDA5LjM1LCJjaXR5IjoiQm9yZGVhdXgiLCJjb250ZXh0IjoiMzMsIEdpcm9uZGUsIE5vdXZlbGxlLUFxdWl0YWluZSIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuODQwMzYsInN0cmVldCI6IlJ1ZSBMZWNvY3EifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbLTAuNTg3NTUzLDQ0Ljg0ODM4NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIE5hdWphYyAzMzAwMCBCb3JkZWF1eCIsInNjb3JlIjowLjg5NDU3ODE4MTgxODE4MTcsImhvdXNlbnVtYmVyIjoiNzgiLCJpZCI6IjMzMDYzXzY2NjBfMDAwNzgiLCJuYW1lIjoiNzggUnVlIE5hdWphYyIsInBvc3Rjb2RlIjoiMzMwMDAiLCJjaXR5Y29kZSI6IjMzMDYzIiwieCI6NDE2NjM1LjQ5LCJ5Ijo2NDIzMDE3LjY1LCJjaXR5IjoiQm9yZGVhdXgiLCJjb250ZXh0IjoiMzMsIEdpcm9uZGUsIE5vdXZlbGxlLUFxdWl0YWluZSIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuODQwMzYsInN0cmVldCI6IlJ1ZSBOYXVqYWMifX1dLCJhdHRyaWJ1dGlvbiI6IkJBTiIsImxpY2VuY2UiOiJFVEFMQUItMi4wIiwicXVlcnkiOiI3OCBSdWUiLCJsaW1pdCI6MTB9 - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr%C3%A9s - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4186' - Vary: - - Origin - Etag: - - W/"105a-ebhd1czeXybk7JwL9w0CNhYfX38" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjMwNzQ3LDQzLjc0NjA2NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IEdyw6lzIDMwMzEwIFZlcmfDqHplIiwic2NvcmUiOjAuOTU5NTA3MjcyNzI3MjcyNywiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzAzNDRfMDA5OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzEwIiwiY2l0eWNvZGUiOiIzMDM0NCIsIngiOjc5OTE0OS4zMywieSI6NjI5NDg4OC4zMSwiY2l0eSI6IlZlcmfDqHplIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjU1NDU4LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMS4wMDQzNTgsNDkuNDU5NDIzXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZHUgR3LDqSA3NjM4MCBNb250aWdueSIsInNjb3JlIjowLjc5NzQ0MjAyNzk3MjAyOCwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNzY0NDZfMDIwMF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqSIsInBvc3Rjb2RlIjoiNzYzODAiLCJjaXR5Y29kZSI6Ijc2NDQ2IiwieCI6NTU1MjgyLjI3LCJ5Ijo2OTMwNzE5LjU0LCJjaXR5IjoiTW9udGlnbnkiLCJjb250ZXh0IjoiNzYsIFNlaW5lLU1hcml0aW1lLCBOb3JtYW5kaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjQ2NDE3LCJzdHJlZXQiOiJSdWUgZHUgR3LDqSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlstMC42NzQ3MDYsNDcuODE1MTAyXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3LDqHMgNTMyMDAgQ2jDonRlYXUtR29udGllci1zdXItTWF5ZW5uZSIsInNjb3JlIjowLjY4MDkyMjQ0NzU1MjQ0NzYsImlkIjoiNTMwNjJfMTY5OCIsIm5hbWUiOiJSdWUgZHUgR3LDqHMiLCJwb3N0Y29kZSI6IjUzMjAwIiwiY2l0eWNvZGUiOiI1MzA2MiIsIm9sZGNpdHljb2RlIjoiNTMwMTQiLCJ4Ijo0MjUwODUuMDgsInkiOjY3NTI0NzYuNjYsImNpdHkiOiJDaMOidGVhdS1Hb250aWVyLXN1ci1NYXllbm5lIiwib2xkY2l0eSI6IkF6w6kiLCJjb250ZXh0IjoiNTMsIE1heWVubmUsIFBheXMgZGUgbGEgTG9pcmUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC41NjcwNywic3RyZWV0IjoiUnVlIGR1IEdyw6hzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuNzcwNjE5LDQzLjk1ODE5NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyw6hzIDMwMTMzIExlcyBBbmdsZXMiLCJzY29yZSI6MC42NzkzNDc5MDIwOTc5MDIxLCJpZCI6IjMwMDExX2tldHl2YSIsIm5hbWUiOiJSdWUgZHUgR3LDqHMiLCJwb3N0Y29kZSI6IjMwMTMzIiwiY2l0eWNvZGUiOiIzMDAxMSIsIngiOjg0MjEwNi44NiwieSI6NjMxOTI4MS4yNiwiY2l0eSI6IkxlcyBBbmdsZXMiLCJjb250ZXh0IjoiMzAsIEdhcmQsIE9jY2l0YW5pZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjU0OTc1LCJzdHJlZXQiOiJSdWUgZHUgR3LDqHMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMS44MTc5NzUsNDMuNjk5MzE3XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3LDqHMgODE1MDAgTGF2YXVyIiwic2NvcmUiOjAuNjc2NTg4ODExMTg4ODExMiwiaWQiOiI4MTE0MF8wMzgwIiwibmFtZSI6IlJ1ZSBkdSBHcsOocyIsInBvc3Rjb2RlIjoiODE1MDAiLCJjaXR5Y29kZSI6IjgxMTQwIiwieCI6NjA0Njk3LjY3LCJ5Ijo2Mjg5NjMzLjg4LCJjaXR5IjoiTGF2YXVyIiwiY29udGV4dCI6IjgxLCBUYXJuLCBPY2NpdGFuaWUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC41MTk0LCJzdHJlZXQiOiJSdWUgZHUgR3LDqHMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbLTEuOTU3OTEzLDQ2LjcwNzI0Nl19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyw6hzIDg1MjcwIFNhaW50LUhpbGFpcmUtZGUtUmlleiIsInNjb3JlIjowLjY3NDQzODgxMTE4ODgxMTIsImlkIjoiODUyMjZfMTI1NSIsIm5hbWUiOiJSdWUgZHUgR3LDqHMiLCJwb3N0Y29kZSI6Ijg1MjcwIiwiY2l0eWNvZGUiOiI4NTIyNiIsIngiOjMyMTQ3Mi44NiwieSI6NjYzNDkwMy43MSwiY2l0eSI6IlNhaW50LUhpbGFpcmUtZGUtUmlleiIsImNvbnRleHQiOiI4NSwgVmVuZMOpZSwgUGF5cyBkZSBsYSBMb2lyZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjQ5NTc1LCJzdHJlZXQiOiJSdWUgZHUgR3LDqHMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbLTEuMDMzMjQ5LDQ4LjExODQxMV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyZXMgNTM0MTAgU2FpbnQtUGllcnJlLWxhLUNvdXIiLCJzY29yZSI6MC42NzQzNzMzNTY2NDMzNTY2LCJpZCI6IjUzMjQ3XzAwMTgiLCJuYW1lIjoiUnVlIGR1IEdyZXMiLCJwb3N0Y29kZSI6IjUzNDEwIiwiY2l0eWNvZGUiOiI1MzI0NyIsIngiOjQwMDAwNC41NSwieSI6Njc4NzQ0NS41MywiY2l0eSI6IlNhaW50LVBpZXJyZS1sYS1Db3VyIiwiY29udGV4dCI6IjUzLCBNYXllbm5lLCBQYXlzIGRlIGxhIExvaXJlIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNDk1MDMsInN0cmVldCI6IlJ1ZSBkdSBHcmVzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMDMxNTIyLDQzLjY2NDY2M119LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyw6lzIDM0NjcwIFNhaW50LUJyw6hzIiwic2NvcmUiOjAuNjcyNDkyNDQ3NTUyNDQ3NSwiaWQiOiIzNDI0NF8wMDI3IiwibmFtZSI6IlJ1ZSBkdSBHcsOpcyIsInBvc3Rjb2RlIjoiMzQ2NzAiLCJjaXR5Y29kZSI6IjM0MjQ0IiwieCI6NzgzMjE4Ljg4LCJ5Ijo2Mjg1NjEyLjg4LCJjaXR5IjoiU2FpbnQtQnLDqHMiLCJjb250ZXh0IjoiMzQsIEjDqXJhdWx0LCBPY2NpdGFuaWUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC40NzQzNCwic3RyZWV0IjoiUnVlIGR1IEdyw6lzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzMuODk5NDI1LDQzLjg2MDE4NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyw6hzIDM0MjcwIENsYXJldCIsInNjb3JlIjowLjY3MTA1MjQ0NzU1MjQ0NzYsImlkIjoiMzQwNzhfMDE1OSIsIm5hbWUiOiJSdWUgZHUgR3LDqHMiLCJwb3N0Y29kZSI6IjM0MjcwIiwiY2l0eWNvZGUiOiIzNDA3OCIsIngiOjc3MjMxNC44NSwieSI6NjMwNzIwOS4wOSwiY2l0eSI6IkNsYXJldCIsImNvbnRleHQiOiIzNCwgSMOpcmF1bHQsIE9jY2l0YW5pZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjQ1ODUsInN0cmVldCI6IlJ1ZSBkdSBHcsOocyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0LjAxNjY4Miw0OS4wMjU0NzFdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlJ1ZSBkdSBHcmVzIDUxNTMwIENob3VpbGx5Iiwic2NvcmUiOjAuNjY5MDgzMzU2NjQzMzU2NiwiaWQiOiI1MTE1M19ieXNiZ2IiLCJuYW1lIjoiUnVlIGR1IEdyZXMiLCJwb3N0Y29kZSI6IjUxNTMwIiwiY2l0eWNvZGUiOiI1MTE1MyIsIngiOjc3NDM1My45MiwieSI6Njg4MTA5OC4wNCwiY2l0eSI6IkNob3VpbGx5IiwiY29udGV4dCI6IjUxLCBNYXJuZSwgR3JhbmQgRXN0IiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNDM2ODQsInN0cmVldCI6IlJ1ZSBkdSBHcmVzIn19XSwiYXR0cmlidXRpb24iOiJCQU4iLCJsaWNlbmNlIjoiRVRBTEFCLTIuMCIsInF1ZXJ5IjoiNzggUnVlIGR1IEdyw6lzIiwibGltaXQiOjEwfQ== - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr%C3%A9s%203 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4362' - Vary: - - Origin - Etag: - - W/"110a-cDKu3ljEy++lztESVOHNYLZfaLE" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzIuNzA3MjMxLDQ4LjQ3NjIxNV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGRlcyBHcmVzIDc3NTkwIEJvaXMtbGUtUm9pIiwic2NvcmUiOjAuNDg1NjE1OTg5MzA0ODEyOCwiaWQiOiI3NzAzN18wNDIwIiwibmFtZSI6IlJ1ZSBkZXMgR3JlcyIsInBvc3Rjb2RlIjoiNzc1OTAiLCJjaXR5Y29kZSI6Ijc3MDM3IiwieCI6Njc4MzYxLjY5LCJ5Ijo2ODE5NTkwLjQxLCJjaXR5IjoiQm9pcy1sZS1Sb2kiLCJjb250ZXh0IjoiNzcsIFNlaW5lLWV0LU1hcm5lLCDDjmxlLWRlLUZyYW5jZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjU3NzA3LCJzdHJlZXQiOiJSdWUgZGVzIEdyZXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMi41NzA1NSw0OC41MjYyMDZdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlJ1ZSBkZXMgR3LDqHMgNzczMTAgQm9pc3Npc2UtbGUtUm9pIiwic2NvcmUiOjAuNDc0OTc3ODA3NDg2NjMxMDQsImlkIjoiNzcwNDBfMDIyOCIsIm5hbWUiOiJSdWUgZGVzIEdyw6hzIiwicG9zdGNvZGUiOiI3NzMxMCIsImNpdHljb2RlIjoiNzcwNDAiLCJ4Ijo2NjgyOTAuMDIsInkiOjY4MjUxOTMuNjEsImNpdHkiOiJCb2lzc2lzZS1sZS1Sb2kiLCJjb250ZXh0IjoiNzcsIFNlaW5lLWV0LU1hcm5lLCDDjmxlLWRlLUZyYW5jZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjQ2MDA1LCJzdHJlZXQiOiJSdWUgZGVzIEdyw6hzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMDc1MzI4LDQ4LjI5Mzg4NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyb3MgUmFpc2luIDEwMDAwIFRyb3llcyIsInNjb3JlIjowLjQyNzA5NzE5MDA4MjY0NDYsImlkIjoiMTAzODdfMjM1MCIsIm5hbWUiOiJSdWUgZHUgR3JvcyBSYWlzaW4iLCJwb3N0Y29kZSI6IjEwMDAwIiwiY2l0eWNvZGUiOiIxMDM4NyIsIngiOjc3OTc1MC4yOCwieSI6Njc5OTgyNi45MiwiY2l0eSI6IlRyb3llcyIsImNvbnRleHQiOiIxMCwgQXViZSwgR3JhbmQgRXN0IiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNjA3MTYsInN0cmVldCI6IlJ1ZSBkdSBHcm9zIFJhaXNpbiJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlswLjA2NDEwOCw0NS42NDM3NV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyb3MgQ2jDqm5lIDE2NzMwIFRyb2lzLVBhbGlzIiwic2NvcmUiOjAuMzcxMjgxNjUyODkyNTYyLCJpZCI6IjE2Mzg4XzAxNjAiLCJuYW1lIjoiUnVlIGR1IEdyb3MgQ2jDqm5lIiwicG9zdGNvZGUiOiIxNjczMCIsImNpdHljb2RlIjoiMTYzODgiLCJ4Ijo0NzEzNjQuNDEsInkiOjY1MDkxNjIuOTYsImNpdHkiOiJUcm9pcy1QYWxpcyIsImNvbnRleHQiOiIxNiwgQ2hhcmVudGUsIE5vdXZlbGxlLUFxdWl0YWluZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjQwMjI4LCJzdHJlZXQiOiJSdWUgZHUgR3JvcyBDaMOqbmUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMS42OTIxODEsNDguNDk0NjgzXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZGVzIDMgTWFyZXMgMjg3MDAgTGUgR3XDqS1kZS1Mb25ncm9pIiwic2NvcmUiOjAuMzQ2NzEwMTI5ODcwMTI5ODQsImlkIjoiMjgxODhfMDA3MiIsIm5hbWUiOiJSdWUgZGVzIDMgTWFyZXMiLCJwb3N0Y29kZSI6IjI4NzAwIiwiY2l0eWNvZGUiOiIyODE4OCIsIngiOjYwMzM3OC40MSwieSI6NjgyMjQwMy4zNywiY2l0eSI6IkxlIEd1w6ktZGUtTG9uZ3JvaSIsImNvbnRleHQiOiIyOCwgRXVyZS1ldC1Mb2lyLCBDZW50cmUtVmFsIGRlIExvaXJlIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuMzg1MjQsInN0cmVldCI6IlJ1ZSBkZXMgMyBNYXJlcyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls1LjA1MjUyNCw0My40MTA2NzddfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IkhsbSBsZSBHcsOocyAzIDEzNTAwIE1hcnRpZ3VlcyIsInNjb3JlIjowLjMzMzU2MzYzNjM2MzYzNjM0LCJ0eXBlIjoibG9jYWxpdHkiLCJpbXBvcnRhbmNlIjowLjUxOTIsImlkIjoiMTMwNTZfQTEyMCIsIm5hbWUiOiJIbG0gbGUgR3LDqHMgMyIsInBvc3Rjb2RlIjoiMTM1MDAiLCJjaXR5Y29kZSI6IjEzMDU2IiwieCI6ODY2MzA4LjgxLCJ5Ijo2MjU5MDAxLjYsImNpdHkiOiJNYXJ0aWd1ZXMiLCJjb250ZXh0IjoiMTMsIEJvdWNoZXMtZHUtUmjDtG5lLCBQcm92ZW5jZS1BbHBlcy1Dw7R0ZSBkJ0F6dXIiLCJsb2NhbGl0eSI6IkhsbSBsZSBHcsOocyAzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzAuODA1MjAzLDQ5Ljg3MzMzMl19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IDMgUmd0IGRlcyBEcmFnb25zIDc2OTgwIFZldWxlcy1sZXMtUm9zZXMiLCJzY29yZSI6MC4zMTg4NDMyNDY3NTMyNDY3NywiaWQiOiI3NjczNV8wMjY1IiwibmFtZSI6IlJ1ZSBkdSAzIFJndCBkZXMgRHJhZ29ucyIsInBvc3Rjb2RlIjoiNzY5ODAiLCJjaXR5Y29kZSI6Ijc2NzM1IiwieCI6NTQyMTIzLjksInkiOjY5NzcxNDguMDgsImNpdHkiOiJWZXVsZXMtbGVzLVJvc2VzIiwiY29udGV4dCI6Ijc2LCBTZWluZS1NYXJpdGltZSwgTm9ybWFuZGllIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuMjkyOTksInN0cmVldCI6IlJ1ZSBkdSAzIFJndCBkZXMgRHJhZ29ucyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0Ljk1MDQ1Myw0NC4yODY3NDNdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlpBIGR1IEdyZCBEZXbDqXMgQWxsZWUgMyAyNjc5MCBUdWxldHRlIiwic2NvcmUiOjAuMjk1NzA5NzQwMjU5NzQwMywiaWQiOiIyNjM1N19CMDU1IiwibmFtZSI6IlpBIGR1IEdyZCBEZXbDqXMgQWxsZWUgMyIsInBvc3Rjb2RlIjoiMjY3OTAiLCJjaXR5Y29kZSI6IjI2MzU3IiwieCI6ODU1NjM1LjY1LCJ5Ijo2MzU2MTEzLjU5LCJjaXR5IjoiVHVsZXR0ZSIsImNvbnRleHQiOiIyNiwgRHLDtG1lLCBBdXZlcmduZS1SaMO0bmUtQWxwZXMiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC4zNTk5NSwic3RyZWV0IjoiWkEgZHUgR3JkIERldsOpcyBBbGxlZSAzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzEuMjMwNjk4LDQ1LjI4ODkxM119LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUm91dGUgZGVzIEdyw6hzIFJvdWdlcyAyNDE2MCBTYWludGUtVHJpZSIsInNjb3JlIjowLjI2NzA2MzMzMzMzMzMzMzMsImlkIjoiMjQ1MDdfeTVpNjYxIiwibmFtZSI6IlJvdXRlIGRlcyBHcsOocyBSb3VnZXMiLCJwb3N0Y29kZSI6IjI0MTYwIiwiY2l0eWNvZGUiOiIyNDUwNyIsIngiOjU2MTMxMC42OCwieSI6NjQ2NzA1OS43NywiY2l0eSI6IlNhaW50ZS1UcmllIiwiY29udGV4dCI6IjI0LCBEb3Jkb2duZSwgTm91dmVsbGUtQXF1aXRhaW5lIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuMjcxMDMsInN0cmVldCI6IlJvdXRlIGRlcyBHcsOocyBSb3VnZXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMC4wMjQ5NDcsNDcuMDYwODE4XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3XDqSBTYWludGUtTWFyaWUgODYxMjAgTGVzIFRyb2lzLU1vdXRpZXJzIiwic2NvcmUiOjAuMjI0MDIzNjM2MzYzNjM2MzcsImlkIjoiODYyNzRfMDcyMCIsIm5hbWUiOiJSdWUgZHUgR3XDqSBTYWludGUtTWFyaWUiLCJwb3N0Y29kZSI6Ijg2MTIwIiwiY2l0eWNvZGUiOiI4NjI3NCIsIngiOjQ3NDI0NC4wOCwieSI6NjY2NjUzOS4zMiwiY2l0eSI6IkxlcyBUcm9pcy1Nb3V0aWVycyIsImNvbnRleHQiOiI4NiwgVmllbm5lLCBOb3V2ZWxsZS1BcXVpdGFpbmUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC4zNjQyNiwic3RyZWV0IjoiUnVlIGR1IEd1w6kgU2FpbnRlLU1hcmllIn19XSwiYXR0cmlidXRpb24iOiJCQU4iLCJsaWNlbmNlIjoiRVRBTEFCLTIuMCIsInF1ZXJ5IjoiNzggUnVlIGR1IEdyw6lzIDMiLCJsaW1pdCI6MTB9 - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr%C3%A9s%20303 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4247' - Vary: - - Origin - Etag: - - W/"1097-sr8xmITTS06AybsOH3rSsirtCrw" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjMwNzQ3LDQzLjc0NjA2NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IEdyw6lzIDMwMzEwIFZlcmfDqHplIiwic2NvcmUiOjAuODY4NTk4MTgxODE4MTgxOCwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzAzNDRfMDA5OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzEwIiwiY2l0eWNvZGUiOiIzMDM0NCIsIngiOjc5OTE0OS4zMywieSI6NjI5NDg4OC4zMSwiY2l0eSI6IlZlcmfDqHplIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjU1NDU4LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNC40NzQ5NDYsNDQuMDk1MjA5XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3LDqHMgMzAzMzAgTGEgQmFzdGlkZS1kJ0VuZ3JhcyIsInNjb3JlIjowLjYyOTM4ODA4NjEyNDQwMTgsImlkIjoiMzAwMzFfMDA0OCIsIm5hbWUiOiJSdWUgZHUgR3LDqHMiLCJwb3N0Y29kZSI6IjMwMzMwIiwiY2l0eWNvZGUiOiIzMDAzMSIsIngiOjgxODA5NS4zNCwieSI6NjMzNDAxNC43MywiY2l0eSI6IkxhIEJhc3RpZGUtZCdFbmdyYXMiLCJjb250ZXh0IjoiMzAsIEdhcmQsIE9jY2l0YW5pZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjI5MTY5LCJzdHJlZXQiOiJSdWUgZHUgR3LDqHMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNC4wOTgwMTYsNDQuMTcxNzI5XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJDaGVtaW4gZHUgR3LDqHMgMzAzNDAgU2FpbnQtSnVsaWVuLWxlcy1Sb3NpZXJzIiwic2NvcmUiOjAuNDExNzk3MjcyNzI3MjcyNzMsImlkIjoiMzAyNzRfMDIzMCIsIm5hbWUiOiJDaGVtaW4gZHUgR3LDqHMiLCJwb3N0Y29kZSI6IjMwMzQwIiwiY2l0eWNvZGUiOiIzMDI3NCIsIngiOjc4Nzc5OS41MywieSI6NjM0MjAyMy44OCwiY2l0eSI6IlNhaW50LUp1bGllbi1sZXMtUm9zaWVycyIsImNvbnRleHQiOiIzMCwgR2FyZCwgT2NjaXRhbmllIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNTY5NzcsInN0cmVldCI6IkNoZW1pbiBkdSBHcsOocyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0LjU1ODU4Miw0My44MzI2MzRdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlJ1ZSBkdSBHcsOocyBkZXMgT2xpdmllcnMgMzAzMDAgSm9ucXVpw6hyZXMtU2FpbnQtVmluY2VudCIsInNjb3JlIjowLjQwMzgyODE4MTgxODE4MTgsImlkIjoiMzAxMzVfMDEyMiIsIm5hbWUiOiJSdWUgZHUgR3LDqHMgZGVzIE9saXZpZXJzIiwicG9zdGNvZGUiOiIzMDMwMCIsImNpdHljb2RlIjoiMzAxMzUiLCJ4Ijo4MjUzNjYuODcsInkiOjYzMDQ5NzMuMTEsImNpdHkiOiJKb25xdWnDqHJlcy1TYWludC1WaW5jZW50IiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC41MDQ2MSwic3RyZWV0IjoiUnVlIGR1IEdyw6hzIGRlcyBPbGl2aWVycyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0LjE0MTI1OCw0NC4xMDc4ODVdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IkNoZW1pbiBkdSBHcsOocyAzMDM0MCBNw6lqYW5uZXMtbMOocy1BbMOocyIsInNjb3JlIjowLjQwMzI1NTQ1NDU0NTQ1NDUzLCJpZCI6IjMwMTY1XzAwNjciLCJuYW1lIjoiQ2hlbWluIGR1IEdyw6hzIiwicG9zdGNvZGUiOiIzMDM0MCIsImNpdHljb2RlIjoiMzAxNjUiLCJ4Ijo3OTEzNTkuNTMsInkiOjYzMzQ5ODAuMzUsImNpdHkiOiJNw6lqYW5uZXMtbMOocy1BbMOocyIsImNvbnRleHQiOiIzMCwgR2FyZCwgT2NjaXRhbmllIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNDc1ODEsInN0cmVldCI6IkNoZW1pbiBkdSBHcsOocyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0Ljc5MjcyNSw0My45NzY1NzVdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IkltcGFzc2UgZHUgR3LDqXMgMzA0MDAgVmlsbGVuZXV2ZS1sw6hzLUF2aWdub24iLCJzY29yZSI6MC40MDE4NTU2NjQzMzU2NjQzLCJpZCI6IjMwMzUxXzA1OTAiLCJuYW1lIjoiSW1wYXNzZSBkdSBHcsOpcyIsInBvc3Rjb2RlIjoiMzA0MDAiLCJjaXR5Y29kZSI6IjMwMzUxIiwieCI6ODQzODM0LjM4LCJ5Ijo2MzIxMzYzLjE2LCJjaXR5IjoiVmlsbGVuZXV2ZS1sw6hzLUF2aWdub24iLCJjb250ZXh0IjoiMzAsIEdhcmQsIE9jY2l0YW5pZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjYxMjcyLCJzdHJlZXQiOiJJbXBhc3NlIGR1IEdyw6lzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMTI1MDMsNDMuOTY2OTg3XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJDaGVtaW4gZHUgR3LDqXMgMzAzNTAgQWlncmVtb250Iiwic2NvcmUiOjAuMzk4MTEyNzI3MjcyNzI3MywiaWQiOiIzMDAwMl8wMDUzIiwibmFtZSI6IkNoZW1pbiBkdSBHcsOpcyIsInBvc3Rjb2RlIjoiMzAzNTAiLCJjaXR5Y29kZSI6IjMwMDAyIiwieCI6NzkwMjgzLjU4LCJ5Ijo2MzE5MzA4LjEzLCJjaXR5IjoiQWlncmVtb250IiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC40MTkyNCwic3RyZWV0IjoiQ2hlbWluIGR1IEdyw6lzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuNTg0NDYxLDQ0LjEwMjIyNV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiQ2hlbWluIGR1IEdyw6hzIDMwMzMwIFRyZXNxdWVzIiwic2NvcmUiOjAuMzk3MjM2MzYzNjM2MzYzNiwiaWQiOiIzMDMzMV8wMDgzIiwibmFtZSI6IkNoZW1pbiBkdSBHcsOocyIsInBvc3Rjb2RlIjoiMzAzMzAiLCJjaXR5Y29kZSI6IjMwMzMxIiwieCI6ODI2ODQ3LjE2LCJ5Ijo2MzM0OTYzLjk2LCJjaXR5IjoiVHJlc3F1ZXMiLCJjb250ZXh0IjoiMzAsIEdhcmQsIE9jY2l0YW5pZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjQwOTYsInN0cmVldCI6IkNoZW1pbiBkdSBHcsOocyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0LjE1Njk3Nyw0NC4wMjg3OTFdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IkNoZW1pbiBkdSBHcsOpcyAzMDM2MCBOZXJzIiwic2NvcmUiOjAuMzk1Nzc5OTk5OTk5OTk5OTcsImlkIjoiMzAxODhfMDI2MCIsIm5hbWUiOiJDaGVtaW4gZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzYwIiwiY2l0eWNvZGUiOiIzMDE4OCIsIngiOjc5Mjc0Ni41MywieSI6NjMyNjIxMS42MywiY2l0eSI6Ik5lcnMiLCJjb250ZXh0IjoiMzAsIEdhcmQsIE9jY2l0YW5pZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjM5MzU4LCJzdHJlZXQiOiJDaGVtaW4gZHUgR3LDqXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNC4xMjY5NjEsNDQuMDA2ODhdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IkNoZW1pbiBkdSBHcsOpcyAzMDM1MCBNYXJ1w6lqb2xzLWzDqHMtR2FyZG9uIiwic2NvcmUiOjAuMzg5MjgxODE4MTgxODE4MiwiaWQiOiIzMDE2MF8wMDQ2IiwibmFtZSI6IkNoZW1pbiBkdSBHcsOpcyIsInBvc3Rjb2RlIjoiMzAzNTAiLCJjaXR5Y29kZSI6IjMwMTYwIiwieCI6NzkwMzc1LjI3LCJ5Ijo2MzIzNzQyLjUyLCJjaXR5IjoiTWFydcOpam9scy1sw6hzLUdhcmRvbiIsImNvbnRleHQiOiIzMCwgR2FyZCwgT2NjaXRhbmllIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuMzIyMSwic3RyZWV0IjoiQ2hlbWluIGR1IEdyw6lzIn19XSwiYXR0cmlidXRpb24iOiJCQU4iLCJsaWNlbmNlIjoiRVRBTEFCLTIuMCIsInF1ZXJ5IjoiNzggUnVlIGR1IEdyw6lzIDMwMyIsImxpbWl0IjoxMH0= - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr%C3%A9s%2030310 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '561' - Vary: - - Origin - Etag: - - W/"231-jbqSGt6/x4K0FWGGWwu4WBzdAD8" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjMwNzQ3LDQzLjc0NjA2NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IEdyw6lzIDMwMzEwIFZlcmfDqHplIiwic2NvcmUiOjAuOTU5NTA3MjcyNzI3MjcyNywiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzAzNDRfMDA5OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzEwIiwiY2l0eWNvZGUiOiIzMDM0NCIsIngiOjc5OTE0OS4zMywieSI6NjI5NDg4OC4zMSwiY2l0eSI6IlZlcmfDqHplIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjU1NDU4LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX1dLCJhdHRyaWJ1dGlvbiI6IkJBTiIsImxpY2VuY2UiOiJFVEFMQUItMi4wIiwicXVlcnkiOiI3OCBSdWUgZHUgR3LDqXMgMzAzMTAiLCJsaW1pdCI6MTB9 - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr%C3%A9s%2030310%20V - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4141' - Vary: - - Origin - Etag: - - W/"102d-/V1fRUVlD/3rJBcID+VimJZ4k6w" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjMwNzQ3LDQzLjc0NjA2NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IEdyw6lzIDMwMzEwIFZlcmfDqHplIiwic2NvcmUiOjAuODY4NTk4MTgxODE4MTgxOCwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzAzNDRfMDA5OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzEwIiwiY2l0eWNvZGUiOiIzMDM0NCIsIngiOjc5OTE0OS4zMywieSI6NjI5NDg4OC4zMSwiY2l0eSI6IlZlcmfDqHplIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjU1NDU4LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNC41NTQzMzQsNDMuOTcxNTI1XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3LDqXMgMzAyMTAgQ2FzdGlsbG9uLWR1LUdhcmQiLCJzY29yZSI6MC41MzQxMDgyNjA4Njk1NjUyLCJpZCI6IjMwMDczXzAwNjkiLCJuYW1lIjoiUnVlIGR1IEdyw6lzIiwicG9zdGNvZGUiOiIzMDIxMCIsImNpdHljb2RlIjoiMzAwNzMiLCJ4Ijo4MjQ3MjEuNDQsInkiOjYzMjAzOTYuNzcsImNpdHkiOiJDYXN0aWxsb24tZHUtR2FyZCIsImNvbnRleHQiOiIzMCwgR2FyZCwgT2NjaXRhbmllIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuMzk2OTMsInN0cmVldCI6IlJ1ZSBkdSBHcsOpcyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0LjQ3NDk0Niw0NC4wOTUyMDldfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlJ1ZSBkdSBHcsOocyAzMDMzMCBMYSBCYXN0aWRlLWQnRW5ncmFzIiwic2NvcmUiOjAuNTI0NTQwOTg4MTQyMjkyNiwiaWQiOiIzMDAzMV8wMDQ4IiwibmFtZSI6IlJ1ZSBkdSBHcsOocyIsInBvc3Rjb2RlIjoiMzAzMzAiLCJjaXR5Y29kZSI6IjMwMDMxIiwieCI6ODE4MDk1LjM0LCJ5Ijo2MzM0MDE0LjczLCJjaXR5IjoiTGEgQmFzdGlkZS1kJ0VuZ3JhcyIsImNvbnRleHQiOiIzMCwgR2FyZCwgT2NjaXRhbmllIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuMjkxNjksInN0cmVldCI6IlJ1ZSBkdSBHcsOocyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlsxLjAwNDM1OCw0OS40NTk0MjNdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBkdSBHcsOpIDc2MzgwIE1vbnRpZ255Iiwic2NvcmUiOjAuNTA5NzI5NzQwMjU5NzQwMiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNzY0NDZfMDIwMF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqSIsInBvc3Rjb2RlIjoiNzYzODAiLCJjaXR5Y29kZSI6Ijc2NDQ2IiwieCI6NTU1MjgyLjI3LCJ5Ijo2OTMwNzE5LjU0LCJjaXR5IjoiTW9udGlnbnkiLCJjb250ZXh0IjoiNzYsIFNlaW5lLU1hcml0aW1lLCBOb3JtYW5kaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjQ2NDE3LCJzdHJlZXQiOiJSdWUgZHUgR3LDqSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls0Ljc3MDYxOSw0My45NTgxOTRdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlJ1ZSBkdSBHcsOocyAzMDEzMyBMZXMgQW5nbGVzIiwic2NvcmUiOjAuNDkzMTU5MDkwOTA5MDkwOSwiaWQiOiIzMDAxMV9rZXR5dmEiLCJuYW1lIjoiUnVlIGR1IEdyw6hzIiwicG9zdGNvZGUiOiIzMDEzMyIsImNpdHljb2RlIjoiMzAwMTEiLCJ4Ijo4NDIxMDYuODYsInkiOjYzMTkyODEuMjYsImNpdHkiOiJMZXMgQW5nbGVzIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC41NDk3NSwic3RyZWV0IjoiUnVlIGR1IEdyw6hzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjk1ODQ2LDQzLjkzMTQ1OV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEdyw6lzIDMwMTkwIERpb25zIiwic2NvcmUiOjAuNDgxOTQ5MDkwOTA5MDkwOCwiaWQiOiIzMDEwMl8wMDg3IiwibmFtZSI6IlJ1ZSBkdSBHcsOpcyIsInBvc3Rjb2RlIjoiMzAxOTAiLCJjaXR5Y29kZSI6IjMwMTAyIiwieCI6ODA0MDU1LjE5LCJ5Ijo2MzE1NTcxLjA4LCJjaXR5IjoiRGlvbnMiLCJjb250ZXh0IjoiMzAsIEdhcmQsIE9jY2l0YW5pZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjQyNjQ0LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMy45NDA3NjMsNDMuODcxNzExXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3LDqHMgMzAyNjAgQ29yY29ubmUiLCJzY29yZSI6MC40NzA1MDU0NTQ1NDU0NTQ1LCJ0eXBlIjoibG9jYWxpdHkiLCJpbXBvcnRhbmNlIjowLjMwMDU2LCJpZCI6IjMwMDk1XzAwNjAiLCJuYW1lIjoiUnVlIGR1IEdyw6hzIiwicG9zdGNvZGUiOiIzMDI2MCIsImNpdHljb2RlIjoiMzAwOTUiLCJ4Ijo3NzU2MjMuMDYsInkiOjYzMDg1MjguNjIsImNpdHkiOiJDb3Jjb25uZSIsImNvbnRleHQiOiIzMCwgR2FyZCwgT2NjaXRhbmllIiwibG9jYWxpdHkiOiJSdWUgZHUgR3LDqHMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMi40OTcyNDgsNDYuNjAxNzM0XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3JlcyBSb3NlIDE4MzYwIFNhdWx6YWlzLWxlLVBvdGllciIsInNjb3JlIjowLjQ1MTg1MDkwOTA5MDkwOTEsImlkIjoiMTgyNDVfMDAxOCIsIm5hbWUiOiJSdWUgZHUgR3JlcyBSb3NlIiwicG9zdGNvZGUiOiIxODM2MCIsImNpdHljb2RlIjoiMTgyNDUiLCJ4Ijo2NjE1MTYuMjcsInkiOjY2MTE0MjAuNzUsImNpdHkiOiJTYXVsemFpcy1sZS1Qb3RpZXIiLCJjb250ZXh0IjoiMTgsIENoZXIsIENlbnRyZS1WYWwgZGUgTG9pcmUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC40NzAzNiwic3RyZWV0IjoiUnVlIGR1IEdyZXMgUm9zZSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlstNTIuNDc5NDMzLDQuOTg5OTkyXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR1LDiFMgUk9VR0UgOTczNTUgTWFjb3VyaWEiLCJzY29yZSI6MC40NDgxNDYzNjM2MzYzNjM1NiwiaWQiOiI5NzMwNV8wNzU4IiwibmFtZSI6IlJ1ZSBkdSBHUsOIUyBST1VHRSIsInBvc3Rjb2RlIjoiOTczNTUiLCJjaXR5Y29kZSI6Ijk3MzA1IiwieCI6MzM1OTc3Ljg2LCJ5Ijo1NTE3NDIuMjUsImNpdHkiOiJNYWNvdXJpYSIsImNvbnRleHQiOiI5NzMsIEd1eWFuZSIsInR5cGUiOiJzdHJlZXQiLCJpbXBvcnRhbmNlIjowLjYwOTYxLCJzdHJlZXQiOiJSdWUgZHUgR1LDiFMgUk9VR0UifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMi40MzIxNDUsNDYuNTQwNzk2XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR3JlcyBSb3NlIDE4MzYwIFZlc2R1biIsInNjb3JlIjowLjQ0NDQ1MzYzNjM2MzYzNjMzLCJpZCI6IjE4Mjc4XzAwMzQiLCJuYW1lIjoiUnVlIGR1IEdyZXMgUm9zZSIsInBvc3Rjb2RlIjoiMTgzNjAiLCJjaXR5Y29kZSI6IjE4Mjc4IiwieCI6NjU2NDg0LjMsInkiOjY2MDQ2ODcuMTEsImNpdHkiOiJWZXNkdW4iLCJjb250ZXh0IjoiMTgsIENoZXIsIENlbnRyZS1WYWwgZGUgTG9pcmUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC4zODg5OSwic3RyZWV0IjoiUnVlIGR1IEdyZXMgUm9zZSJ9fV0sImF0dHJpYnV0aW9uIjoiQkFOIiwibGljZW5jZSI6IkVUQUxBQi0yLjAiLCJxdWVyeSI6Ijc4IFJ1ZSBkdSBHcsOpcyAzMDMxMCBWIiwibGltaXQiOjEwfQ== - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT + W3sibm9tIjoiQXBwaWxseSIsImNvZGUiOiI2MDAyMSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDAyMTYiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo1MTd9LHsibm9tIjoiQmFixZN1ZiIsImNvZGUiOiI2MDAzNyIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDAzNjQiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo1MTF9LHsibm9tIjoiQmVhdXJhaW5zLWzDqHMtTm95b24iLCJjb2RlIjoiNjAwNTUiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAwNTQ3IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MzM4fSx7Im5vbSI6IkLDqWjDqXJpY291cnQiLCJjb2RlIjoiNjAwNTkiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAwNTg4IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MTk4fSx7Im5vbSI6IkJyw6l0aWdueSIsImNvZGUiOiI2MDEwNSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDEwNTciLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo0MzF9LHsibm9tIjoiQnVzc3kiLCJjb2RlIjoiNjAxMTciLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAxMTcyIiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MzA0fSx7Im5vbSI6IkNhaXNuZXMiLCJjb2RlIjoiNjAxMTgiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAxMTgwIiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NTA3fSx7Im5vbSI6IkNyaXNvbGxlcyIsImNvZGUiOiI2MDE4MSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDE4MDAiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo5MTR9LHsibm9tIjoiQ3V0cyIsImNvZGUiOiI2MDE4OSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDE4ODMiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo5NjF9LHsibm9tIjoiR2VudnJ5IiwiY29kZSI6IjYwMjcwIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwMjY3NSIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjMzMn0seyJub20iOiJHcmFuZHLDuyIsImNvZGUiOiI2MDI4NyIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDI4NDAiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjozNjR9LHsibm9tIjoiTGFyYnJveWUiLCJjb2RlIjoiNjAzNDgiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDAzNDY3IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NTE2fSx7Im5vbSI6Ik1vbmRlc2NvdXJ0IiwiY29kZSI6IjYwNDEwIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNDA2OSIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjI0OH0seyJub20iOiJNb3JsaW5jb3VydCIsImNvZGUiOiI2MDQzMSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDQyNjciLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo1MjZ9LHsibm9tIjoiTmFtcGNlbCIsImNvZGUiOiI2MDQ0NSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDQ0MDgiLCJjb2RlRXBjaSI6IjI0NjAwMDc0OSIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjozMDR9LHsibm9tIjoiTm95b24iLCJjb2RlIjoiNjA0NzEiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA0NjU1IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6MTI5ODd9LHsibm9tIjoiUGFzc2VsIiwiY29kZSI6IjYwNDg4IiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNDgyMCIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjI3MH0seyJub20iOiJQb250LWwnw4l2w6pxdWUiLCJjb2RlIjoiNjA1MDYiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA1MDA5IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6Njc1fSx7Im5vbSI6IlBvbnRvaXNlLWzDqHMtTm95b24iLCJjb2RlIjoiNjA1MDciLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA1MDE3IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NDUwfSx7Im5vbSI6IlBvcnF1w6lyaWNvdXJ0IiwiY29kZSI6IjYwNTExIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNTA1OCIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjQxMH0seyJub20iOiJTYWxlbmN5IiwiY29kZSI6IjYwNjAzIiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNTk2NyIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjg5Mn0seyJub20iOiJTZW1waWdueSIsImNvZGUiOiI2MDYxMCIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDYwMTUiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjo3NjB9LHsibm9tIjoiU2VybWFpemUiLCJjb2RlIjoiNjA2MTciLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA2MDgwIiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6Mjc0fSx7Im5vbSI6IlN1em95IiwiY29kZSI6IjYwNjI1IiwiY29kZURlcGFydGVtZW50IjoiNjAiLCJzaXJlbiI6IjIxNjAwNjE2MyIsImNvZGVFcGNpIjoiMjQ2MDAwNzU2IiwiY29kZVJlZ2lvbiI6IjMyIiwiY29kZXNQb3N0YXV4IjpbIjYwNDAwIl0sInBvcHVsYXRpb24iOjU1NX0seyJub20iOiJWYXJlc25lcyIsImNvZGUiOiI2MDY1NSIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDY0NjAiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjozNjN9LHsibm9tIjoiVmF1Y2hlbGxlcyIsImNvZGUiOiI2MDY1NyIsImNvZGVEZXBhcnRlbWVudCI6IjYwIiwic2lyZW4iOiIyMTYwMDY0ODYiLCJjb2RlRXBjaSI6IjI0NjAwMDc1NiIsImNvZGVSZWdpb24iOiIzMiIsImNvZGVzUG9zdGF1eCI6WyI2MDQwMCJdLCJwb3B1bGF0aW9uIjoyNDN9LHsibm9tIjoiVmlsbGUiLCJjb2RlIjoiNjA2NzYiLCJjb2RlRGVwYXJ0ZW1lbnQiOiI2MCIsInNpcmVuIjoiMjE2MDA2Njc2IiwiY29kZUVwY2kiOiIyNDYwMDA3NTYiLCJjb2RlUmVnaW9uIjoiMzIiLCJjb2Rlc1Bvc3RhdXgiOlsiNjA0MDAiXSwicG9wdWxhdGlvbiI6NzUwfV0= + recorded_at: Tue, 02 Jul 2024 13:53:51 GMT - request: method: get uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr%C3%A9s%2030310%20Verg%C3%A8 @@ -423,9 +55,9 @@ http_interactions: message: '' headers: Server: - - nginx/1.25.3 + - nginx/1.25.5 Date: - - Mon, 04 Mar 2024 09:41:12 GMT + - Tue, 02 Jul 2024 13:53:52 GMT Content-Type: - application/json; charset=utf-8 Content-Length: @@ -433,19 +65,19 @@ http_interactions: Vary: - Origin Etag: - - W/"238-PKhw2BRdtojt7kPtuUxNTXlI3i8" + - W/"238-Y47qALrriF7wD0KtsLJdCgH+fmc" X-Cache-Status: - - MISS + - HIT Access-Control-Allow-Headers: - X-Requested-With,Content-Type body: encoding: ASCII-8BIT string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjMwNzQ3LDQzLjc0NjA2NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IEdyw6lzIDMwMzEwIFZlcmfDqHplIiwic2NvcmUiOjAuODY4NTk4MTgxODE4MTgxOCwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzAzNDRfMDA5OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzEwIiwiY2l0eWNvZGUiOiIzMDM0NCIsIngiOjc5OTE0OS4zMywieSI6NjI5NDg4OC4zMSwiY2l0eSI6IlZlcmfDqHplIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjU1NDU4LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX1dLCJhdHRyaWJ1dGlvbiI6IkJBTiIsImxpY2VuY2UiOiJFVEFMQUItMi4wIiwicXVlcnkiOiI3OCBSdWUgZHUgR3LDqXMgMzAzMTAgVmVyZ8OoIiwibGltaXQiOjEwfQ== - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT + eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjMwNzQ3LDQzLjc0NjA2NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IEdyw6lzIDMwMzEwIFZlcmfDqHplIiwic2NvcmUiOjAuODY4NzI2MzYzNjM2MzYzNiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzAzNDRfMDA5OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzEwIiwiY2l0eWNvZGUiOiIzMDM0NCIsIngiOjc5OTE0OS4zMywieSI6NjI5NDg4OC4zMSwiY2l0eSI6IlZlcmfDqHplIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjU1NTk5LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX1dLCJhdHRyaWJ1dGlvbiI6IkJBTiIsImxpY2VuY2UiOiJFVEFMQUItMi4wIiwicXVlcnkiOiI3OCBSdWUgZHUgR3LDqXMgMzAzMTAgVmVyZ8OoIiwibGltaXQiOjEwfQ== + recorded_at: Tue, 02 Jul 2024 13:53:52 GMT - request: method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr%C3%A9s%2030310%20Ver + uri: https://data.education.gouv.fr/api/records/1.0/search?dataset=fr-en-annuaire-education&q=Moulin&rows=5 body: encoding: US-ASCII string: '' @@ -460,98 +92,175 @@ http_interactions: message: '' headers: Server: - - nginx/1.25.3 + - openresty Date: - - Mon, 04 Mar 2024 09:41:12 GMT + - Tue, 02 Jul 2024 13:54:04 GMT Content-Type: - application/json; charset=utf-8 Content-Length: - - '565' + - '10143' + X-Ratelimit-Remaining: + - '4927' + X-Ratelimit-Limit: + - '5000' + X-Ratelimit-Reset: + - '2024-07-03 00:00:00+00:00' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate Vary: - - Origin - Etag: - - W/"235-DA17b0DXZrDgQoRCces7jzDyzFY" - X-Cache-Status: - - MISS + - Accept-Language, Cookie, Host + Content-Language: + - fr-fr + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Methods: + - POST, GET, OPTIONS + Access-Control-Max-Age: + - '1000' Access-Control-Allow-Headers: - - X-Requested-With,Content-Type + - Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type, + ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept + Access-Control-Expose-Headers: + - ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit, + X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit, + X-RateLimit-dataset-Reset + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Referrer-Policy: + - strict-origin-when-cross-origin + Permissions-Policy: + - midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=() + Content-Security-Policy: + - upgrade-insecure-requests; + X-Ua-Compatible: + - IE=edge body: encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzQuMjMwNzQ3LDQzLjc0NjA2NF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IEdyw6lzIDMwMzEwIFZlcmfDqHplIiwic2NvcmUiOjAuODY4NTk4MTgxODE4MTgxOCwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzAzNDRfMDA5OF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgR3LDqXMiLCJwb3N0Y29kZSI6IjMwMzEwIiwiY2l0eWNvZGUiOiIzMDM0NCIsIngiOjc5OTE0OS4zMywieSI6NjI5NDg4OC4zMSwiY2l0eSI6IlZlcmfDqHplIiwiY29udGV4dCI6IjMwLCBHYXJkLCBPY2NpdGFuaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjU1NDU4LCJzdHJlZXQiOiJSdWUgZHUgR3LDqXMifX1dLCJhdHRyaWJ1dGlvbiI6IkJBTiIsImxpY2VuY2UiOiJFVEFMQUItMi4wIiwicXVlcnkiOiI3OCBSdWUgZHUgR3LDqXMgMzAzMTAgVmVyIiwibGltaXQiOjEwfQ== - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:12 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4416' - Vary: - - Origin - Etag: - - W/"1140-7druqEPKyu54Y61r7AheVXwHMF0" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6Wy0wLjU4NDA5Myw0NC44MzEwNDNdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IFJ1ZSBkdSBUb25kdSAzMzAwMCBCb3JkZWF1eCIsInNjb3JlIjowLjg5NDYxNzI3MjcyNzI3MjYsImhvdXNlbnVtYmVyIjoiNzgiLCJpZCI6IjMzMDYzXzg5NzVfMDAwNzgiLCJuYW1lIjoiNzggUnVlIGR1IFRvbmR1IiwicG9zdGNvZGUiOiIzMzAwMCIsImNpdHljb2RlIjoiMzMwNjMiLCJ4Ijo0MTY4MjEuMiwieSI6NjQyMTA4MS4xNSwiY2l0eSI6IkJvcmRlYXV4IiwiY29udGV4dCI6IjMzLCBHaXJvbmRlLCBOb3V2ZWxsZS1BcXVpdGFpbmUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjg0MDc5LCJzdHJlZXQiOiJSdWUgZHUgVG9uZHUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMS40NDMzODMsNDMuNTc4ODYzXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZHUgRsOpcsOpdHJhIDMxNDAwIFRvdWxvdXNlIiwic2NvcmUiOjAuODkyNTYxODE4MTgxODE4MiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMzE1NTVfMzI1Nl8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgRsOpcsOpdHJhIiwicG9zdGNvZGUiOiIzMTQwMCIsImNpdHljb2RlIjoiMzE1NTUiLCJ4Ijo1NzQyMzUuMjUsInkiOjYyNzY3NzMuMjgsImNpdHkiOiJUb3Vsb3VzZSIsImNvbnRleHQiOiIzMSwgSGF1dGUtR2Fyb25uZSwgT2NjaXRhbmllIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44MTgxOCwic3RyZWV0IjoiUnVlIGR1IEbDqXLDqXRyYSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOlstMS41MjM1OTcsNDcuMjM2NTk0XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZHUgQ3JvaXNzYW50IDQ0MzAwIE5hbnRlcyIsInNjb3JlIjowLjg5MjM0NTQ1NDU0NTQ1NDQsImhvdXNlbnVtYmVyIjoiNzgiLCJpZCI6IjQ0MTA5XzIzMTJfMDAwNzgiLCJuYW1lIjoiNzggUnVlIGR1IENyb2lzc2FudCIsInBvc3Rjb2RlIjoiNDQzMDAiLCJjaXR5Y29kZSI6IjQ0MTA5IiwieCI6MzU3OTYwLjM3LCJ5Ijo2NjkxNjEwLjMxLCJjaXR5IjoiTmFudGVzIiwiY29udGV4dCI6IjQ0LCBMb2lyZS1BdGxhbnRpcXVlLCBQYXlzIGRlIGxhIExvaXJlIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44MTU4LCJzdHJlZXQiOiJSdWUgZHUgQ3JvaXNzYW50In19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzIuMzczOTMxLDQ4LjgzMTM5OV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IENoZXZhbGVyZXQgNzUwMTMgUGFyaXMiLCJzY29yZSI6MC44OTIyMiwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNzUxMTNfMTk5MF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgQ2hldmFsZXJldCIsInBvc3Rjb2RlIjoiNzUwMTMiLCJjaXR5Y29kZSI6Ijc1MTEzIiwieCI6NjU0MDQxLjI0LCJ5Ijo2ODU5MjIwLjI5LCJjaXR5IjoiUGFyaXMiLCJkaXN0cmljdCI6IlBhcmlzIDEzZSBBcnJvbmRpc3NlbWVudCIsImNvbnRleHQiOiI3NSwgUGFyaXMsIMOObGUtZGUtRnJhbmNlIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44MTQ0Miwic3RyZWV0IjoiUnVlIGR1IENoZXZhbGVyZXQifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNC4wNDAzMzMsNDkuMjQ4NDUzXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBydWUgZHUgQmFyYsOidHJlIDUxMTAwIFJlaW1zIiwic2NvcmUiOjAuODkyMjEzNjM2MzYzNjM2NCwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNTE0NTRfMDYwMF8wMDA3OCIsIm5hbWUiOiI3OCBydWUgZHUgQmFyYsOidHJlIiwicG9zdGNvZGUiOiI1MTEwMCIsImNpdHljb2RlIjoiNTE0NTQiLCJ4Ijo3NzU3NTYuNzYsInkiOjY5MDU5MTkuNDksImNpdHkiOiJSZWltcyIsImNvbnRleHQiOiI1MSwgTWFybmUsIEdyYW5kIEVzdCIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuODE0MzUsInN0cmVldCI6InJ1ZSBkdSBCYXJiw6J0cmUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMi4yOTE2NjMsNDguODQ3NDU4XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZHUgVGjDqcOidHJlIDc1MDE1IFBhcmlzIiwic2NvcmUiOjAuODkyMTAwOTA5MDkwOTA5MSwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNzUxMTVfOTIzMF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgVGjDqcOidHJlIiwicG9zdGNvZGUiOiI3NTAxNSIsImNpdHljb2RlIjoiNzUxMTUiLCJ4Ijo2NDgwMTguMjQsInkiOjY4NjEwNTYuOSwiY2l0eSI6IlBhcmlzIiwiZGlzdHJpY3QiOiJQYXJpcyAxNWUgQXJyb25kaXNzZW1lbnQiLCJjb250ZXh0IjoiNzUsIFBhcmlzLCDDjmxlLWRlLUZyYW5jZSIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuODEzMTEsInN0cmVldCI6IlJ1ZSBkdSBUaMOpw6J0cmUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMy4xNDE0OTQsNTAuNzIzNDAyXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZHUgQ2xpbnF1ZXQgNTkyMDAgVG91cmNvaW5nIiwic2NvcmUiOjAuODkxNzg1NDU0NTQ1NDU0NSwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNTk1OTlfMTMzMF8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgQ2xpbnF1ZXQiLCJwb3N0Y29kZSI6IjU5MjAwIiwiY2l0eWNvZGUiOiI1OTU5OSIsIngiOjcxMDAwOS41OSwieSI6NzA2OTY0MS42NSwiY2l0eSI6IlRvdXJjb2luZyIsImNvbnRleHQiOiI1OSwgTm9yZCwgSGF1dHMtZGUtRnJhbmNlIiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC44MDk2NCwic3RyZWV0IjoiUnVlIGR1IENsaW5xdWV0In19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzMuMDkwMjkxLDUwLjY0ODUxOF19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IEJvaXMgNTk4MDAgTGlsbGUiLCJzY29yZSI6MC4zODkyNzUzMTQ2ODUzMTQ3LCJpZCI6IjU5MzUwXzA5MzQiLCJuYW1lIjoiUnVlIGR1IEJvaXMiLCJwb3N0Y29kZSI6IjU5ODAwIiwiY2l0eWNvZGUiOiI1OTM1MCIsIm9sZGNpdHljb2RlIjoiNTkzNTAiLCJ4Ijo3MDYzOTYuOTMsInkiOjcwNjEyOTEuMzksImNpdHkiOiJMaWxsZSIsIm9sZGNpdHkiOiJMaWxsZSIsImNvbnRleHQiOiI1OSwgTm9yZCwgSGF1dHMtZGUtRnJhbmNlIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuODIwNDksInN0cmVldCI6IlJ1ZSBkdSBCb2lzIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzEuNDQyMzk5LDQzLjYwNjM4OV19LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiUnVlIGR1IFRhdXIgMzEwMDAgVG91bG91c2UiLCJzY29yZSI6MC4zODg2OTYyMjM3NzYyMjM3NiwiaWQiOiIzMTU1NV84NDA4IiwibmFtZSI6IlJ1ZSBkdSBUYXVyIiwicG9zdGNvZGUiOiIzMTAwMCIsImNpdHljb2RlIjoiMzE1NTUiLCJ4Ijo1NzQyMTYuMSwieSI6NjI3OTgzMy41MiwiY2l0eSI6IlRvdWxvdXNlIiwiY29udGV4dCI6IjMxLCBIYXV0ZS1HYXJvbm5lLCBPY2NpdGFuaWUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC44MTQxMiwic3RyZWV0IjoiUnVlIGR1IFRhdXIifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMS40MTc5NDMsNDMuNTc4MjA3XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgQ2FnaXJlIDMxMTAwIFRvdWxvdXNlIiwic2NvcmUiOjAuMzQ2NzM4MTgxODE4MTgxOCwiaWQiOiIzMTU1NV8xNDk2IiwibmFtZSI6IlJ1ZSBkdSBDYWdpcmUiLCJwb3N0Y29kZSI6IjMxMTAwIiwiY2l0eWNvZGUiOiIzMTU1NSIsIngiOjU3MjE3OC42NywieSI6NjI3Njc0MS4yNCwiY2l0eSI6IlRvdWxvdXNlIiwiY29udGV4dCI6IjMxLCBIYXV0ZS1HYXJvbm5lLCBPY2NpdGFuaWUiLCJ0eXBlIjoic3RyZWV0IiwiaW1wb3J0YW5jZSI6MC44MTQxMiwic3RyZWV0IjoiUnVlIGR1IENhZ2lyZSJ9fV0sImF0dHJpYnV0aW9uIjoiQkFOIiwibGljZW5jZSI6IkVUQUxBQi0yLjAiLCJxdWVyeSI6Ijc4IFJ1ZSBkdSAiLCJsaW1pdCI6MTB9 - recorded_at: Mon, 04 Mar 2024 09:41:12 GMT -- request: - method: get - uri: https://api-adresse.data.gouv.fr/search?limit=10&q=78%20Rue%20du%20Gr - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - demarches-simplifiees.fr - Expect: - - '' - response: - status: - code: 200 - message: '' - headers: - Server: - - nginx/1.25.3 - Date: - - Mon, 04 Mar 2024 09:41:13 GMT - Content-Type: - - application/json; charset=utf-8 - Content-Length: - - '4411' - Vary: - - Origin - Etag: - - W/"113b-Bi9ByBNLSvhokhk7dJmhHV5RY/U" - X-Cache-Status: - - MISS - Access-Control-Allow-Headers: - - X-Requested-With,Content-Type - body: - encoding: ASCII-8BIT - string: !binary |- - eyJ0eXBlIjoiRmVhdHVyZUNvbGxlY3Rpb24iLCJ2ZXJzaW9uIjoiZHJhZnQiLCJmZWF0dXJlcyI6W3sidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzUuNjM3OTgyLDQzLjYzNzE5XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgR3JhbmRlIDEzNDkwIEpvdXF1ZXMiLCJzY29yZSI6MC40OTMzNjk5OTk5OTk5OTk5LCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIxMzA0OF8zMDUwXzAwMDc4IiwibmFtZSI6Ijc4IFJ1ZSBHcmFuZGUiLCJwb3N0Y29kZSI6IjEzNDkwIiwiY2l0eWNvZGUiOiIxMzA0OCIsIngiOjkxMjg4OS44LCJ5Ijo2Mjg1NTcyLjUxLCJjaXR5IjoiSm91cXVlcyIsImNvbnRleHQiOiIxMywgQm91Y2hlcy1kdS1SaMO0bmUsIFByb3ZlbmNlLUFscGVzLUPDtHRlIGQnQXp1ciIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuNjI3MDcsInN0cmVldCI6IlJ1ZSBHcmFuZGUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMy41NTU4ODIsNDguNTkyNzQxXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBSdWUgZHUgUGVycmV5IDEwMzcwIFZpbGxlbmF1eGUtbGEtR3JhbmRlIiwic2NvcmUiOjAuNDg0NzA1OTg5MzA0ODEyOCwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiMTA0MjBfMDY0MV8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgUGVycmV5IiwicG9zdGNvZGUiOiIxMDM3MCIsImNpdHljb2RlIjoiMTA0MjAiLCJ4Ijo3NDA5OTMuMzMsInkiOjY4MzI2NDguMTUsImNpdHkiOiJWaWxsZW5hdXhlLWxhLUdyYW5kZSIsImNvbnRleHQiOiIxMCwgQXViZSwgR3JhbmQgRXN0IiwidHlwZSI6ImhvdXNlbnVtYmVyIiwiaW1wb3J0YW5jZSI6MC41NjcwNiwic3RyZWV0IjoiUnVlIGR1IFBlcnJleSJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls1LjU1MDIwNiw0Ni4xMzUxMDJdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IEdyYW5kZSBSdWUgMDE0MzAgU2FpbnQtTWFydGluLWR1LUZyw6puZSIsInNjb3JlIjowLjQ4NDA2MzYzNjM2MzYzNjMsImhvdXNlbnVtYmVyIjoiNzgiLCJpZCI6IjAxMzczXzAxNTBfMDAwNzgiLCJuYW1lIjoiNzggR3JhbmRlIFJ1ZSIsInBvc3Rjb2RlIjoiMDE0MzAiLCJjaXR5Y29kZSI6IjAxMzczIiwieCI6ODk2ODQ5LjE5LCJ5Ijo2NTYyNjU2LjA2LCJjaXR5IjoiU2FpbnQtTWFydGluLWR1LUZyw6puZSIsImNvbnRleHQiOiIwMSwgQWluLCBBdXZlcmduZS1SaMO0bmUtQWxwZXMiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjUyNDcsInN0cmVldCI6IkdyYW5kZSBSdWUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNS45MDE3NjEsNDYuNTk2Mjg4XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiI3OCBHcmFuZGUgUnVlIDM5MTUwIExhIENoYXV4LWR1LURvbWJpZWYiLCJzY29yZSI6MC40Nzk3NjE4MTgxODE4MTgyLCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIzOTEzMV8wMDI1XzAwMDc4IiwibmFtZSI6Ijc4IEdyYW5kZSBSdWUiLCJwb3N0Y29kZSI6IjM5MTUwIiwiY2l0eWNvZGUiOiIzOTEzMSIsIngiOjkyMjA5Mi4zNywieSI6NjYxNDc3NC42NywiY2l0eSI6IkxhIENoYXV4LWR1LURvbWJpZWYiLCJjb250ZXh0IjoiMzksIEp1cmEsIEJvdXJnb2duZS1GcmFuY2hlLUNvbXTDqSIsInR5cGUiOiJob3VzZW51bWJlciIsImltcG9ydGFuY2UiOjAuNDc3MzgsInN0cmVldCI6IkdyYW5kZSBSdWUifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbNS45ODUyOCw0Ni42MTc5MzddfSwicHJvcGVydGllcyI6eyJsYWJlbCI6Ijc4IEdyYW5kZSBSdWUgMzkxNTAgRm9ydC1kdS1QbGFzbmUiLCJzY29yZSI6MC40Nzc1ODcyNzI3MjcyNzI3LCJob3VzZW51bWJlciI6Ijc4IiwiaWQiOiIzOTIzMl8wMDIwXzAwMDc4IiwibmFtZSI6Ijc4IEdyYW5kZSBSdWUiLCJwb3N0Y29kZSI6IjM5MTUwIiwiY2l0eWNvZGUiOiIzOTIzMiIsIngiOjkyODM5MC43OSwieSI6NjYxNzQxNS41MywiY2l0eSI6IkZvcnQtZHUtUGxhc25lIiwiY29udGV4dCI6IjM5LCBKdXJhLCBCb3VyZ29nbmUtRnJhbmNoZS1Db210w6kiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjQ1MzQ2LCJzdHJlZXQiOiJHcmFuZGUgUnVlIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzEuMjI2NzQ4LDQ5Ljc4NTM3N119LCJwcm9wZXJ0aWVzIjp7ImxhYmVsIjoiNzggUnVlIGR1IENpbWV0aWVyZSA3Njk1MCBMZXMgR3JhbmRlcy1WZW50ZXMiLCJzY29yZSI6MC40MTEyMjQ1NDU0NTQ1NDU0NSwiaG91c2VudW1iZXIiOiI3OCIsImlkIjoiNzYzMjFfMDA0MV8wMDA3OCIsIm5hbWUiOiI3OCBSdWUgZHUgQ2ltZXRpZXJlIiwicG9zdGNvZGUiOiI3Njk1MCIsImNpdHljb2RlIjoiNzYzMjEiLCJ4Ijo1NzIyMjAuOTMsInkiOjY5NjY1OTguNDMsImNpdHkiOiJMZXMgR3JhbmRlcy1WZW50ZXMiLCJjb250ZXh0IjoiNzYsIFNlaW5lLU1hcml0aW1lLCBOb3JtYW5kaWUiLCJ0eXBlIjoiaG91c2VudW1iZXIiLCJpbXBvcnRhbmNlIjowLjQ3MzQ3LCJzdHJlZXQiOiJSdWUgZHUgQ2ltZXRpZXJlIn19LHsidHlwZSI6IkZlYXR1cmUiLCJnZW9tZXRyeSI6eyJ0eXBlIjoiUG9pbnQiLCJjb29yZGluYXRlcyI6WzEuMjA3NDA0LDQ5Ljc3NjYyXX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgR291bGV0IDc2OTUwIExlcyBHcmFuZGVzLVZlbnRlcyIsInNjb3JlIjowLjQwMTc1NzI3MjcyNzI3Mjc0LCJpZCI6Ijc2MzIxXzAwNTUiLCJuYW1lIjoiUnVlIGR1IEdvdWxldCIsInBvc3Rjb2RlIjoiNzY5NTAiLCJjaXR5Y29kZSI6Ijc2MzIxIiwieCI6NTcwODA1LjEzLCJ5Ijo2OTY1NjU1LjQ3LCJjaXR5IjoiTGVzIEdyYW5kZXMtVmVudGVzIiwiY29udGV4dCI6Ijc2LCBTZWluZS1NYXJpdGltZSwgTm9ybWFuZGllIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNDgxODMsInN0cmVldCI6IlJ1ZSBkdSBHb3VsZXQifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMi4yODk1MTQsNTEuMDA0NzQ5XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgTGFjIDU5NzYwIEdyYW5kZS1TeW50aGUiLCJzY29yZSI6MC40MDAyNjY2MjMzNzY2MjMzLCJpZCI6IjU5MjcxXzAzOTciLCJuYW1lIjoiUnVlIGR1IExhYyIsInBvc3Rjb2RlIjoiNTk3NjAiLCJjaXR5Y29kZSI6IjU5MjcxIiwieCI6NjUwMDIxLjUxLCJ5Ijo3MTAxMjE4LjkxLCJjaXR5IjoiR3JhbmRlLVN5bnRoZSIsImNvbnRleHQiOiI1OSwgTm9yZCwgSGF1dHMtZGUtRnJhbmNlIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNTQ1NzksInN0cmVldCI6IlJ1ZSBkdSBMYWMifX0seyJ0eXBlIjoiRmVhdHVyZSIsImdlb21ldHJ5Ijp7InR5cGUiOiJQb2ludCIsImNvb3JkaW5hdGVzIjpbMi4zNzM4NzksNTAuOTk3NTY2XX0sInByb3BlcnRpZXMiOnsibGFiZWwiOiJSdWUgZHUgTGFjIDU5MTgwIENhcHBlbGxlLWxhLUdyYW5kZSIsInNjb3JlIjowLjM5ODE0MDI1OTc0MDI1OTcsImlkIjoiNTkxMzFfMDM5NyIsIm5hbWUiOiJSdWUgZHUgTGFjIiwicG9zdGNvZGUiOiI1OTE4MCIsImNpdHljb2RlIjoiNTkxMzEiLCJ4Ijo2NTU5NDkuNiwieSI6NzEwMDM2Ny44NiwiY2l0eSI6IkNhcHBlbGxlLWxhLUdyYW5kZSIsImNvbnRleHQiOiI1OSwgTm9yZCwgSGF1dHMtZGUtRnJhbmNlIiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNTIyNCwic3RyZWV0IjoiUnVlIGR1IExhYyJ9fSx7InR5cGUiOiJGZWF0dXJlIiwiZ2VvbWV0cnkiOnsidHlwZSI6IlBvaW50IiwiY29vcmRpbmF0ZXMiOls2LjEzMzgwNiw0OS40MTM5NDFdfSwicHJvcGVydGllcyI6eyJsYWJlbCI6IlJ1ZSBkdSBSZXliYWNoIDU3MzMwIEhldHRhbmdlLUdyYW5kZSIsInNjb3JlIjowLjM4Njg2NzQ4NjYzMTAxNjA2LCJpZCI6IjU3MzIzXzEyNjUiLCJuYW1lIjoiUnVlIGR1IFJleWJhY2giLCJwb3N0Y29kZSI6IjU3MzMwIiwiY2l0eWNvZGUiOiI1NzMyMyIsIngiOjkyNzQxOS4xNCwieSI6NjkyODM0My44MiwiY2l0eSI6IkhldHRhbmdlLUdyYW5kZSIsImNvbnRleHQiOiI1NywgTW9zZWxsZSwgR3JhbmQgRXN0IiwidHlwZSI6InN0cmVldCIsImltcG9ydGFuY2UiOjAuNTQ5NjYsInN0cmVldCI6IlJ1ZSBkdSBSZXliYWNoIn19XSwiYXR0cmlidXRpb24iOiJCQU4iLCJsaWNlbmNlIjoiRVRBTEFCLTIuMCIsInF1ZXJ5IjoiNzggUnVlIGR1IEdyIiwibGltaXQiOjEwfQ== - recorded_at: Mon, 04 Mar 2024 09:41:13 GMT + string: '{"nhits": 1230, "parameters": {"dataset": "fr-en-annuaire-education", + "q": "Moulin", "rows": 5, "start": 0, "format": "json", "timezone": "UTC"}, + "records": [{"datasetid": "fr-en-annuaire-education", "recordid": "5a1303829afed9e2f13d93bd0f56c4c526a0cd62", + "fields": {"position": [44.26226619871097, 5.960521626379292], "statut_public_prive": + "Priv\u00e9", "restauration": 0, "type_contrat_prive": "HORS CONTRAT", "ecole_maternelle": + 1, "libelle_departement": "Alpes-de-Haute-Provence", "hebergement": 0, "date_ouverture": + "2019-09-01", "code_region": "93", "libelle_bassin_formation": "DIGNE SISTERON", + "rpi_concentre": 0, "type_etablissement": "Ecole", "ministere_tutelle": "MINISTERE + DE L''EDUCATION NATIONALE", "precision_localisation": "Ville", "libelle_academie": + "Aix-Marseille", "telephone": "0492621133", "code_nature": 151, "code_type_contrat_prive": + "10", "longitude": 5.960521626379292, "etat": "OUVERT", "latitude": 44.26226619871097, + "code_academie": "02", "ecole_elementaire": 1, "adresse_1": "Lieu-dit le moulin", + "coordy_origine": 6355902.4, "code_commune": "04231", "code_circonscription": + "0134548Y", "identifiant_de_l_etablissement": "0040581K", "adresse_3": "04200 + VALERNES", "multi_uai": 0, "libelle_nature": "ECOLE DE NIVEAU ELEMENTAIRE", + "mail": "moulin@gdv-cor.org", "nom_circonscription": "CIRCONSCRIPTION 1er + D. ETABLISSEMENTS PRIVES Hors Contrat", "libelle_region": "Provence-Alpes-C\u00f4te + d''Azur", "ulis": 0, "nom_etablissement": "Communaut\u00e9 de la R\u00e9conciliation", + "nom_commune": "Valernes", "code_departement": "004", "code_bassin_formation": + "02101", "code_postal": "04200", "epsg_origine": "EPSG:2154", "date_maj_ligne": + "2024-06-29", "coordx_origine": 936304.3}, "geometry": {"type": "Point", "coordinates": + [5.960521626379292, 44.26226619871097]}, "record_timestamp": "2024-07-02T01:01:00Z"}, + {"datasetid": "fr-en-annuaire-education", "recordid": "1d41d58dd458e147633388e57acc0950e2d8b65f", + "fields": {"position": [46.56509840412523, 3.327179664489305], "statut_public_prive": + "Public", "restauration": 0, "type_contrat_prive": "SANS OBJET", "ecole_maternelle": + 1, "libelle_departement": "Allier", "hebergement": 0, "date_ouverture": "1967-06-06", + "siren_siret": "21030190900060", "code_region": "84", "libelle_bassin_formation": + "MOULINS", "rpi_concentre": 0, "type_etablissement": "Ecole", "ministere_tutelle": + "MINISTERE DE L''EDUCATION NATIONALE", "precision_localisation": "Num\u00e9ro + de rue", "libelle_academie": "Clermont-Ferrand", "telephone": "0470440367", + "code_nature": 151, "code_type_contrat_prive": "99", "longitude": 3.327179664489305, + "etat": "OUVERT", "latitude": 46.56509840412523, "code_academie": "06", "ecole_elementaire": + 1, "adresse_1": "25 rue Louis Blanc", "coordy_origine": 6607281.5, "code_commune": + "03190", "code_circonscription": "0030064D", "identifiant_de_l_etablissement": + "0030323K", "nombre_d_eleves": 294, "adresse_3": "03000 MOULINS", "multi_uai": + 0, "libelle_nature": "ECOLE DE NIVEAU ELEMENTAIRE", "mail": "ce.0030323K@ac-clermont.fr", + "nom_circonscription": "Circonscription d''inspection du 1er degr\u00e9 de + Moulins I", "libelle_region": "Auvergne-Rh\u00f4ne-Alpes", "ulis": 1, "nom_etablissement": + "Ecole primaire Jean Moulin", "pial": "0030013Y", "nom_commune": "Moulins", + "code_departement": "003", "code_bassin_formation": "06032", "code_postal": + "03000", "epsg_origine": "EPSG:2154", "date_maj_ligne": "2024-06-29", "coordx_origine": + 725061.3}, "geometry": {"type": "Point", "coordinates": [3.327179664489305, + 46.56509840412523]}, "record_timestamp": "2024-07-02T01:01:00Z"}, {"datasetid": + "fr-en-annuaire-education", "recordid": "9d7d1885862511baab98e460d51f4acf3c371adc", + "fields": {"libelle_zone_animation_pedagogique": "JEAN MOULIN", "hebergement": + 0, "segpa": "0", "date_ouverture": "1966-10-17", "adresse_2": "BP 133", "apprentissage": + "0", "section_theatre": "0", "lycee_agricole": "0", "libelle_bassin_formation": + "SALON DE PROVENCE", "lycee_des_metiers": "0", "section_internationale": "0", + "type_etablissement": "Coll\u00e8ge", "section_cinema": "0", "code_nature": + 340, "greta": "0", "lycee_militaire": "0", "etat": "OUVERT", "section_europeenne": + "0", "code_academie": "02", "code_commune": "13103", "voie_technologique": + "0", "post_bac": "0", "identifiant_de_l_etablissement": "0131265E", "web": + "http://www.clg-moulin-salon.ac-aix-marseille.fr", "libelle_nature": "COLLEGE", + "fiche_onisep": "https://www.onisep.fr/http/redirection/etablissement/slug/ENS.5888", + "libelle_region": "Provence-Alpes-C\u00f4te d''Azur", "section_arts": "0", + "nom_etablissement": "Coll\u00e8ge Jean Moulin", "fax": "04 90 56 38 81", + "code_bassin_formation": "02112", "code_postal": "13657", "epsg_origine": + "EPSG:2154", "date_maj_ligne": "2024-06-29", "position": [43.642784212843395, + 5.103994516073329], "statut_public_prive": "Public", "restauration": 1, "type_contrat_prive": + "SANS OBJET", "libelle_departement": "Bouches-du-Rh\u00f4ne", "voie_professionnelle": + "0", "siren_siret": "19131265100018", "code_region": "93", "voie_generale": + "0", "code_zone_animation_pedagogique": "013020", "section_sport": "1", "rpi_concentre": + 0, "ministere_tutelle": "MINISTERE DE L''EDUCATION NATIONALE", "precision_localisation": + "Num\u00e9ro de rue", "libelle_academie": "Aix-Marseille", "telephone": "04 + 90 56 14 20", "code_type_contrat_prive": "99", "longitude": 5.103994516073329, + "latitude": 43.642784212843395, "adresse_1": "Avenue de l''Europe", "coordy_origine": + 6284900.0, "code_circonscription": "0134012R", "nombre_d_eleves": 500, "appartenance_education_prioritaire": + "REP", "multi_uai": 0, "mail": "ce.0131265E@ac-aix-marseille.fr", "nom_circonscription": + "Circonscription d''inspection du 1er degr\u00e9 d''Arles - ASH Ouest", "ulis": + 0, "pial": "0133492A", "nom_commune": "Salon-de-Provence", "code_departement": + "013", "coordx_origine": 869791.0}, "geometry": {"type": "Point", "coordinates": + [5.103994516073329, 43.642784212843395]}, "record_timestamp": "2024-07-02T01:01:00Z"}, + {"datasetid": "fr-en-annuaire-education", "recordid": "4feceb0ba417c9c01ffdeabca67d467285cd3565", + "fields": {"libelle_zone_animation_pedagogique": "JEAN MOULIN", "hebergement": + 0, "date_ouverture": "1971-06-15", "libelle_bassin_formation": "SALON DE PROVENCE", + "type_etablissement": "Ecole", "code_nature": 151, "etat": "OUVERT", "code_academie": + "02", "ecole_elementaire": 1, "code_commune": "13103", "identifiant_de_l_etablissement": + "0132152U", "libelle_nature": "ECOLE DE NIVEAU ELEMENTAIRE", "libelle_region": + "Provence-Alpes-C\u00f4te d''Azur", "nom_etablissement": "Ecole \u00e9l\u00e9mentaire + Saint Norbert", "code_bassin_formation": "02112", "code_postal": "13300", + "epsg_origine": "EPSG:2154", "date_maj_ligne": "2024-06-29", "position": [43.649631956501274, + 5.104010330876341], "statut_public_prive": "Public", "restauration": 1, "type_contrat_prive": + "SANS OBJET", "ecole_maternelle": 0, "libelle_departement": "Bouches-du-Rh\u00f4ne", + "siren_siret": "21130103100269", "code_region": "93", "code_zone_animation_pedagogique": + "013020", "rpi_concentre": 0, "ministere_tutelle": "MINISTERE DE L''EDUCATION + NATIONALE", "precision_localisation": "Num\u00e9ro de rue", "libelle_academie": + "Aix-Marseille", "telephone": "0490534878", "code_type_contrat_prive": "99", + "longitude": 5.104010330876341, "latitude": 43.649631956501274, "adresse_1": + "Boulevard des Nations Unies", "coordy_origine": 6285660.8, "code_circonscription": + "0131315J", "nombre_d_eleves": 116, "appartenance_education_prioritaire": + "REP", "adresse_3": "13300 SALON DE PROVENCE", "multi_uai": 0, "mail": "ce.0132152U@ac-aix-marseille.fr", + "nom_circonscription": "Circonscription d''inspection du 1er degr\u00e9 de + Salon de Provence", "ulis": 0, "pial": "0131143X", "nom_commune": "Salon-de-Provence", + "code_departement": "013", "coordx_origine": 869772.0}, "geometry": {"type": + "Point", "coordinates": [5.104010330876341, 43.649631956501274]}, "record_timestamp": + "2024-07-02T01:01:00Z"}, {"datasetid": "fr-en-annuaire-education", "recordid": + "a31fb73e330d1cf9dfad9cbce5c581f0c9071157", "fields": {"hebergement": 0, "segpa": + "0", "date_ouverture": "1977-06-03", "adresse_2": "BP 55", "apprentissage": + "0", "section_theatre": "0", "lycee_agricole": "0", "libelle_bassin_formation": + "ALBERTVILLE", "lycee_des_metiers": "0", "section_internationale": "0", "type_etablissement": + "Coll\u00e8ge", "section_cinema": "0", "code_nature": 340, "greta": "1", "lycee_militaire": + "0", "etat": "OUVERT", "section_europeenne": "0", "code_academie": "08", "code_commune": + "73011", "voie_technologique": "0", "post_bac": "0", "identifiant_de_l_etablissement": + "0731224J", "web": "https://jean-moulin.ent.auvergnerhonealpes.fr/", "libelle_nature": + "COLLEGE", "fiche_onisep": "https://www.onisep.fr/http/redirection/etablissement/slug/ENS.16243", + "libelle_region": "Auvergne-Rh\u00f4ne-Alpes", "section_arts": "0", "nom_etablissement": + "Coll\u00e8ge Jean Moulin", "fax": "04 79 32 03 84", "code_bassin_formation": + "08734", "code_postal": "73202", "epsg_origine": "EPSG:2154", "date_maj_ligne": + "2024-06-29", "position": [45.673982216909764, 6.389100607462976], "statut_public_prive": + "Public", "restauration": 1, "type_contrat_prive": "SANS OBJET", "libelle_departement": + "Savoie", "voie_professionnelle": "0", "siren_siret": "19731224200013", "code_region": + "84", "voie_generale": "0", "section_sport": "0", "rpi_concentre": 0, "ministere_tutelle": + "MINISTERE DE L''EDUCATION NATIONALE", "precision_localisation": "Num\u00e9ro + de rue", "libelle_academie": "Grenoble", "telephone": "04 79 32 49 03", "code_type_contrat_prive": + "99", "longitude": 6.389100607462976, "latitude": 45.673982216909764, "adresse_1": + "12 rue F\u00e9lix Chautemps", "coordy_origine": 6513930.7, "code_circonscription": + "0730061V", "nombre_d_eleves": 335, "multi_uai": 0, "mail": "Ce.0731224J@ac-grenoble.fr", + "nom_circonscription": "Circonscription d''inspection du 1er degr\u00e9 de + Chamb\u00e9ry 2 - ASH", "ulis": 1, "pial": "0731224J", "nom_commune": "Albertville", + "code_departement": "073", "coordx_origine": 963765.4}, "geometry": {"type": + "Point", "coordinates": [6.389100607462976, 45.673982216909764]}, "record_timestamp": + "2024-07-02T01:01:00Z"}]}' + recorded_at: Tue, 02 Jul 2024 13:54:04 GMT recorded_with: VCR 6.2.0 diff --git a/spec/models/champs/annuaire_education_champ_spec.rb b/spec/models/champs/annuaire_education_champ_spec.rb index 8c8f58be5..3ac3a87a0 100644 --- a/spec/models/champs/annuaire_education_champ_spec.rb +++ b/spec/models/champs/annuaire_education_champ_spec.rb @@ -22,21 +22,19 @@ RSpec.describe Champs::AnnuaireEducationChamp do it_behaves_like "a data updater (without updating the value)", '' 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 let(:data) { { - 'nom_etablissement': "karrigel an ankou", + 'nom_etablissement' => "karrigel an ankou", 'nom_commune' => 'kumun', '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 diff --git a/spec/models/champs/commune_champ_spec.rb b/spec/models/champs/commune_champ_spec.rb index 76051c0c3..87d726dd6 100644 --- a/spec/models/champs/commune_champ_spec.rb +++ b/spec/models/champs/commune_champ_spec.rb @@ -5,7 +5,7 @@ describe Champs::CommuneChamp do let(:champ) { create(:champ_communes, code_postal:, external_id: code_insee) } describe 'value' do - it 'with code_postal' do + it 'find commune' do expect(champ.to_s).to eq('Châteldon (63290)') expect(champ.name).to eq('Châteldon') 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(:code)).to eq '63102' expect(champ.for_export(:departement)).to eq '63 – Puy-de-Dôme' - expect(champ.communes.size).to eq(8) end - end - describe 'code_postal with spaces' do - let(:code_postal) { ' 63 2 90  ' } + context 'with code' do + let(:champ) { create(:champ_communes, code: '63102-63290') } - it 'with code_postal' do - expect(champ.communes.size).to eq(8) + it 'find commune' do + 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 diff --git a/spec/models/champs/decimal_number_champ_spec.rb b/spec/models/champs/decimal_number_champ_spec.rb index 3a5674f73..57ac73cf8 100644 --- a/spec/models/champs/decimal_number_champ_spec.rb +++ b/spec/models/champs/decimal_number_champ_spec.rb @@ -24,6 +24,12 @@ describe Champs::DecimalNumberChamp do end end + context 'when value contain space' do + let(:champ) { create(:champ_decimal_number, :private, value:) } + let(:value) { ' 2.6666 ' } + it { expect(champ.value).to eq('2.6666') } + end + context 'when the value has too many decimal' do let(:value) { '2.6666' } @@ -65,7 +71,7 @@ describe Champs::DecimalNumberChamp do end context 'with number having spaces' do let(:value) { " 120 " } - it { is_expected.to be_nil } + it { is_expected.to eq(120) } end end end diff --git a/spec/models/champs/integer_number_champ_spec.rb b/spec/models/champs/integer_number_champ_spec.rb index f405b1835..7c2a4ad68 100644 --- a/spec/models/champs/integer_number_champ_spec.rb +++ b/spec/models/champs/integer_number_champ_spec.rb @@ -27,6 +27,16 @@ describe Champs::IntegerNumberChamp do end end + context 'when the value is a number with sapces' do + let(:value) { ' 120 ' } + + it 'is valid and is formated' do + is_expected.to be_truthy + champ.save! + expect(champ.value).to eq('120') + end + end + context 'when the value is blank' do let(:value) { '' } diff --git a/spec/models/logic/champ_value_spec.rb b/spec/models/logic/champ_value_spec.rb index f7b12020f..c287c57d4 100644 --- a/spec/models/logic/champ_value_spec.rb +++ b/spec/models/logic/champ_value_spec.rb @@ -40,12 +40,6 @@ describe Logic::ChampValue do it { is_expected.to be nil } end - - context 'with invalid value' do - before { champ.value = 'environ 300' } - - it { is_expected.to be nil } - end end context 'decimal tdc' do @@ -53,18 +47,6 @@ describe Logic::ChampValue do it { expect(champ_value(champ.stable_id).type([champ.type_de_champ])).to eq(:number) } it { is_expected.to eq(42.01) } - - context 'with invalid value with too many digits after the decimal point' do - before { champ.value = '42.1234' } - - it { is_expected.to be nil } - end - - context 'with invalid value' do - before { champ.value = 'racine de 2' } - - it { is_expected.to be nil } - end end context 'dropdown tdc' do diff --git a/spec/support/system_helpers.rb b/spec/support/system_helpers.rb index 6a5d6fad7..b725cd798 100644 --- a/spec/support/system_helpers.rb +++ b/spec/support/system_helpers.rb @@ -106,25 +106,10 @@ module SystemHelpers end end - def select_combobox(libelle, fill_with, value, check: true) - fill_in libelle, with: fill_with - find('li[role="option"][data-reach-combobox-option]', text: value, wait: 5).click - if check - 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) + def select_combobox(libelle, value, custom_value: false) + fill_in libelle, with: custom_value ? "#{value}," : value + if !custom_value + find_field(libelle).send_keys(:down, :enter) end end diff --git a/spec/system/administrateurs/procedure_update_spec.rb b/spec/system/administrateurs/procedure_update_spec.rb index 65440e57e..610c37933 100644 --- a/spec/system/administrateurs/procedure_update_spec.rb +++ b/spec/system/administrateurs/procedure_update_spec.rb @@ -60,7 +60,7 @@ describe 'Administrateurs can edit procedures', js: true do procedure.update!(tags: ['social']) 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' expect(procedure.reload.tags).to eq(['social', 'planete']) diff --git a/spec/system/instructeurs/expert_spec.rb b/spec/system/instructeurs/expert_spec.rb index 64a66c1e9..426cc9655 100644 --- a/spec/system/instructeurs/expert_spec.rb +++ b/spec/system/instructeurs/expert_spec.rb @@ -29,7 +29,8 @@ describe 'Inviting an expert:', js: true do within('.fr-sidemenu') { click_on 'Demander un avis' } 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.' check 'avis_invite_linked_dossiers' 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' } 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.' check 'avis_invite_linked_dossiers' page.select 'confidentiel', from: 'avis_confidentiel' diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index d6230e9b5..f36b15f5f 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -160,13 +160,10 @@ describe 'Instructing a dossier:', js: true do within('.fr-sidemenu') { click_on 'Demander un avis' } expect(page).to have_current_path(avis_new_instructeur_dossier_path(procedure, dossier)) - expert_email_formated = "[\"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}\"]" - expert_email = instructeur2.email - ask_confidential_avis(expert_email_formated, 'a good introduction') + ask_confidential_avis(instructeur2.email, 'a good introduction') click_on 'Personnes impliquées' expect(page).to have_text(expert_email) @@ -189,8 +186,8 @@ describe 'Instructing a dossier:', js: true do click_on 'Personnes impliquées' - select_combobox('Emails', instructeur_2.email, instructeur_2.email, check: false) - select_combobox('Emails', instructeur_3.email, instructeur_3.email, check: false) + select_combobox('Emails', instructeur_2.email) + select_combobox('Emails', instructeur_3.email) click_on 'Envoyer' @@ -287,7 +284,7 @@ describe 'Instructing a dossier:', js: true do end 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 select 'confidentiel', from: 'avis_confidentiel' within('form#new_avis') { click_on 'Demander un avis' } diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index 0e3a6db03..3900fe426 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -88,33 +88,13 @@ describe "procedure filters" do scenario "should be able to user custom fiters", js: true do # use date filter - click_on 'Sélectionner un filtre' - 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) + add_filter("En construction le", "10/10/2010", type: :date) # use statut dropdown filter - click_on 'Sélectionner un filtre' - 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) + add_filter('Statut', 'En construction', type: :enum) # use choice dropdown filter - click_on 'Sélectionner un filtre' - 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" + add_filter('Choix unique', 'val1', type: :enum) end describe 'with a vcr cached cassette' do @@ -124,14 +104,7 @@ describe "procedure filters" do departement_champ.reload champ_select_value = "#{departement_champ.external_id} – #{departement_champ.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: 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 + add_filter(departement_champ.libelle, champ_select_value, type: :enum) expect(page).to have_link(new_unfollow_dossier.id.to_s) end @@ -140,14 +113,7 @@ describe "procedure filters" do region_champ.update!(value: 'Bretagne', external_id: '53') region_champ.reload - click_on 'Sélectionner un filtre' - 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 + add_filter(region_champ.libelle, region_champ.value, type: :enum) expect(page).to have_link(new_unfollow_dossier.id.to_s) 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 add_filter(type_de_champ.libelle, champ.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 expect(page).to have_link(new_unfollow_dossier.id.to_s, exact: true) @@ -185,40 +151,40 @@ describe "procedure filters" do 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) click_link text: filter_value 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) click_on 'Personnaliser' - select_combobox('Colonne à afficher', column_name, column_name, check: false) + select_combobox('Colonne à afficher', column_name) click_button "Enregistrer" end def remove_column(column_name) click_on 'Personnaliser' - click_button column_name - find("body").native.send_key("Escape") + within '.fr-tag-list' do + find('.fr-tag', text: column_name).find('button').click + end click_button "Enregistrer" end end diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index 6f5a65135..664f656b1 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -10,6 +10,10 @@ describe 'The user' do log_in(user, procedure) fill_individual + + # wait for react components to be initialized + find('.dom-ready') + # fill data fill_in('text', with: 'super texte', match: :first) fill_in('textarea', with: 'super textarea') @@ -37,16 +41,23 @@ describe 'The user' do select('Martinique', from: form_id_for('regions')) select('02 – Aisne', from: form_id_for('departements')) + scroll_to(find_field('communes'), align: :center) fill_in('communes', with: '60400') - find('li', text: 'Brétigny (60400)').click + find('.fr-menu__item', text: 'Brétigny (60400)').click wait_until { champ_value_for('communes') == "Brétigny" } + scroll_to(find_field('address'), align: :center) fill_in('address', with: '78 Rue du Grés 30310 Vergè') - find('li', text: '78 Rue du Grés 30310 Vergèze').click + find('.fr-menu__item', text: '78 Rue du Grés 30310 Vergèze').click wait_until { champ_value_for('address') == '78 Rue du Grés 30310 Vergèze' } wait_until { champ_for('address').full_address? } expect(champ_for('address').departement_code_and_name).to eq('30 – Gard') + scroll_to(find_field('annuaire_education'), align: :center) + fill_in('annuaire_education', with: 'Moulin') + find('.fr-menu__item', text: 'Ecole primaire Jean Moulin, Moulins (0030323K)').click + wait_until { champ_for('annuaire_education').external_id == "0030323K" } + fill_in('dossier_link', with: '123') find('.editable-champ-piece_justificative input[type=file]').attach_file(Rails.root + 'spec/fixtures/files/file.pdf') diff --git a/spec/tasks/maintenance/fix_decimal_number_with_spaces_task_spec.rb b/spec/tasks/maintenance/fix_decimal_number_with_spaces_task_spec.rb deleted file mode 100644 index 1e257144f..000000000 --- a/spec/tasks/maintenance/fix_decimal_number_with_spaces_task_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Maintenance - RSpec.describe FixDecimalNumberWithSpacesTask do - describe "#process" do - subject(:process) { described_class.process(element) } - let(:champ) { create(:champ_decimal_number, value:) } - let(:element) { champ } - - context 'with nil' do - let(:value) { 0 } - it { expect { process }.not_to change { champ.reload.valid_value } } - end - context 'with simple number' do - let(:value) { "120" } - it { expect { process }.not_to change { champ.reload.valid_value } } - end - context 'with number having leading spaces' do - let(:value) { " 120" } - it { expect { process }.to change { champ.reload.valid_value }.from(nil).to("120") } - end - context 'with number having trailing spaces' do - let(:value) { "120 " } - it { expect { process }.to change { champ.reload.valid_value }.from(nil).to("120") } - end - context 'with number having leading and trailing spaces' do - let(:value) { " 120 " } - it { expect { process }.to change { champ.reload.valid_value }.from(nil).to("120") } - end - context 'with number having in between spaces' do - let(:value) { "1 2 0" } - it { expect { process }.to change { champ.reload.valid_value }.from(nil).to("120") } - end - context 'with number having in between tab' do - let(:value) { "\t120\t" } - it { expect { process }.to change { champ.reload.valid_value }.from(nil).to("120") } - end - end - end -end diff --git a/spec/views/administrateurs/procedures/show.html.haml_spec.rb b/spec/views/administrateurs/procedures/show.html.haml_spec.rb index 57224b13b..da9cbe1e6 100644 --- a/spec/views/administrateurs/procedures/show.html.haml_spec.rb +++ b/spec/views/administrateurs/procedures/show.html.haml_spec.rb @@ -16,17 +16,11 @@ describe 'administrateurs/procedures/show', type: :view do render end - describe 'publish button is visible' do - it { expect(rendered).to have_css('#publish-procedure-link') } - it { expect(rendered).not_to have_css('#close-procedure-link') } - end - - describe 'procedure path is not customized' do - it { expect(rendered).to have_content('En test') } - end - - describe 'archive button' do - it { expect(rendered).not_to have_css('#archive-procedure') } + it "render content" do + expect(rendered).to have_css('#publish-procedure-link') + expect(rendered).not_to have_css('#close-procedure-link') + expect(rendered).to have_content('En test') + expect(rendered).not_to have_css('#archive-procedure') end end end diff --git a/spec/views/commencer/show.html.haml_spec.rb b/spec/views/commencer/show.html.haml_spec.rb index 571654477..bcda8b893 100644 --- a/spec/views/commencer/show.html.haml_spec.rb +++ b/spec/views/commencer/show.html.haml_spec.rb @@ -7,10 +7,15 @@ RSpec.describe 'commencer/show', type: :view do let(:drafts) { [] } let(:not_drafts) { [] } let(:preview_dossiers) { dossiers.take(3) } + let(:user) { nil } + + before do + allow(view).to receive(:current_administrateur).and_return(user&.administrateur) + end before do assign(:procedure, procedure) - assign(:revision, procedure.published_revision) + assign(:revision, procedure.active_revision) assign(:dossiers, dossiers) assign(:drafts, drafts) assign(:not_drafts, not_drafts) @@ -25,8 +30,6 @@ RSpec.describe 'commencer/show', type: :view do subject { render } context 'when no user is signed in' do - let(:user) { nil } - it 'renders sign-in and sign-up links' do subject expect(rendered).to have_link('Créer un compte') @@ -98,4 +101,35 @@ RSpec.describe 'commencer/show', type: :view do end end end + + context "procedure is draft" do + let(:procedure) { create(:procedure, :draft) } + let(:user) { create :user } + + it 'renders a warning' do + subject + expect(rendered).to have_text("Cette démarche est actuellement en test") + end + + context "when user is admin" do + let(:user) { procedure.administrateurs.first.user } + + it "renders warning about draft" do + subject + expect(rendered).to have_text("Cette démarche est actuellement en test") + expect(rendered).to have_text("Ne communiquez pas ce lien") + end + end + end + + context "revision is draft" do + before { + assign(:revision, procedure.draft_revision) + } + + it "renders warning about draft" do + subject + expect(rendered).to have_text("Démarche en test") + end + end end diff --git a/spec/views/instructeur/dossiers/_envoyer_dossier_block.html.haml_spec.rb b/spec/views/instructeur/dossiers/_envoyer_dossier_block.html.haml_spec.rb index 1c3107fbb..94b8d0e57 100644 --- a/spec/views/instructeur/dossiers/_envoyer_dossier_block.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/_envoyer_dossier_block.html.haml_spec.rb @@ -13,7 +13,7 @@ describe 'instructeurs/dossiers/envoyer_dossier_block', type: :view do let(:instructeur) { create(:instructeur, email: 'yop@totomail.fr') } let(:potential_recipients) { [instructeur] } - it { is_expected.to match(/data-react-props.*#{instructeur.email}/) } + it { is_expected.to match(/props.*#{instructeur.email}/) } it { is_expected.to have_css(".fr-btn") } end diff --git a/tsconfig.json b/tsconfig.json index dc38473b9..4240d0dc7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "target": "ES2019", "moduleResolution": "node", "module": "es2020", - "jsx": "react", + "jsx": "react-jsx", "esModuleInterop": true, "experimentalDecorators": true, "isolatedModules": true, @@ -13,7 +13,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "types": ["react/next", "react-dom/next", "vite/client"], + "types": ["vite/client"], "paths": { "~/*": ["./app/javascript/*"], "@utils": ["./app/javascript/shared/utils.ts"] diff --git a/vite.config.ts b/vite.config.ts index 036c8e0c8..48ab90a9c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,14 +2,21 @@ import { defineConfig } from 'vite'; import ViteReact from '@vitejs/plugin-react'; import RubyPlugin from 'vite-plugin-ruby'; import FullReload from 'vite-plugin-full-reload'; +//import optimizeLocales from '@react-aria/optimize-locales-plugin'; const plugins = [ RubyPlugin(), - ViteReact({ jsxRuntime: 'classic' }), + ViteReact(), FullReload( ['config/routes.rb', 'app/views/**/*', 'app/components/**/*.haml'], { delay: 200 } ) + // { + // ...optimizeLocales.vite({ + // locales: ['en-GB', 'fr-FR'] + // }), + // enforce: 'pre' as const + // } ]; export default defineConfig({