Merge branch 'main' into fix/stored_query_issue
This commit is contained in:
commit
8a7cb3f1fe
301 changed files with 4456 additions and 1168 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
@ -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:
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -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'
|
||||
|
|
19
Gemfile.lock
19
Gemfile.lock
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -89,7 +89,8 @@
|
|||
color: $light-grey;
|
||||
}
|
||||
|
||||
.conditionnel {
|
||||
.conditionnel,
|
||||
p {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
11
app/assets/stylesheets/sections.scss
Normal file
11
app/assets/stylesheets/sections.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
.counter-start-header-section {
|
||||
counter-reset: headerSectionCounter;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
counter-increment: headerSectionCounter;
|
||||
|
||||
&.header-section-counter::before {
|
||||
content: counter(headerSectionCounter) ". ";
|
||||
}
|
||||
}
|
7
app/components/attachment/delete_form_component.rb
Normal file
7
app/components/attachment/delete_form_component.rb
Normal 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
|
|
@ -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."
|
||||
|
|
|
@ -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 s’est produite pendant l’envoi du fichier."
|
||||
virus_infected: "Virus détecté, merci d’envoyer un autre fichier."
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
2
app/components/attachment/progress_bar_component.rb
Normal file
2
app/components/attachment/progress_bar_component.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
class Attachment::ProgressBarComponent < ApplicationComponent
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
en:
|
||||
loading: Loading
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
fr:
|
||||
loading: Chargement de fichier
|
|
@ -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" }
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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' } }
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent
|
||||
end
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent
|
||||
end
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
|
||||
def initialize(**args)
|
||||
super(**args)
|
||||
|
||||
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
17
app/components/editable_champ/combo_search_component.rb
Normal file
17
app/components/editable_champ/combo_search_component.rb
Normal 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
|
|
@ -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)
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
class EditableChamp::CommunesComponent < EditableChamp::ComboSearchComponent
|
||||
end
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::ExplicationComponent < EditableChamp::EditableChampBaseComponent
|
||||
include StringToHtmlHelper
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::LinkedDropDownListComponent < EditableChamp::EditableChampBaseComponent
|
||||
include StringToHtmlHelper
|
||||
end
|
||||
|
|
|
@ -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],
|
||||
{},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
|
|
53
app/components/password_complexity_component.rb
Normal file
53
app/components/password_complexity_component.rb
Normal 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
|
|
@ -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.
|
|
@ -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é.
|
|
@ -0,0 +1,6 @@
|
|||
%div{ class: complexity_classes }
|
||||
|
||||
%div{ class: alert_classes }
|
||||
%h3.fr-alert__title= title
|
||||
- if !success?
|
||||
%p= t(".hint")
|
|
@ -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
|
||||
|
|
51
app/components/simple_format_component.rb
Normal file
51
app/components/simple_format_component.rb
Normal 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
|
|
@ -0,0 +1 @@
|
|||
= sanitize(@renderer.render(@text), tags:, attributes:)
|
|
@ -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 lorsqu’aucun 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)
|
||||
|
|
|
@ -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)
|
||||
"L’instructeur « #{instructeur.email} » a été retiré du groupe."
|
||||
else
|
||||
"L’instructeur 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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -22,6 +22,10 @@ module Instructeurs
|
|||
else
|
||||
groupe_instructeur.add(instructeur)
|
||||
flash[:notice] = "L’instructeur « #{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] = "L’instructeur « #{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] = "L’instructeur « #{instructeur.email} » n’est pas dans le groupe."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -288,6 +288,7 @@ class API::V2::StoredQuery
|
|||
dateFermeture
|
||||
notice { url }
|
||||
deliberation { url }
|
||||
demarcheUrl
|
||||
cadreJuridiqueUrl
|
||||
service @include(if: $includeService) {
|
||||
...ServiceFragment
|
||||
|
|
|
@ -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: }
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -75,7 +75,11 @@ Cela évite l’accè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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:)
|
||||
|
|
|
@ -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)
|
||||
|
|
6
app/helpers/sanitize_with_link_helper.rb
Normal file
6
app/helpers/sanitize_with_link_helper.rb
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
11
app/jobs/migrations/normalize_geo_area_job.rb
Normal file
11
app/jobs/migrations/normalize_geo_area_job.rb
Normal 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
|
21
app/lib/redcarpet/bare_renderer.rb
Normal file
21
app/lib/redcarpet/bare_renderer.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -25,14 +25,6 @@ class Champs::HeaderSectionChamp < Champ
|
|||
# The user cannot enter any information here so it doesn’t 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
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue