Merge branch 'main' into fix/stored_query_issue

This commit is contained in:
Damien Le Thiec 2023-03-01 10:22:40 +01:00 committed by GitHub
commit 8a7cb3f1fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
301 changed files with 4456 additions and 1168 deletions

View file

@ -1,9 +1,11 @@
name: Continuous Integration
on:
push:
branches: 'main'
branches: [main]
pull_request:
branches: 'main'
branches: [main]
merge_group:
branches: [main]
jobs:
linters:

View file

@ -27,6 +27,7 @@ gem 'devise-i18n'
gem 'devise-two-factor'
gem 'discard'
gem 'dotenv-rails', require: 'dotenv/rails-now' # dotenv should always be loaded before rails
gem 'elastic-apm'
gem 'flipper'
gem 'flipper-active_record'
gem 'flipper-ui'
@ -69,6 +70,7 @@ gem 'rack-attack'
gem 'rails'
gem 'rails-i18n' # Locales par défaut
gem 'rake-progressbar', require: false
gem 'redcarpet'
gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/issues/325)
gem 'rgeo-geojson'
gem 'rqrcode'

View file

@ -220,6 +220,10 @@ GEM
dumb_delegator (1.0.0)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
elastic-apm (4.6.0)
concurrent-ruby (~> 1.0)
http (>= 3.0)
ruby2_keywords
encryptor (3.0.0)
erubi (1.12.0)
et-orbi (1.2.4)
@ -249,6 +253,9 @@ GEM
faraday-patron (1.0.0)
faraday-rack (1.0.0)
ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
flipper (0.26.0)
concurrent-ruby (< 2)
flipper-active_record (0.26.0)
@ -323,9 +330,15 @@ GEM
highline (2.0.3)
html_tokenizer (0.0.7)
htmlentities (4.3.4)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.4.0)
http-accept (1.7.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
http-form_data (2.3.0)
http_accept_language (2.1.1)
httpclient (2.8.3)
i18n (1.12.0)
@ -390,6 +403,9 @@ GEM
listen (3.4.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
lograge (0.11.2)
actionpack (>= 4)
activesupport (>= 4)
@ -560,6 +576,7 @@ GEM
rb-fsevent (0.10.4)
rb-inotify (0.10.1)
ffi (~> 1.0)
redcarpet (3.6.0)
regexp_parser (2.6.0)
request_store (1.5.0)
rack (>= 1.4)
@ -841,6 +858,7 @@ DEPENDENCIES
devise-two-factor
discard
dotenv-rails
elastic-apm
factory_bot
flipper
flipper-active_record
@ -896,6 +914,7 @@ DEPENDENCIES
rails-erd
rails-i18n
rake-progressbar
redcarpet
rexml
rgeo-geojson
rqrcode

View file

@ -3,16 +3,11 @@
.help-dropdown {
.dropdown-content {
width: 340px;
}
.dropdown-description {
font-size: 14px;
width: 360px;
}
}
.help-dropdown-title {
font-size: 16px;
color: $blue-france-500;
}
@ -37,15 +32,5 @@
.help-dropdown-service-item {
margin-top: $default-spacer;
line-height: 18px;
.icon {
vertical-align: middle;
margin-right: 5px;
&.clock {
filter: contrast(0) brightness(120%);
vertical-align: -4px;
}
}
}

View file

@ -9,12 +9,10 @@ $complexity-color-3: #FFD000;
$complexity-color-4: $green;
.password-complexity {
margin-top: -24px;
width: 100%;
height: 12px;
background: $complexity-bg;
display: block;
margin-bottom: $default-spacer;
text-align: center;
border-radius: 8px;

View file

@ -10,7 +10,7 @@
}
[data-react-component-value^="ComboMultiple"] {
margin-bottom: $default-fields-spacer;
margin-bottom: 0;
[data-reach-combobox-token-list] {
padding: 0.5 * $default-padding;

View file

@ -89,7 +89,8 @@
color: $light-grey;
}
.conditionnel {
.conditionnel,
p {
color: $white;
}
}

View file

@ -5,8 +5,6 @@ $procedure-context-breakpoint: $two-columns-breakpoint;
$procedure-description-line-height: 22px;
.procedure-preview {
font-size: 24px;
.paperless-logo {
width: 100%;
margin-bottom: 60px;
@ -74,17 +72,6 @@ $procedure-description-line-height: 22px;
border-bottom: 1px dotted $blue-france-500;
}
.procedure-description {
font-size: 16px;
p:not(:last-of-type) {
// Space the paragraphs by exactly one line height,
// so that the text always is truncated in the middle of a line,
// regarless of the number of paragraphs.
margin-bottom: 3 * $default-spacer;
}
}
.read-more-button {
display: none;
}
@ -100,7 +87,7 @@ $procedure-description-line-height: 22px;
// If the text exceeds the max-height,
// truncate it and displays the "Read more" button.
&.read-more-enabled {
overflow: hidden;
overflow: auto;
border-bottom: 1px solid $border-grey;
+ .read-more-button {

View file

@ -0,0 +1,11 @@
.counter-start-header-section {
counter-reset: headerSectionCounter;
}
.header-section {
counter-increment: headerSectionCounter;
&.header-section-counter::before {
content: counter(headerSectionCounter) ". ";
}
}

View file

@ -0,0 +1,7 @@
# Display a form for destroying a file attachment via a button, but since it might already be nested within a form
# put this component before the actual form containing the editcomponent
class Attachment::DeleteFormComponent < ApplicationComponent
def call
form_tag('/attachments/:id', method: :delete, data: { 'turbo-method': :delete, turbo: true }, id: dom_id(ActiveStorage::Attachment.new, :delete)) {}
end
end

View file

@ -7,6 +7,7 @@ en:
delete_file: Delete file %{filename}
replace: Replace
replace_file: Replace file %{filename}
open_file: Open file %{filename}
errors:
uploading: "An error occurred while sending the file."
virus_infected: "Virus detected, please send another file."

View file

@ -7,6 +7,7 @@ fr:
delete_file: Supprimer le fichier %{filename}
replace: Remplacer
replace_file: Remplacer le fichier %{filename}
open_file: Ouvrir le fichier %{filename}
errors:
uploading: "Une erreur sest produite pendant lenvoi du fichier."
virus_infected: "Virus détecté, merci denvoyer un autre fichier."

View file

@ -3,7 +3,8 @@
%div{ id: dom_id(attachment, :persisted_row) }
.flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) }
- if user_can_destroy?
= link_to(t('.delete'), destroy_attachment_path, **remove_button_options, class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename))
= button_tag(name: "action", formaction: destroy_attachment_path, class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename), form: dom_id(ActiveStorage::Attachment.new, :delete), data: {turbo: true, 'turbo-method': 'delete'}) do
= t('.delete')
- elsif user_can_replace?
= button_tag t('.replace'), **replace_button_options, class: "fr-btn fr-btn--tertiary fr-btn--sm", title: t(".replace_file", filename: attachment.filename)
@ -11,7 +12,7 @@
= render Dsfr::DownloadComponent.new(attachment:)
- else
.fr-py-1v
%span.attachment-filename.fr-mr-1w= link_to_if(viewable?, attachment.filename.to_s, helpers.url_for(attachment.blob), title: "Ouvrir le fichier #{attachment.filename.to_s}", **helpers.external_link_attributes)
%span.attachment-filename.fr-mr-1w= link_to_if(viewable?, attachment.filename.to_s, helpers.url_for(attachment.blob), title: t(".open_file", filename: attachment.filename), **helpers.external_link_attributes)
= render Attachment::ProgressComponent.new(attachment: attachment)

View file

@ -0,0 +1,2 @@
class Attachment::ProgressBarComponent < ApplicationComponent
end

View file

@ -0,0 +1,3 @@
---
en:
loading: Loading

View file

@ -0,0 +1,3 @@
---
fr:
loading: Chargement de fichier

View file

@ -0,0 +1,5 @@
%template#progress-bar-template
.direct-upload
.direct-upload__progress{ role: "progressbar", 'aria-label': t(".loading"), tabindex: "0", 'aria-valuemin': "0", 'aria-valuemax': "100", max: "100", style: "width: 0%" }
%span.direct-upload__filename
%slot{ name: "filename" }

View file

@ -1,2 +1,2 @@
%p.fr-badge.fr-badge--info.fr-badge--sm.fr-badge--no-icon
%p.fr-badge.fr-badge--info.fr-badge--sm.fr-badge--no-icon{ role: 'status' }
= progress_label

View file

@ -55,15 +55,6 @@ class Dossiers::MessageComponent < ApplicationComponent
l(commentaire.created_at, format: is_current_year ? :message_date : :message_date_with_year)
end
def commentaire_body
if commentaire.discarded?
t('.deleted_body')
else
body_formatted = commentaire.sent_by_system? ? commentaire.body : simple_format(commentaire.body)
sanitize(body_formatted)
end
end
def highlight?
commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at)
end

View file

@ -8,7 +8,14 @@
%span.fr-text--xs.fr-text-mention--grey.font-weight-normal= t('.guest')
%span.date{ class: ["fr-text--xs", "fr-text-mention--grey", "font-weight-normal", highlight_if_unseen_class], data: scroll_to_target }
= commentaire_date
.rich-text= commentaire_body
.rich-text
- if commentaire.discarded?
%p= t('.deleted_body')
- elsif commentaire.sent_by_system?
= sanitize(commentaire.body, scrubber: Sanitizers::MailScrubber.new)
- else
= render SimpleFormatComponent.new(commentaire.body, allow_a: false)
.message-extras.flex.justify-start
- if commentaire.soft_deletable?(connected_user)

View file

@ -1,6 +1,7 @@
# see: https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/mise-en-avant
class Dsfr::CalloutComponent < ApplicationComponent
renders_one :body
renders_one :html_body
renders_one :bottom
attr_reader :title, :theme, :icon, :extra_class_names

View file

@ -1,5 +1,8 @@
%div{ class: callout_class }
- if title.present?
%h3.fr-callout__title= title
%p.fr-callout__text= body
- if html_body?
.fr-callout__text= html_body
- if body?
%p.fr-callout__text= body
= bottom

View file

@ -6,7 +6,7 @@ class Dsfr::InputComponent < ApplicationComponent
# it uses aria-describedby on input and link it to yielded content
renders_one :describedby
def initialize(form:, attribute:, input_type:, opts: {}, required: true)
def initialize(form:, attribute:, input_type: :text_field, opts: {}, required: true)
@form = form
@attribute = attribute
@input_type = input_type
@ -40,19 +40,21 @@ class Dsfr::InputComponent < ApplicationComponent
'fr-mb-0': true,
'fr-input--error': errors_on_attribute?))
if errors_on_attribute? || describedby
@opts = @opts.deep_merge(aria: {
describedby: error_message_id,
invalid: errors_on_attribute?
if errors_on_attribute? || describedby?
@opts.deep_merge!(aria: {
describedby: describedby_id,
invalid: errors_on_attribute?
})
end
if @required
@opts[:required] = true
end
if email?
@opts = @opts.deep_merge(data: {
@opts.deep_merge!(data: {
action: "blur->email-input#checkEmail",
'email-input-target': 'input'
'email-input-target': 'input'
})
end
@opts
@ -63,14 +65,14 @@ class Dsfr::InputComponent < ApplicationComponent
errors.has_key?(attribute_or_rich_body)
end
def error_message_id
dom_id(object, @attribute)
end
def error_messages
errors.full_messages_for(attribute_or_rich_body)
end
def describedby_id
dom_id(object, "#{@attribute}-messages")
end
# i18n lookups
def label
object.class.human_attribute_name(@attribute)
@ -89,6 +91,10 @@ class Dsfr::InputComponent < ApplicationComponent
@input_type == :email_field
end
def show_password_id
dom_id(object, "#{@attribute}_show_password")
end
private
def hint?

View file

@ -7,13 +7,13 @@
- if hint?
%span.fr-hint-text= hint
= @form.send(@input_type, @attribute, input_opts)
= @form.public_send(@input_type, @attribute, input_opts)
- if errors_on_attribute?
- if error_messages.size == 1
%p.fr-error-text{ id: error_message_id }= error_messages.first
%p.fr-error-text{ id: describedby_id }= error_messages.first
- else
.fr-error-text{ id: error_message_id }
.fr-error-text{ id: describedby_id }
%ul.list-style-type-none.fr-pl-0
- error_messages.map do |error_message|
%li= error_message
@ -23,8 +23,8 @@
- if password?
.fr-password__checkbox.fr-checkbox-group.fr-checkbox-group--sm
%input#show_password{ "aria-label" => t('.show_password.aria_label'), type: "checkbox" }/
%label.fr--password__checkbox.fr-label{ for: "show_password" }= t('.show_password.label')
%input{ id: show_password_id, "aria-label" => t('.show_password.aria_label'), type: "checkbox" }/
%label.fr--password__checkbox.fr-label{ for: show_password_id }= t('.show_password.label')
- if email?
.suspect-email.hidden{ data: { "email-input-target": 'ariaRegion'}, aria: { live: 'off' } }

View file

@ -1,3 +1,2 @@
class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent
end

View file

@ -1,6 +1,11 @@
- render_parent
= @form.hidden_field :value
= @form.hidden_field :external_id
= react_component("ComboAdresseSearch",
required: @champ.required?,
id: @champ.input_id,
describedby: @champ.describedby_id)
describedby: @champ.describedby_id,
**react_combo_props,
)

View file

@ -1,3 +1,2 @@
class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent
end

View file

@ -1,6 +1,9 @@
- render_parent
= @form.hidden_field :value
= @form.hidden_field :external_id
= react_component("ComboAnnuaireEducationSearch",
required: @champ.required?,
id: @champ.input_id,
describedby: @champ.describedby_id)
describedby: @champ.describedby_id,
**react_combo_props)

View file

@ -1,3 +1,9 @@
class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
def initialize(**args)
super(**args)
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args)
end
end

View file

@ -1,4 +1,11 @@
= react_component("MapEditor", featureCollection: @champ.to_feature_collection, url: champs_carte_features_path(@champ), options: @champ.render_options)
= render @autocomplete_component
= react_component("MapEditor",
featureCollection: @champ.to_feature_collection,
url: champs_carte_features_path(@champ),
options: @champ.render_options,
autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id,
autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions"))
.geo-areas{ id: dom_id(@champ, :geo_areas) }
= render partial: 'shared/champs/carte/geo_areas', locals: { champ: @champ, editing: true }

View file

@ -1,6 +1,4 @@
class EditableChamp::ChampLabelComponent < ApplicationComponent
include StringToHtmlHelper
def initialize(form:, champ:, seen_at: nil)
@form, @champ, @seen_at = form, champ, seen_at
end

View file

@ -7,4 +7,4 @@
= render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at
- if @champ.description.present?
.notice{ id: @champ.describedby_id }= string_to_html(@champ.description, allow_a: true)
.notice{ id: @champ.describedby_id }= render SimpleFormatComponent.new(@champ.description, allow_a: true)

View file

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

View file

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

View file

@ -1,3 +1,2 @@
class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
class EditableChamp::CommunesComponent < EditableChamp::ComboSearchComponent
end

View file

@ -1,3 +1,4 @@
- render_parent
= @form.hidden_field :value
= @form.hidden_field :external_id
= @form.hidden_field :departement
@ -7,4 +8,5 @@
id: @champ.input_id,
classNameDepartement: "width-33-desktop width-100-mobile",
className: "width-66-desktop width-100-mobile",
describedby: @champ.describedby_id)
describedby: @champ.describedby_id,
**react_combo_props)

View file

@ -1,6 +1,4 @@
class EditableChamp::EditableChampComponent < ApplicationComponent
include StringToHtmlHelper
def initialize(form:, champ:, seen_at: nil)
@form, @champ, @seen_at = form, champ, seen_at
end

View file

@ -2,7 +2,7 @@
- if @champ.block?
%h3.header-subsection= @champ.libelle
- if @champ.description.present?
%p.notice= string_to_html(@champ.description, false, allow_a: true)
.notice= render SimpleFormatComponent.new(@champ.description, allow_a: true)
- elsif has_label?(@champ)
= render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at

View file

@ -1,3 +1,2 @@
class EditableChamp::ExplicationComponent < EditableChamp::EditableChampBaseComponent
include StringToHtmlHelper
end

View file

@ -1,7 +1,7 @@
= render Dsfr::CalloutComponent.new(title: @champ.libelle, extra_class_names: ['fr-mb-2w', 'fr-callout--blue-cumulus']) do |c|
- c.with_body do
- c.with_html_body do
= string_to_html(@champ.description, allow_a: true)
= render SimpleFormatComponent.new(@champ.description, allow_a: true)
- if @champ.collapsible_explanation_enabled? && @champ.collapsible_explanation_text.present?
%div

View file

@ -1,2 +1,2 @@
%h2.header-section
= @champ.libelle_with_section_index
%h2.header-section{ class: @champ.dossier.auto_numbering_section_headers_for?(@champ) ? "header-section-counter" : nil }
= @champ.libelle

View file

@ -1,3 +1,2 @@
class EditableChamp::LinkedDropDownListComponent < EditableChamp::EditableChampBaseComponent
include StringToHtmlHelper
end

View file

@ -8,7 +8,7 @@
= @form.label :secondary_value, for: "#{@champ.input_id}-secondary" do
- sanitize((@champ.drop_down_secondary_libelle.presence || "Valeur secondaire dépendant de la première") + (@champ.type_de_champ.mandatory? ? tag.span(' *', class: 'mandatory') : ''))
- if @champ.drop_down_secondary_description.present?
.notice{ id: "#{@champ.describedby_id}-secondary" }= string_to_html(@champ.drop_down_secondary_description, allow_a: true)
.notice{ id: "#{@champ.describedby_id}-secondary" }= render SimpleFormatComponent.new(@champ.drop_down_secondary_description, allow_a: true)
= @form.select :secondary_value,
@champ.secondary_options[@champ.primary_value],
{},

View file

@ -2,11 +2,11 @@
id: @champ.input_id,
aria: { describedby: @champ.describedby_id },
placeholder: t(".placeholder"),
data: { controller: 'turbo-input', turbo_input_url_value: champs_rna_path(@champ.id) },
data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.data.blank?, turbo_input_url_value: champs_rna_path(@champ.id) },
required: @champ.required?,
pattern: "W[0-9]{9}",
title: t(".title"),
class: "width-33-desktop",
maxlength: 10
.rna-info{ id: dom_id(@champ, :rna_info) }
= render 'shared/champs/rna/association', champ: @champ, network_error: false, rna: @champ.value
= render 'shared/champs/rna/association', champ: @champ, error: nil

View file

@ -2,7 +2,7 @@
id: @champ.input_id,
aria: { describedby: @champ.describedby_id },
placeholder: t(".placeholder"),
data: { controller: 'turbo-input', turbo_input_url_value: champs_siret_path(@champ.id) },
data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.etablissement.blank?, turbo_input_url_value: champs_siret_path(@champ.id) },
required: @champ.required?,
pattern: "[0-9]{14}",
title: t(".title"),

View file

@ -0,0 +1,53 @@
class PasswordComplexityComponent < ApplicationComponent
def initialize(length: nil, min_length: nil, score: nil, min_complexity: nil)
@length = length
@min_length = min_length
@score = score
@min_complexity = min_complexity
end
private
def filled?
!@length.nil? || !@score.nil?
end
def alert_classes
class_names(
"fr-alert": true,
"fr-alert--sm": true,
"fr-alert--info": !success?,
"fr-alert--success": success?
)
end
def success?
return false if !filled?
@length >= @min_length && @score >= @min_complexity
end
def complexity_classes
[
"password-complexity fr-mt-2w fr-mb-1w",
filled? ? "complexity-#{@length < @min_length ? @score / 2 : @score}" : nil
]
end
def title
return t(".title.empty") if !filled?
return t(".title.too_short", min_length: @min_length) if @length < @min_length
case @score
when 0..1
return t(".title.weakest")
when 2...@min_complexity
return t(".title.weak")
when @min_complexity...4
return t(".title.passable")
else
return t(".title.strong")
end
end
end

View file

@ -0,0 +1,10 @@
---
en:
title:
empty: Enter a password.
too_short: Password must be at least %{min_length} characters long.
passable: Password is acceptable. You can validate… or improve your password.
strong: Congratulations! Password is strong and secure enough.
weak: Vulnerable password.
weakest: Very vulnerable password.
hint: A short sentence with punctuation can be a very secure password.

View file

@ -0,0 +1,10 @@
---
fr:
title:
empty: Inscrivez un mot de passe.
too_short: Le mot de passe doit faire au moins %{min_length} caractères.
passable: Mot de passe acceptable. Vous pouvez valider… ou améliorer votre mot de passe.
strong: Félicitations ! Mot de passe suffisamment fort et sécurisé.
weak: Mot de passe vulnérable.
weakest: Mot de passe très vulnérable.
hint: Une courte phrase avec ponctuation peut être un mot de passe très sécurisé.

View file

@ -0,0 +1,6 @@
%div{ class: complexity_classes }
%div{ class: alert_classes }
%h3.fr-alert__title= title
- if !success?
%p= t(".hint")

View file

@ -46,13 +46,13 @@
= t(".public.enable_mandatory", label: change.label)
- if !total_dossiers.zero? && !change.can_rebase?
%strong
= t(:breaking_change, count: total_dossiers)
= t('.breaking_change', count: total_dossiers)
- else
- list.with_item do
= t(".public.disable_mandatory", label: change.label)
- if !total_dossiers.zero? && !change.can_rebase?
%strong
= t(:breaking_change, count: total_dossiers)
= t('.breaking_change', count: total_dossiers)
- when :piece_justificative_template
- list.with_item do
= t(".#{prefix}.update_piece_justificative_template", label: change.label)
@ -115,19 +115,19 @@
= t(".#{prefix}.add_condition", label: change.label, to: change.to)
- if !total_dossiers.zero? && !change.can_rebase?
%strong
= t(:breaking_change, count: total_dossiers)
= t('.breaking_change', count: total_dossiers)
- elsif change.to.nil?
- list.with_item do
= t(".#{prefix}.remove_condition", label: change.label)
- if !total_dossiers.zero? && !change.can_rebase?
%strong
= t(:breaking_change, count: total_dossiers)
= t('.breaking_change', count: total_dossiers)
- else
- list.with_item do
= t(".#{prefix}.update_condition", label: change.label, to: change.to)
- if !total_dossiers.zero? && !change.can_rebase?
%strong
= t(:breaking_change, count: total_dossiers)
= t('.breaking_change', count: total_dossiers)
- if @public_move_changes.present?
- list.with_item do

View file

@ -0,0 +1,51 @@
class SimpleFormatComponent < ApplicationComponent
# see: https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use
REDCARPET_EXTENSIONS = {
no_intra_emphasis: false,
tables: false,
fenced_code_blocks: false,
autolink: false,
disable_indented_code_blocks: false,
strikethrough: false,
lax_spacing: false,
space_after_headers: false,
superscript: false,
underline: false,
highlight: false,
quote: false,
footnotes: false
}
# see: https://github.com/vmg/redcarpet#darling-i-packed-you-a-couple-renderers-for-lunch
REDCARPET_RENDERER_OPTS = {
no_images: true
}
def initialize(text, allow_a: true, class_names_map: {})
@text = (text || "").gsub(/\R/, "\n\n") # force double \n otherwise a single one won't split paragraph
.split("\n\n") #
.map(&:lstrip) # this block prevent redcarpet to consider " text" as block code by lstriping
.join("\n\n") #
@allow_a = allow_a
@renderer = Redcarpet::Markdown.new(
Redcarpet::BareRenderer.new(link_attributes: external_link_attributes, class_names_map: class_names_map),
REDCARPET_EXTENSIONS.merge(autolink: @allow_a)
)
end
def external_link_attributes
{ target: '_blank', rel: 'noopener noreferrer' }
end
def tags
if @allow_a
Rails.configuration.action_view.sanitized_allowed_tags + ['a']
else
Rails.configuration.action_view.sanitized_allowed_tags
end
end
def attributes
['target', 'rel', 'href', 'class']
end
end

View file

@ -0,0 +1 @@
= sanitize(@renderer.render(@text), tags:, attributes:)

View file

@ -29,6 +29,10 @@
= form.check_box :mandatory, class: 'small-margin small', id: dom_id(type_de_champ, :mandatory)
= form.label :mandatory, "Champ obligatoire", for: dom_id(type_de_champ, :mandatory)
= form.text_field :libelle, class: 'small-margin small width-100', id: dom_id(type_de_champ, :libelle), data: input_autofocus
- if type_de_champ.header_section?
%p
%small Nous numérotons automatiquement les titres lorsquaucun de vos titres ne commence par un chiffre.
- if !type_de_champ.header_section? && !type_de_champ.titre_identite?
.cell.mt-1
= form.label :description, "Description du champ (optionnel)", for: dom_id(type_de_champ, :description)

View file

@ -120,8 +120,6 @@ module Administrateurs
end
if instructeurs.present?
instructeurs.each { groupe_instructeur.add(_1) }
flash[:notice] = if procedure.routing_enabled?
t('.assignment',
count: instructeurs.size,
@ -130,6 +128,10 @@ module Administrateurs
else
"Les instructeurs ont bien été affectés à la démarche"
end
GroupeInstructeurMailer
.notify_added_instructeurs(groupe_instructeur, instructeurs, current_administrateur.email)
.deliver_later
end
if procedure.routing_enabled?
@ -146,15 +148,18 @@ module Administrateurs
instructeur = groupe_instructeur.instructeurs.find_by(id: instructeur_id)
if groupe_instructeur.remove(instructeur)
flash[:notice] = if procedure.routing_enabled?
GroupeInstructeurMailer
.remove_instructeurs(groupe_instructeur, [instructeur], current_administrateur.email)
.deliver_later
flash[:notice] = if instructeur.in?(procedure.instructeurs)
"Linstructeur « #{instructeur.email} » a été retiré du groupe."
else
"Linstructeur a bien été désaffecté de la démarche"
end
GroupeInstructeurMailer
.notify_removed_instructeur(groupe_instructeur, instructeur, current_administrateur.email)
.deliver_later
GroupeInstructeurMailer
.notify_group_when_instructeurs_removed(groupe_instructeur, [instructeur], current_administrateur.email)
.deliver_later
else
flash[:alert] = if procedure.routing_enabled?
if instructeur.present?
@ -193,33 +198,56 @@ module Administrateurs
def import
if procedure.publiee_or_close?
if !CSV_ACCEPTED_CONTENT_TYPES.include?(group_csv_file.content_type) && !CSV_ACCEPTED_CONTENT_TYPES.include?(marcel_content_type)
if !CSV_ACCEPTED_CONTENT_TYPES.include?(csv_file.content_type) && !CSV_ACCEPTED_CONTENT_TYPES.include?(marcel_content_type)
flash[:alert] = "Importation impossible : veuillez importer un fichier CSV"
elsif group_csv_file.size > CSV_MAX_SIZE
elsif csv_file.size > CSV_MAX_SIZE
flash[:alert] = "Importation impossible : le poids du fichier est supérieur à #{number_to_human_size(CSV_MAX_SIZE)}"
else
file = group_csv_file.read
file = csv_file.read
base_encoding = CharlockHolmes::EncodingDetector.detect(file)
groupes_emails = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase)
.map { |r| r.to_h.slice('groupe', 'email') }
groupes_emails_has_keys = groupes_emails.first.has_key?("groupe") && groupes_emails.first.has_key?("email")
if params[:group_csv_file]
groupes_emails = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase)
.map { |r| r.to_h.slice('groupe', 'email') }
if groupes_emails_has_keys.blank?
flash[:alert] = "Importation impossible, veuillez importer un csv #{view_context.link_to('suivant ce modèle', "/csv/#{I18n.locale}/import-groupe-test.csv")}"
else
add_instructeurs_and_get_errors = InstructeursImportService.import(procedure, groupes_emails)
groupes_emails_has_keys = groupes_emails.first.has_key?("groupe") && groupes_emails.first.has_key?("email")
if add_instructeurs_and_get_errors.empty?
flash[:notice] = "La liste des instructeurs a été importée avec succès"
if groupes_emails_has_keys.blank?
flash[:alert] = "Importation impossible, veuillez importer un csv #{view_context.link_to('suivant ce modèle', "/csv/#{I18n.locale}/import-groupe-test.csv")}"
else
flash[:alert] = "Import terminé. Cependant les emails suivants ne sont pas pris en compte: #{add_instructeurs_and_get_errors.join(', ')}"
added_instructeurs_by_group, invalid_emails = InstructeursImportService.import_groupes(procedure, groupes_emails)
added_instructeurs_by_group.each do |groupe, added_instructeurs|
GroupeInstructeurMailer
.notify_added_instructeurs(groupe, added_instructeurs, current_administrateur.email)
.deliver_later
end
flash_message_for_import(invalid_emails)
end
elsif params[:instructeurs_csv_file]
instructors_emails = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase)
.map(&:to_h)
instructors_emails_has_key = instructors_emails.first.has_key?("email") && !instructors_emails.first.keys.many?
if instructors_emails_has_key.blank?
flash[:alert] = "Importation impossible, veuillez importer un csv #{view_context.link_to('suivant ce modèle', "/csv/import-instructeurs-test.csv")}"
else
added_instructeurs, invalid_emails = InstructeursImportService.import_instructeurs(procedure, instructors_emails)
GroupeInstructeurMailer
.notify_added_instructeurs(groupe_instructeur, added_instructeurs, current_administrateur.email)
.deliver_later
flash_message_for_import(invalid_emails)
end
end
redirect_to admin_procedure_groupe_instructeurs_path(procedure)
end
redirect_to admin_procedure_groupe_instructeurs_path(procedure)
end
end
@ -291,12 +319,12 @@ module Administrateurs
(all - assigned).sort
end
def group_csv_file
params[:group_csv_file]
def csv_file
params[:group_csv_file] || params[:instructeurs_csv_file]
end
def marcel_content_type
Marcel::MimeType.for(group_csv_file.read, name: group_csv_file.original_filename, declared_type: group_csv_file.content_type)
Marcel::MimeType.for(csv_file.read, name: csv_file.original_filename, declared_type: csv_file.content_type)
end
def instructeurs_self_management_enabled_params
@ -306,5 +334,13 @@ module Administrateurs
def routing_enabled_params
{ routing_enabled: params.require(:routing) == 'enable' }
end
def flash_message_for_import(result)
if result.blank?
flash[:notice] = "La liste des instructeurs a été importée avec succès"
else
flash[:alert] = "Import terminé. Cependant les emails suivants ne sont pas pris en compte: #{result.join(', ')}"
end
end
end
end

View file

@ -96,8 +96,20 @@ module Administrateurs
@procedure = current_administrateur
.procedures
.includes(
published_revision: :types_de_champ,
draft_revision: :types_de_champ
published_revision: {
types_de_champ: [],
revision_types_de_champ: { type_de_champ: { piece_justificative_template_attachment: :blob } }
},
draft_revision: {
types_de_champ: [],
revision_types_de_champ: { type_de_champ: { piece_justificative_template_attachment: :blob } }
},
attestation_template: [],
initiated_mail: [],
received_mail: [],
closed_mail: [],
refused_mail: [],
without_continuation_mail: []
)
.find(params[:id])
@ -332,7 +344,35 @@ module Administrateurs
end
def champs
@procedure = Procedure.includes(draft_revision: { revision_types_de_champ_public: :type_de_champ }).find(@procedure.id)
@procedure = Procedure.includes(draft_revision: {
revision_types_de_champ: {
type_de_champ: { piece_justificative_template_attachment: :blob, revision: [], procedure: [] },
revision: [],
procedure: []
},
revision_types_de_champ_public: {
type_de_champ: { piece_justificative_template_attachment: :blob, revision: [], procedure: [] },
revision: [],
procedure: []
},
procedure: []
}).find(@procedure.id)
end
def annotations
@procedure = Procedure.includes(draft_revision: {
revision_types_de_champ: {
type_de_champ: { piece_justificative_template_attachment: :blob, revision: [], procedure: [] },
revision: [],
procedure: []
},
revision_types_de_champ_private: {
type_de_champ: { piece_justificative_template_attachment: :blob, revision: [], procedure: [] },
revision: [],
procedure: []
},
procedure: []
}).find(@procedure.id)
end
def detail
@ -370,7 +410,7 @@ module Administrateurs
private
def filter_procedures(filter)
procedures_result = Procedure.select(:id).joins(:procedures_zones).distinct.publiees_ou_closes
procedures_result = Procedure.select(:id).left_joins(:procedures_zones).distinct.publiees_ou_closes
procedures_result = procedures_result.where(procedures_zones: { zone_id: filter.zone_ids }) if filter.zone_ids.present?
procedures_result = procedures_result.where(aasm_state: filter.statuses) if filter.statuses.present?
procedures_result = procedures_result.where("? = ANY(tags)", filter.tag) if filter.tag.present?
@ -378,7 +418,7 @@ module Administrateurs
procedures_result = procedures_result.where('unaccent(libelle) ILIKE unaccent(?)', "%#{filter.libelle}%") if filter.libelle.present?
procedures_sql = procedures_result.to_sql
sql = "select id, libelle, published_at, aasm_state, count(administrateurs_procedures.administrateur_id) as admin_count from administrateurs_procedures inner join procedures on procedures.id = administrateurs_procedures.procedure_id where procedures.id in (#{procedures_sql}) group by procedures.id order by published_at desc"
sql = "select id, libelle, published_at, aasm_state, estimated_dossiers_count, count(administrateurs_procedures.administrateur_id) as admin_count from administrateurs_procedures inner join procedures on procedures.id = administrateurs_procedures.procedure_id where procedures.id in (#{procedures_sql}) group by procedures.id order by published_at desc"
ActiveRecord::Base.connection.execute(sql)
end

View file

@ -11,18 +11,43 @@ class API::Public::V1::DossiersController < API::Public::V1::BaseController
dossier.build_default_individual
if dossier.save
dossier.prefill!(PrefillParams.new(dossier, params.to_unsafe_h).to_a)
render json: {
dossier_url: commencer_url(@procedure.path, prefill_token: dossier.prefill_token),
dossier_id: dossier.to_typed_id,
dossier_number: dossier.id
}, status: :created
render json: serialize_dossier(dossier), status: :created
else
render_bad_request(dossier.errors.full_messages.to_sentence)
end
end
def index
prefill_token = Array.wrap(params.fetch(:prefill_token, [])).flat_map { _1.split(',') }
dossiers = @procedure.dossiers.visible_by_user.prefilled.order(:created_at).where(prefill_token:)
if dossiers.present?
render json: dossiers.map { serialize_dossier(_1) }
else
render json: []
end
end
private
def serialize_dossier(dossier)
if dossier.orphan?
{
dossier_url: commencer_url(@procedure.path, prefill_token: dossier.prefill_token),
state: :prefilled
}
else
{
state: dossier.state,
submitted_at: dossier.depose_at&.iso8601,
processed_at: dossier.processed_at&.iso8601
}
end.merge(
dossier_id: dossier.to_typed_id,
dossier_number: dossier.id,
dossier_prefill_token: dossier.prefill_token
).compact
end
def retrieve_procedure
@procedure = Procedure.publiees_ou_brouillons.find_by(id: params[:id])
render_not_found("procedure", params[:id]) if @procedure.blank?

View file

@ -337,6 +337,8 @@ class ApplicationController < ActionController::Base
extract_locale_from_accept_language_header ||
I18n.default_locale
gon.locale = locale
I18n.with_locale(locale, &action)
end

View file

@ -3,14 +3,10 @@ class Champs::RNAController < ApplicationController
def show
@champ = policy_scope(Champ).find(params[:champ_id])
@rna = read_param_value(@champ.input_name, 'value')
@network_error = false
begin
data = APIEntreprise::RNAAdapter.new(@rna, @champ.procedure_id).to_params
@champ.update!(data: data, value: @rna)
rescue APIEntreprise::API::Error, ActiveRecord::RecordInvalid => error
@network_error = true if error.try(:network_error?) && !APIEntrepriseService.api_up?
@champ.update(data: nil, value: nil)
rna = read_param_value(@champ.input_name, 'value')
unless @champ.fetch_association!(rna)
@error = @champ.association_fetch_error_key
end
end
end

View file

@ -3,61 +3,11 @@ class Champs::SiretController < ApplicationController
def show
@champ = policy_scope(Champ).find(params[:champ_id])
@siret = read_param_value(@champ.input_name, 'value')
@etablissement = @champ.etablissement
if @siret.empty?
return clear_siret_and_etablissement
if @champ.fetch_etablissement!(read_param_value(@champ.input_name, 'value'), current_user)
@siret = @champ.etablissement.siret
else
@siret = @champ.etablissement_fetch_error_key
end
if !Siret.new(siret: @siret).valid?
# i18n-tasks-use t('errors.messages.invalid_siret')
return siret_error(:invalid)
end
begin
etablissement = find_etablissement_with_siret
rescue => error
if error.try(:network_error?) && !APIEntrepriseService.api_up?
# TODO: notify ops
etablissement = APIEntrepriseService.create_etablissement_as_degraded_mode(@champ, @siret, current_user.id)
if !@champ.nil?
@champ.update!(value: etablissement.siret, etablissement: etablissement)
end
@siret = :api_entreprise_down
return
else
Sentry.capture_exception(error, extra: { dossier_id: @champ.dossier_id, siret: @siret })
# i18n-tasks-use t('errors.messages.siret_network_error')
return siret_error(:network_error)
end
end
if etablissement.nil?
# i18n-tasks-use t('errors.messages.siret_not_found')
return siret_error(:not_found)
end
@etablissement = etablissement
if !@champ.nil?
@champ.update!(value: etablissement.siret, etablissement: etablissement)
end
end
private
def find_etablissement_with_siret
APIEntrepriseService.create_etablissement(@champ, @siret, current_user.id)
end
def clear_siret_and_etablissement
@champ.update!(value: '')
@etablissement&.destroy
end
def siret_error(error)
clear_siret_and_etablissement
@siret = error
end
end

View file

@ -22,6 +22,10 @@ module Instructeurs
else
groupe_instructeur.add(instructeur)
flash[:notice] = "Linstructeur « #{instructeur_email} » a été affecté au groupe."
GroupeInstructeurMailer
.notify_added_instructeurs(groupe_instructeur, [instructeur], current_user.email)
.deliver_later
end
redirect_to instructeur_groupe_path(procedure, groupe_instructeur)
@ -35,7 +39,11 @@ module Instructeurs
if groupe_instructeur.remove(instructeur)
flash[:notice] = "Linstructeur « #{instructeur.email} » a été retiré du groupe."
GroupeInstructeurMailer
.remove_instructeurs(groupe_instructeur, [instructeur], current_user.email)
.notify_removed_instructeur(groupe_instructeur, instructeur, current_user.email)
.deliver_later
GroupeInstructeurMailer
.notify_group_when_instructeurs_removed(groupe_instructeur, [instructeur], current_user.email)
.deliver_later
else
flash[:alert] = "Linstructeur « #{instructeur.email} » nest pas dans le groupe."

View file

@ -12,6 +12,6 @@ class PrefillTypeDeChampsController < ApplicationController
end
def set_prefill_type_de_champ
@type_de_champ = TypesDeChamp::PrefillTypeDeChamp.build(@procedure.active_revision.types_de_champ_public.fillable.find(params[:id]))
@type_de_champ = TypesDeChamp::PrefillTypeDeChamp.build(@procedure.active_revision.types_de_champ.fillable.find(params[:id]), @procedure.active_revision)
end
end

View file

@ -42,10 +42,10 @@ class API::V2::Context < GraphQL::Query::Context
return true
end
# We are caching authorization logic because it is called for each node
# of the requested graph and can be expensive. Context is reset per request so it is safe.
self[:authorized] ||= Hash.new do |hash, demarche_id|
hash[demarche_id] = if self[:administrateur_id]
self[:authorized] ||= {}
if self[:authorized][demarche.id].nil?
self[:authorized][demarche.id] = if self[:administrateur_id]
demarche.administrateurs.map(&:id).include?(self[:administrateur_id])
elsif self[:token]
APIToken.find_and_verify(self[:token], demarche.administrateurs).present?

View file

@ -288,6 +288,7 @@ class API::V2::StoredQuery
dateFermeture
notice { url }
deliberation { url }
demarcheUrl
cadreJuridiqueUrl
service @include(if: $includeService) {
...ServiceFragment

View file

@ -11,9 +11,8 @@ module Mutations
def resolve(groupe_instructeur:, instructeurs:)
ids, emails = partition_instructeurs_by(instructeurs)
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(ids:, emails:)
_, invalid_emails = groupe_instructeur.add_instructeurs(ids:, emails:)
instructeurs.each { groupe_instructeur.add(_1) }
groupe_instructeur.reload
result = { groupe_instructeur: }

View file

@ -29,9 +29,8 @@ module Mutations
result = { groupe_instructeur: }
if emails.present? || ids.present?
instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(ids:, emails:)
_, invalid_emails = groupe_instructeur.add_instructeurs(ids:, emails:)
instructeurs.each { groupe_instructeur.add(_1) }
groupe_instructeur.reload
if invalid_emails.present?

View file

@ -17,7 +17,7 @@ module Mutations
if groupe_instructeur.procedure.routing_enabled? && instructeurs.present?
GroupeInstructeurMailer
.remove_instructeurs(groupe_instructeur, instructeurs, current_administrateur.email)
.notify_group_when_instructeurs_removed(groupe_instructeur, instructeurs, current_administrateur.email)
.deliver_later
end

View file

@ -6,7 +6,10 @@ module Types::Champs
def etablissement
if object.etablissement_id.present?
Loaders::Record.for(Etablissement).load(object.etablissement_id)
Loaders::Record.for(Etablissement).load(object.etablissement_id).then do |etablissement|
return nil if etablissement.as_degraded_mode?
etablissement
end
end
end
end

View file

@ -75,7 +75,11 @@ Cela évite laccès récursif aux dossiers."
delegate :description, :opendata, :tags, to: :procedure
def demarche_url
procedure.lien_demarche
if procedure.brouillon?
Rails.application.routes.url_helpers.commencer_test_url(path: procedure.path)
else
Rails.application.routes.url_helpers.commencer_url(path: procedure.path)
end
end
def dpo_url

View file

@ -12,7 +12,7 @@ module Types
global_id_field :id
field :source, GeoAreaSource, null: false
field :geometry, Types::GeoJSON, null: false, method: :safe_geometry
field :geometry, Types::GeoJSON, null: false
field :description, String, null: true
definition_methods do

View file

@ -19,7 +19,7 @@ module Types
field :demarches_publiques, DemarcheDescriptorType.connection_type, null: false, internal: true
def demarches_publiques
Procedure.opendata.includes(draft_revision: :procedure, published_revision: :procedure)
Procedure.publiees_ou_closes.opendata.includes(draft_revision: :procedure, published_revision: :procedure)
end
def demarche_descriptor(demarche:)

View file

@ -142,7 +142,7 @@ module ApplicationHelper
end
def new_tab_suffix(title)
"#{title}#{t('utils.new_tab')}"
"#{title}#{I18n.t('utils.new_tab')}"
end
def download_details(attachment)

View file

@ -0,0 +1,6 @@
module SanitizeWithLinkHelper
def sanitize_with_link(value)
tags = Rails.configuration.action_view.sanitized_allowed_tags + ['a']
sanitize(value, tags:)
end
end

View file

@ -1,15 +0,0 @@
module StringToHtmlHelper
def string_to_html(str, wrapper_tag = 'p', allow_a: false)
return nil if str.blank?
html_formatted = simple_format(str, {}, { wrapper_tag: wrapper_tag })
with_links = Anchored::Linker.auto_link(html_formatted, target: '_blank', rel: 'noopener')
tags = if allow_a
Rails.configuration.action_view.sanitized_allowed_tags + ['a']
else
Rails.configuration.action_view.sanitized_allowed_tags
end
sanitize(with_links, tags:, attributes: ['target', 'rel', 'href'])
end
end

View file

@ -1,4 +1,10 @@
import React, { useState, useRef, ChangeEventHandler } from 'react';
import React, {
useState,
useEffect,
useRef,
useId,
ChangeEventHandler
} from 'react';
import { useDebounce } from 'use-debounce';
import { useQuery } from 'react-query';
import {
@ -18,7 +24,7 @@ type TransformResult<Result> = (
result: Result
) => [key: string, value: string, label?: string];
export type ComboSearchProps<Result> = {
export type ComboSearchProps<Result = unknown> = {
onChange?: (value: string | null, result?: Result) => void;
value?: string;
scope: string;
@ -32,6 +38,8 @@ export type ComboSearchProps<Result> = {
className?: string;
placeholder?: string;
debounceDelay?: number;
screenReaderInstructions: string;
announceTemplateId: string;
};
type QueryKey = readonly [
@ -51,6 +59,8 @@ function ComboSearch<Result>({
transformResults = (_, results) => results as Result[],
id,
describedby,
screenReaderInstructions,
announceTemplateId,
debounceDelay = 0,
...props
}: ComboSearchProps<Result>) {
@ -127,6 +137,46 @@ function ComboSearch<Result>({
}
};
const [announceLive, setAnnounceLive] = useState('');
const announceTimeout = useRef<ReturnType<typeof setTimeout>>();
const announceTemplate = document.querySelector<HTMLTemplateElement>(
`#${announceTemplateId}`
);
invariant(announceTemplate, `Missing #${announceTemplateId}`);
const announceFragment = useRef(
announceTemplate.content.cloneNode(true) as DocumentFragment
).current;
useEffect(() => {
if (isSuccess) {
const slot = announceFragment.querySelector<HTMLSlotElement>(
'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]'
);
if (!slot) {
return;
}
const countSlot =
slot.querySelector<HTMLSlotElement>('slot[name="count"]');
if (countSlot) {
countSlot.replaceWith(String(results.length));
}
setAnnounceLive(slot.textContent ?? '');
}
announceTimeout.current = setTimeout(() => {
setAnnounceLive('');
}, 3000);
return () => clearTimeout(announceTimeout.current);
}, [announceFragment, results.length, isSuccess]);
const initInstrId = useId();
const resultsId = useId();
return (
<Combobox onSelect={handleOnSelect}>
<ComboboxInput
@ -136,10 +186,11 @@ function ComboSearch<Result>({
value={value ?? ''}
autocomplete={false}
id={id}
aria-describedby={describedby}
aria-describedby={describedby ?? initInstrId}
aria-owns={resultsId}
/>
{isSuccess && (
<ComboboxPopover className="shadow-popup">
<ComboboxPopover id={resultsId} className="shadow-popup">
{results.length > 0 ? (
<ComboboxList>
{results.map((result, index) => {
@ -156,6 +207,14 @@ function ComboSearch<Result>({
)}
</ComboboxPopover>
)}
{!describedby && (
<span id={initInstrId} className="hidden">
{screenReaderInstructions}
</span>
)}
<div aria-live="assertive" className="sr-only">
{announceLive}
</div>
</Combobox>
);
}

View file

@ -2,8 +2,14 @@ import React from 'react';
import { fire } from '@utils';
import ComboAdresseSearch from '../../ComboAdresseSearch';
import { ComboSearchProps } from '~/components/ComboSearch';
export function AddressInput() {
export function AddressInput(
comboProps: Pick<
ComboSearchProps,
'screenReaderInstructions' | 'announceTemplateId'
>
) {
return (
<div
style={{
@ -17,6 +23,7 @@ export function AddressInput() {
onChange={(_, feature) => {
fire(document, 'map:zoom', { feature });
}}
{...comboProps}
/>
</div>
);

View file

@ -41,9 +41,11 @@ export function PointInput() {
type="button"
className="button mr-1"
onClick={getCurrentPosition}
title="Localiser votre position"
title="Afficher votre position sur la carte"
>
<span className="sr-only">Localiser votre position</span>
<span className="sr-only">
Afficher votre position sur la carte
</span>
<LocationMarkerIcon className="icon-size-big" aria-hidden />
</button>
) : null}

View file

@ -12,15 +12,20 @@ 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,
options
options,
autocompleteAnnounceTemplateId,
autocompleteScreenReaderInstructions
}: {
featureCollection: FeatureCollection;
url: string;
options: { layers: string[] };
autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId'];
autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions'];
}) {
const [cadastreEnabled, setCadastreEnabled] = useState(false);
@ -46,7 +51,10 @@ export default function MapEditor({
{error && <FlashMessage message={error} level="alert" fixed={true} />}
<ImportFileInput featureCollection={featureCollection} {...actions} />
<AddressInput />
<AddressInput
screenReaderInstructions={autocompleteScreenReaderInstructions}
announceTemplateId={autocompleteAnnounceTemplateId}
/>
<MapLibre layers={options.layers}>
<DrawLayer

View file

@ -26,25 +26,43 @@ type QueryKey = readonly [
];
function buildURL(scope: string, term: string, extra?: string) {
term = encodeURIComponent(term.replace(/\(|\)/g, ''));
if (scope === 'adresse') {
return `${api_adresse_url}/search?q=${term}&limit=${API_ADRESSE_QUERY_LIMIT}`;
} else if (scope === 'annuaire-education') {
return `${api_education_url}/search?dataset=fr-en-annuaire-education&q=${term}&rows=${API_EDUCATION_QUERY_LIMIT}`;
} else if (scope === 'communes') {
const limit = `limit=${API_GEO_COMMUNES_QUERY_LIMIT}`;
const url = extra
? `${api_geo_url}/communes?codeDepartement=${extra}&${limit}&`
: `${api_geo_url}/communes?${limit}&`;
if (isNumeric(term)) {
return `${url}codePostal=${term}`;
term = term.replace(/\(|\)/g, '');
const params = new URLSearchParams();
let path = `${api_geo_url}/${scope}`;
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');
} else if (scope == 'communes') {
if (extra) {
params.set('codeDepartement', extra);
}
return `${url}nom=${term}&boost=population`;
} else if (isNumeric(term)) {
const code = term.padStart(2, '0');
return `${api_geo_url}/${scope}?code=${code}&limit=${API_GEO_QUERY_LIMIT}`;
if (isNumeric(term)) {
params.set('codePostal', term);
} else {
params.set('nom', term);
params.set('boost', 'population');
}
params.set('limit', `${API_GEO_COMMUNES_QUERY_LIMIT}`);
} else {
if (isNumeric(term)) {
params.set('code', term.padStart(2, '0'));
} else {
params.set('nom', term);
}
if (scope == 'departements') {
params.set('zone', 'metro,drom,com');
}
params.set('limit', `${API_GEO_QUERY_LIMIT}`);
}
return `${api_geo_url}/${scope}?nom=${term}&limit=${API_GEO_QUERY_LIMIT}`;
return `${path}?${params}`;
}
const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
@ -55,6 +73,16 @@ const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
return matchSorter(await getPays(signal), term, { keys: ['label'] });
}
// 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, extra);
return httpRequest(url, { csrf: false, signal }).json();
};

View file

@ -9,6 +9,10 @@ type StreamRenderEvent = CustomEvent<{
render(streamElement: StreamElement): void;
}>;
type FrameRenderEvent = CustomEvent<{
render(currentElement: Element, newElement: Element): void;
}>;
export class TurboController extends ApplicationController {
static targets = ['spinner'];
@ -29,7 +33,7 @@ export class TurboController extends ApplicationController {
connect() {
this.#actions = new Actions({
element: document.documentElement,
element: document.body,
schema: { forceAttribute: 'data-turbo-force', hiddenClassName: 'hidden' },
debug: false
});
@ -46,14 +50,22 @@ export class TurboController extends ApplicationController {
// prevent scroll on turbo form submits
this.onGlobal('turbo:render', () => this.preventScrollIfNeeded());
// reset state preserved for actions between pages
this.onGlobal('turbo:load', () => this.actions.reset());
// see: https://turbo.hotwired.dev/handbook/streams#custom-actions
this.onGlobal('turbo:before-stream-render', (event: StreamRenderEvent) => {
event.detail.render = (streamElement: StreamElement) =>
this.actions.applyActions([parseTurboStream(streamElement)]);
});
// see: https://turbo.hotwired.dev/handbook/frames#custom-rendering
this.onGlobal('turbo:before-frame-render', (event: FrameRenderEvent) => {
event.detail.render = (currentElement, newElement) => {
// There is a bug in morphdom when it comes to mutate a custom element. It will miserably
// crash. We mutate its content instead.
const fragment = document.createDocumentFragment();
fragment.append(...newElement.childNodes);
this.actions.update({ targets: [currentElement], fragment });
};
});
}
private startSpinner() {

View file

@ -4,13 +4,18 @@ import { ApplicationController } from './application_controller';
export class TurboInputController extends ApplicationController {
static values = {
url: String
url: String,
loadOnConnect: { type: Boolean, default: false }
};
declare readonly urlValue: string;
declare readonly loadOnConnectValue: boolean;
connect(): void {
this.on('input', () => this.debounce(this.load, 200));
if (this.loadOnConnectValue) {
this.load();
}
}
private load(): void {

View file

@ -1,3 +1,5 @@
import invariant from 'tiny-invariant';
const PENDING_CLASS = 'direct-upload--pending';
const ERROR_CLASS = 'direct-upload--error';
const COMPLETE_CLASS = 'direct-upload--complete';
@ -18,7 +20,7 @@ export default class ProgressBar {
static init(input: HTMLInputElement, id: string, file: File) {
clearErrors(input);
const html = this.render(id, file.name);
input.insertAdjacentHTML('beforebegin', html);
input.before(html);
}
static start(id: string) {
@ -53,10 +55,24 @@ export default class ProgressBar {
}
static render(id: string, filename: string) {
return `<div id="direct-upload-${id}" class="direct-upload ${PENDING_CLASS}" data-direct-upload-id="${id}">
<div role="progressbar" aria-valuemin="0" aria-valuemax="100" class="direct-upload__progress" style="width: 0%"></div>
<span class="direct-upload__filename">${filename}</span>
</div>`;
const template = document.querySelector<HTMLTemplateElement>(
'#progress-bar-template'
);
invariant(template, 'Missing progress-bar-template');
const fragment = template.content.cloneNode(true) as DocumentFragment;
const container = fragment.querySelector<HTMLDivElement>('.direct-upload');
invariant(container, 'Missing .direct-upload element in template');
const slot = container.querySelector<HTMLSlotElement>(
'slot[name="filename"]'
);
invariant(slot, 'Missing "filename" slot in template');
container.id = `direct-upload-${id}`;
container.dataset.directUploadId = id;
container.classList.add(PENDING_CLASS);
slot.replaceWith(document.createTextNode(filename));
return container;
}
id: string;

View file

@ -1,11 +1,15 @@
import { getConfig } from '@utils';
const {
crisp: { key, enabled, administrateur }
crisp: { key, enabled, administrateur },
locale
} = getConfig();
declare const window: Window &
typeof globalThis & {
CRISP_WEBSITE_ID?: string | null;
CRISP_RUNTIME_CONFIG?: {
locale: string;
};
$crisp: (
| [cmd: string, key: string, value: unknown]
| [key: string, value: unknown]
@ -15,6 +19,9 @@ declare const window: Window &
if (enabled) {
window.$crisp = [];
window.CRISP_WEBSITE_ID = key;
window.CRISP_RUNTIME_CONFIG = {
locale: locale
};
const script = document.createElement('script');
const firstScript = document.getElementsByTagName('script')[0];

View file

@ -16,6 +16,7 @@ const Gon = z
api_education_url: z.string().optional()
})
.default({}),
locale: z.string().default('fr'),
matomo: z
.object({
cookieDomain: z.string().optional(),

View file

@ -1,11 +1,9 @@
class ChampFetchExternalDataJob < ApplicationJob
def perform(champ, external_id)
if champ.external_id == external_id && champ.data.nil?
data = champ.fetch_external_data
return if champ.external_id != external_id
return if champ.data.present?
return if (data = champ.fetch_external_data).blank?
if data.present?
champ.update!(data: data)
end
end
champ.update_with_external_data!(data: data)
end
end

View file

@ -0,0 +1,24 @@
class Migrations::NormalizeDepartementsWithEmptyExternalIdJob < ApplicationJob
def perform(ids)
Champs::DepartementChamp.where(id: ids).find_each do |champ|
next unless champ.external_id == ''
if champ.value.nil?
champ.update_columns(external_id: nil)
elsif champ.value == ''
champ.update_columns(external_id: nil, value: nil)
elsif champ.value == '85'
champ.update_columns(external_id: '85', value: 'Vendée')
elsif champ.value.present?
match = champ.value.match(/^(\w{2,3}) - (.+)/)
if match
code = match[1]
name = APIGeoService.departement_name(code)
champ.update_columns(external_id: code, value: name)
else
champ.update_columns(external_id: APIGeoService.departement_code(champ.value))
end
end
end
end
end

View file

@ -0,0 +1,22 @@
class Migrations::NormalizeDepartementsWithNilExternalIdJob < ApplicationJob
def perform(ids)
Champs::DepartementChamp.where(id: ids).find_each do |champ|
next unless champ.external_id.nil?
if champ.value == ''
champ.update_columns(value: nil)
elsif champ.value == '85'
champ.update_columns(external_id: '85', value: 'Vendée')
elsif champ.value.present?
match = champ.value.match(/^(\w{2,3}) - (.+)/)
if match
code = match[1]
name = APIGeoService.departement_name(code)
champ.update_columns(external_id: code, value: name)
else
champ.update_columns(external_id: APIGeoService.departement_code(champ.value))
end
end
end
end
end

View file

@ -0,0 +1,15 @@
class Migrations::NormalizeDepartementsWithPresentExternalIdJob < ApplicationJob
def perform(ids)
Champs::DepartementChamp.where(id: ids).find_each do |champ|
next if champ.external_id.blank?
if champ.value.blank?
champ.update_columns(value: APIGeoService.departement_name(champ.external_id))
elsif (match = champ.value.match(/^(\w{2,3}) - (.+)/))
code = match[1]
name = APIGeoService.departement_name(code)
champ.update_columns(external_id: code, value: name)
end
end
end
end

View file

@ -0,0 +1,11 @@
class Migrations::NormalizeGeoAreaJob < ApplicationJob
def perform(ids)
GeoArea.where(id: ids).find_each do |geo_area|
geojson = RGeo::GeoJSON.decode(geo_area.geometry.to_json, geo_factory: RGeo::Geographic.simple_mercator_factory)
geometry = RGeo::GeoJSON.encode(geojson)
geo_area.update_column(:geometry, geometry)
rescue RGeo::Error::InvalidGeometry
geo_area.destroy
end
end
end

View file

@ -0,0 +1,21 @@
module Redcarpet
class BareRenderer < Redcarpet::Render::HTML
include ActionView::Helpers::TagHelper
# won't use rubocop tag method because it is missing output buffer
# rubocop:disable Rails/ContentTag
def list(content, list_type)
tag = list_type == :ordered ? :ol : :ul
content_tag(tag, content, { class: @options[:class_names_map].fetch(:list) {} }, false)
end
def list_item(content, list_type)
content_tag(:li, content.strip.gsub(/<\/?p>/, ''), {}, false)
end
def paragraph(text)
content_tag(:p, text, { class: @options[:class_names_map].fetch(:paragraph) {} }, false)
end
# rubocop:enable Rails/ContentTag
end
end

View file

@ -1,7 +1,7 @@
class GroupeInstructeurMailer < ApplicationMailer
layout 'mailers/layout'
def remove_instructeurs(group, removed_instructeurs, current_instructeur_email)
def notify_group_when_instructeurs_removed(group, removed_instructeurs, current_instructeur_email)
@removed_instructeur_emails = removed_instructeurs.map(&:email)
@group = group
@current_instructeur_email = current_instructeur_email
@ -11,4 +11,31 @@ class GroupeInstructeurMailer < ApplicationMailer
emails = @group.instructeurs.map(&:email)
mail(bcc: emails, subject: subject)
end
def notify_removed_instructeur(group, removed_instructeur, current_instructeur_email)
@group = group
@current_instructeur_email = current_instructeur_email
@still_assigned_to_procedure = removed_instructeur.in?(group.procedure.instructeurs)
subject = if @still_assigned_to_procedure
"Vous avez été retiré(e) du groupe \"#{group.label}\" de la démarche \"#{group.procedure.libelle}\""
else
"Vous avez été désaffecté(e) de la démarche \"#{group.procedure.libelle}\""
end
mail(to: removed_instructeur.email, subject: subject)
end
def notify_added_instructeurs(group, added_instructeurs, current_instructeur_email)
added_instructeur_emails = added_instructeurs.map(&:email)
@group = group
@current_instructeur_email = current_instructeur_email
subject = if group.procedure.groupe_instructeurs.many?
"Vous avez été ajouté(e) au groupe \"#{group.label}\" de la démarche \"#{group.procedure.libelle}\""
else
"Vous avez été affecté(e) à la démarche \"#{group.procedure.libelle}\""
end
mail(bcc: added_instructeur_emails, subject: subject)
end
end

View file

@ -7,6 +7,7 @@
#
class NotificationMailer < ApplicationMailer
include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::TextHelper
before_action :set_dossier
before_action :set_services_publics_plus, only: :send_notification
@ -67,7 +68,7 @@ class NotificationMailer < ApplicationMailer
mail_template = @dossier.procedure.mail_template_for(params[:state])
@email = @dossier.user_email_for(:notification)
@subject = mail_template.subject_for_dossier(@dossier)
@subject = truncate(mail_template.subject_for_dossier(@dossier), length: 100)
@body = mail_template.body_for_dossier(@dossier)
@actions = mail_template.actions_for_dossier(@dossier)
@attachment = mail_template.attachment_for_dossier(@dossier)

View file

@ -19,7 +19,19 @@ class ApplicationRecord < ActiveRecord::Base
GraphQL::Schema::UniqueWithinType.decode(id)[1]
end
def self.stable_id_from_typed_id(prefixed_typed_id)
return nil unless prefixed_typed_id.starts_with?("champ_")
self.id_from_typed_id(prefixed_typed_id.gsub("champ_", "")).to_i
rescue
nil
end
def to_typed_id
GraphQL::Schema::UniqueWithinType.encode(self.class.name, id)
end
def to_typed_id_for_query
to_typed_id.delete("==")
end
end

View file

@ -53,6 +53,8 @@ class Champ < ApplicationRecord
:repetition?,
:block?,
:dossier_link?,
:departement?,
:region?,
:titre_identite?,
:header_section?,
:simple_drop_down_list?,
@ -72,6 +74,10 @@ class Champ < ApplicationRecord
:refresh_after_update?,
to: :type_de_champ
delegate :to_typed_id, :to_typed_id_for_query, to: :type_de_champ, prefix: true
delegate :revision, to: :dossier, prefix: true
scope :updated_since?, -> (date) { where('champs.updated_at > ?', date) }
scope :public_only, -> { where(private: false) }
scope :private_only, -> { where(private: true) }
@ -211,6 +217,10 @@ class Champ < ApplicationRecord
raise NotImplemented.new(:fetch_external_data)
end
def update_with_external_data!(data:)
update!(data: data)
end
def clone
champ_attributes = [:parent_id, :private, :row_id, :type, :type_de_champ_id]
value_attributes = private? ? [] : [:value, :value_json, :data, :external_id]

View file

@ -28,4 +28,15 @@ class Champs::AnnuaireEducationChamp < Champs::TextChamp
def fetch_external_data
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']})"
)
else
update!(data: data)
end
end
end

View file

@ -64,21 +64,14 @@ class Champs::CarteChamp < Champ
end
def bounding_box
factory = RGeo::Geographic.simple_mercator_factory
bounding_box = RGeo::Cartesian::BoundingBox.new(factory)
if geo_areas.present?
geo_areas.filter_map(&:rgeo_geometry).each do |geometry|
bounding_box.add(geometry)
end
GeojsonService.bbox(type: 'FeatureCollection', features: geo_areas.map(&:to_feature))
elsif dossier.present?
point = dossier.geo_position
bounding_box.add(factory.point(point[:lon], point[:lat]))
GeojsonService.bbox(type: 'Feature', geometry: { type: 'Point', coordinates: [point[:lon], point[:lat]] })
else
bounding_box.add(factory.point(DEFAULT_LON, DEFAULT_LAT))
GeojsonService.bbox(type: 'Feature', geometry: { type: 'Point', coordinates: [DEFAULT_LON, DEFAULT_LAT] })
end
[bounding_box.max_point, bounding_box.min_point].compact.flat_map(&:coordinates)
end
def to_feature_collection

View file

@ -21,6 +21,9 @@
# type_de_champ_id :integer
#
class Champs::DepartementChamp < Champs::TextChamp
validate :value_in_departement_names, unless: -> { value.nil? }
validate :external_id_in_departement_codes, unless: -> { external_id.nil? }
def for_export
[name, code]
end
@ -65,6 +68,9 @@ class Champs::DepartementChamp < Champs::TextChamp
elsif code.blank?
self.external_id = nil
super(nil)
else
self.external_id = APIGeoService.departement_code(code)
super(code)
end
end
@ -73,4 +79,16 @@ class Champs::DepartementChamp < Champs::TextChamp
def formatted_value
blank? ? "" : "#{code} #{name}"
end
def value_in_departement_names
return if value.in?(APIGeoService.departements.pluck(:name))
errors.add(:value, :not_in_departement_names)
end
def external_id_in_departement_codes
return if external_id.in?(APIGeoService.departements.pluck(:code))
errors.add(:external_id, :not_in_departement_codes)
end
end

View file

@ -21,4 +21,13 @@
# type_de_champ_id :integer
#
class Champs::DossierLinkChamp < Champ
validate :value_integerable, if: -> { value.present? }, on: :prefill
private
def value_integerable
Integer(value)
rescue ArgumentError
errors.add(:value, :not_integerable)
end
end

View file

@ -24,6 +24,10 @@ class Champs::EpciChamp < Champs::TextChamp
store_accessor :value_json, :code_departement
before_validation :on_departement_change
validate :code_departement_in_departement_codes, unless: -> { code_departement.nil? }
validate :external_id_in_departement_epci_codes, unless: -> { code_departement.nil? || external_id.nil? }
validate :value_in_departement_epci_names, unless: -> { code_departement.nil? || external_id.nil? || value.nil? }
def for_export
[value, code, "#{code_departement} #{departement_name}"]
end
@ -74,4 +78,22 @@ class Champs::EpciChamp < Champs::TextChamp
self.value = nil
end
end
def code_departement_in_departement_codes
return if code_departement.in?(APIGeoService.departements.pluck(:code))
errors.add(:code_departement, :not_in_departement_codes)
end
def external_id_in_departement_epci_codes
return if external_id.in?(APIGeoService.epcis(code_departement).pluck(:code))
errors.add(:external_id, :not_in_departement_epci_codes)
end
def value_in_departement_epci_names
return if value.in?(APIGeoService.epcis(code_departement).pluck(:name))
errors.add(:value, :not_in_departement_epci_names)
end
end

View file

@ -25,14 +25,6 @@ class Champs::HeaderSectionChamp < Champ
# The user cannot enter any information here so it doesnt make much sense to search
end
def libelle_with_section_index
if sections&.none?(&:libelle_with_section_index?)
"#{section_index}. #{libelle}"
else
libelle
end
end
def libelle_with_section_index?
libelle =~ /^\d/
end

View file

@ -23,6 +23,8 @@
class Champs::MultipleDropDownListChamp < Champ
before_save :format_before_save
validate :values_are_in_options, if: -> { value.present? }
def options?
drop_down_list_options?
end
@ -90,4 +92,12 @@ class Champs::MultipleDropDownListChamp < Champ
end
end
end
def values_are_in_options
json = selected_options.reject(&:blank?)
return if json.empty?
return if (json - enabled_non_empty_options).empty?
errors.add(:value, :not_in_options)
end
end

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