Merge pull request #9535 from demarches-simplifiees/feat/9411

ETQ administrateur, je veux pouvoir valider des champs avec des règles simples (Regexp)
This commit is contained in:
Kara Diaby 2023-10-17 09:11:32 +00:00 committed by GitHub
commit 870efba29b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 381 additions and 12 deletions

View file

@ -0,0 +1,5 @@
class EditableChamp::ExpressionReguliereComponent < EditableChamp::EditableChampBaseComponent
def dsfr_input_classname
'fr-input'
end
end

View file

@ -0,0 +1 @@
= @form.text_field(:value, input_opts(id: @champ.input_id, placeholder: @champ.expression_reguliere_exemple_text, required: @champ.required?, aria: { describedby: @champ.describedby_id }))

View file

@ -40,6 +40,12 @@ fr:
update_condition: La condition du champ « %{label} » a été modifiée. La nouvelle condition est « %{to} ».
update_character_limit: La limite de caractères du champ texte « %{label} » a été modifiée. La nouvelle limite est « %{to} ».
remove_character_limit: La limite de caractères du champ texte « %{label} » a été supprimée.
remove_expression_reguliere: Lexpression régulière du champ « %{label} » a été supprimée.
update_expression_reguliere: Lexpression régulière du champ « %{label} » a été modifiée. La nouvelle expression est « %{to} ».
remove_expression_reguliere_exemple_text: Lexemple dexpression régulière du champ « %{label} » a été supprimé.
update_expression_reguliere_exemple_text: Lexemple dexpression régulière du champ « %{label} » a été modifié. Le nouvel exemple est « %{to} ».
remove_expression_reguliere_error_message: Le message derreur de lexpression régulière du champ « %{label} » a été supprimé.
update_expression_reguliere_error_message: Le message derreur de lexpression régulière du champ « %{label} » a été modifié. Le nouveau message est « %{to} ».
private:
add: Lannotation privée « %{label} » a été ajoutée.
remove: Lannotation privée « %{label} » a été supprimée.

View file

@ -141,6 +141,27 @@
- else
- list.with_item do
= t(".#{prefix}.update_character_limit", label: change.label, to: change.to)
- when :expression_reguliere
- if change.to.blank?
- list.with_item do
= t(".#{prefix}.remove_expression_reguliere", label: change.label, to: change.to)
- else
- list.with_item do
= t(".#{prefix}.update_expression_reguliere", label: change.label, to: change.to)
- when :expression_reguliere_exemple_text
- if change.to.blank?
- list.with_item do
= t(".#{prefix}.remove_expression_reguliere_exemple_text", label: change.label, to: change.to)
- else
- list.with_item do
= t(".#{prefix}.update_expression_reguliere_exemple_text", label: change.label, to: change.to)
- when :expression_reguliere_error_message
- if change.to.blank?
- list.with_item do
= t(".#{prefix}.remove_expression_reguliere_error_message", label: change.label, to: change.to)
- else
- list.with_item do
= t(".#{prefix}.update_expression_reguliere_error_message", label: change.label, to: change.to)
- if @public_move_changes.present?
- list.with_item do

View file

@ -13,3 +13,8 @@ fr:
character_limit:
unlimited: Pas de limite de caractères
limit: Limité à %{limit} caractères
expression_reguliere:
labels:
regex: Saisissez votre expression régulière, essayez-la sur https://rubular.com
valid_exemple: Exemple valide qui passe l'expression régulière
error_message: Message d'erreur à afficher à l'usager en cas de saisie invalide

View file

@ -46,6 +46,23 @@
%p
%small Nous numérotons automatiquement les titres lorsquaucun de vos titres ne commence par un chiffre.
- if type_de_champ.expression_reguliere?
.cell.mt-1
= form.label :expression_reguliere, for: dom_id(type_de_champ, :expression_reguliere) do
= t('.expression_reguliere.labels.regex')
= form.text_field :expression_reguliere, class: "fr-input small-margin small", id: dom_id(type_de_champ, :expression_reguliere)
.cell.mt-1
= form.label :expression_reguliere_exemple_text, for: dom_id(type_de_champ, :expression_reguliere_exemple_text) do
= t('.expression_reguliere.labels.valid_exemple')
= form.text_field :expression_reguliere_exemple_text, class: "fr-input small-margin small", id: dom_id(type_de_champ, :expression_reguliere_exemple_text)
- if type_de_champ.invalid_regexp?
%p.fr-message.fr-message--error
= type_de_champ.errors[:expression_reguliere_exemple_text].join(", ")
.cell.mt-1
= form.label :expression_reguliere_error_message, for: dom_id(type_de_champ, :expression_reguliere_error_message) do
= t('.expression_reguliere.labels.error_message')
= form.text_field :expression_reguliere_error_message, class: "fr-input small-margin small", id: dom_id(type_de_champ, :expression_reguliere_error_message)
- 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

@ -15,6 +15,10 @@ class TypesDeChampEditor::ErrorsSummary < ApplicationComponent
@revision.errors.include?(:header_section)
end
def expression_reguliere_errors?
@revision.errors.include?(:expression_reguliere)
end
private
def errors_for(key)

View file

@ -6,3 +6,7 @@ fr:
fix_header_section:
one: 'Le titre de section suivant est invalide, veuillez le corriger :'
other: 'Les titres de section suivants sont invalides, veuillez les corriger :'
fix_expressions_regulieres:
one: "L'expression régulière suivante est invalide, veuillez la corriger :"
other: 'Les expressions régulières suivantes sont invalides, veuillez les corriger :'

View file

@ -9,3 +9,7 @@
- if header_section_errors?
%p= t('.fix_header_section', count: errors_for(:header_section).size)
= error_message_for(:header_section)
- if expression_reguliere_errors?
%p= t('.fix_expressions_regulieres', count: errors_for(:expression_reguliere).size)
= error_message_for(:expression_reguliere)

View file

@ -130,6 +130,9 @@ module Administrateurs
:collapsible_explanation_text,
:header_section_level,
:character_limit,
:expression_reguliere,
:expression_reguliere_exemple_text,
:expression_reguliere_error_message,
editable_options: [
:cadastres,
:unesco,

View file

@ -114,7 +114,8 @@ class API::V2::Schema < GraphQL::Schema
Types::Champs::Descriptor::TextareaChampDescriptorType,
Types::Champs::Descriptor::TextChampDescriptorType,
Types::Champs::Descriptor::TitreIdentiteChampDescriptorType,
Types::Champs::Descriptor::YesNoChampDescriptorType
Types::Champs::Descriptor::YesNoChampDescriptorType,
Types::Champs::Descriptor::ExpressionReguliereChampDescriptorType
def self.unauthorized_object(error)
# Add a top-level error to the response instead of returning nil:

View file

@ -2284,6 +2284,34 @@ type ExplicationChampDescriptor implements ChampDescriptor {
type: TypeDeChamp! @deprecated(reason: "Utilisez le champ `__typename` à la place.")
}
type ExpressionReguliereChampDescriptor implements ChampDescriptor {
"""
Description des champs dun bloc répétable.
"""
champDescriptors: [ChampDescriptor!] @deprecated(reason: "Utilisez le champ `RepetitionChampDescriptor.champ_descriptors` à la place.")
"""
Description du champ.
"""
description: String
id: ID!
"""
Libellé du champ.
"""
label: String!
"""
Est-ce que le champ est obligatoire ?
"""
required: Boolean!
"""
Type de la valeur du champ.
"""
type: TypeDeChamp! @deprecated(reason: "Utilisez le champ `__typename` à la place.")
}
type File {
byteSize: Int! @deprecated(reason: "Utilisez le champ `byteSizeBigInt` à la place.")
byteSizeBigInt: BigInt!
@ -3945,6 +3973,11 @@ enum TypeDeChamp {
"""
explication
"""
Expression régulière
"""
expression_reguliere
"""
Titre de section
"""

View file

@ -96,6 +96,8 @@ module Types
Types::Champs::Descriptor::EpciChampDescriptorType
when TypeDeChamp.type_champs.fetch(:cojo)
Types::Champs::Descriptor::COJOChampDescriptorType
when TypeDeChamp.type_champs.fetch(:expression_reguliere)
Types::Champs::Descriptor::ExpressionReguliereChampDescriptorType
end
end
end

View file

@ -0,0 +1,5 @@
module Types::Champs::Descriptor
class ExpressionReguliereChampDescriptorType < Types::BaseObject
implements Types::ChampDescriptorType
end
end

View file

@ -58,6 +58,9 @@ class Champ < ApplicationRecord
:character_limit?,
:character_limit,
:yes_no?,
:expression_reguliere,
:expression_reguliere_exemple_text,
:expression_reguliere_error_message,
to: :type_de_champ
delegate :to_typed_id, :to_typed_id_for_query, to: :type_de_champ, prefix: true

View file

@ -0,0 +1,3 @@
class Champs::ExpressionReguliereChamp < Champ
validates_with ExpressionReguliereValidator, if: -> { validation_context != :brouillon }
end

View file

@ -32,6 +32,10 @@ module DossierRebaseConcern
!champs.filter { _1.stable_id == stable_id }.any? { _1.in?(options) }
end
def can_rebase_expression_reguliere_change?(stable_id, expression_reguliere)
false
end
private
def accepted_en_construction_changes?

View file

@ -19,6 +19,7 @@ class ProcedureRevision < ApplicationRecord
validate :conditions_are_valid?
validate :header_sections_are_valid?
validate :expressions_regulieres_are_valid?
delegate :path, to: :procedure, prefix: true
@ -375,6 +376,25 @@ class ProcedureRevision < ApplicationRecord
from_type_de_champ.character_limit,
to_type_de_champ.character_limit)
end
elsif to_type_de_champ.expression_reguliere?
if from_type_de_champ.expression_reguliere != to_type_de_champ.expression_reguliere
changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ,
:expression_reguliere,
from_type_de_champ.expression_reguliere,
to_type_de_champ.expression_reguliere)
end
if from_type_de_champ.expression_reguliere_exemple_text != to_type_de_champ.expression_reguliere_exemple_text
changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ,
:expression_reguliere_exemple_text,
from_type_de_champ.expression_reguliere_exemple_text,
to_type_de_champ.expression_reguliere_exemple_text)
end
if from_type_de_champ.expression_reguliere_error_message != to_type_de_champ.expression_reguliere_error_message
changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ,
:expression_reguliere_error_message,
from_type_de_champ.expression_reguliere_error_message,
to_type_de_champ.expression_reguliere_error_message)
end
end
changes
end
@ -412,6 +432,16 @@ class ProcedureRevision < ApplicationRecord
repetition_tdcs_errors + root_tdcs_errors
end
def expressions_regulieres_are_valid?
types_de_champ_public.to_a
.flat_map { _1.repetition? ? children_of(_1) : _1 }
.each do |tdc|
if tdc.expression_reguliere? && tdc.invalid_regexp?
errors.add(:expression_reguliere, type_de_champ: tdc)
end
end
end
def errors_for_header_sections_order(tdcs)
tdcs
.map.with_index

View file

@ -60,7 +60,6 @@ class ProcedureRevisionChange
def can_rebase?(dossier = nil)
return true if private?
case attribute
when :drop_down_options
(from - to).empty? || dossier&.can_rebase_drop_down_options_change?(stable_id, from - to)
@ -68,7 +67,7 @@ class ProcedureRevisionChange
!from && to
when :mandatory
(from && !to) || dossier&.can_rebase_mandatory_change?(stable_id)
when :type_champ, :condition
when :type_champ, :condition, :expression_reguliere
false
else
true

View file

@ -3,7 +3,8 @@ class TypeDeChamp < ApplicationRecord
FILE_MAX_SIZE = 200.megabytes
FEATURE_FLAGS = {
cojo: :cojo_type_de_champ
cojo: :cojo_type_de_champ,
expression_reguliere: :expression_reguliere_type_de_champ
}
MINIMUM_TEXTAREA_CHARACTER_LIMIT_LENGTH = 400
@ -55,7 +56,8 @@ class TypeDeChamp < ApplicationRecord
dgfip: REFERENTIEL_EXTERNE,
pole_emploi: REFERENTIEL_EXTERNE,
mesri: REFERENTIEL_EXTERNE,
cojo: REFERENTIEL_EXTERNE
cojo: REFERENTIEL_EXTERNE,
expression_reguliere: STANDARD
}
enum type_champs: {
@ -95,7 +97,8 @@ class TypeDeChamp < ApplicationRecord
pole_emploi: 'pole_emploi',
mesri: 'mesri',
epci: 'epci',
cojo: 'cojo'
cojo: 'cojo',
expression_reguliere: 'expression_reguliere'
}
ROUTABLE_TYPES = [
@ -116,6 +119,9 @@ class TypeDeChamp < ApplicationRecord
:drop_down_secondary_description,
:drop_down_other,
:character_limit,
:expression_reguliere,
:expression_reguliere_exemple_text,
:expression_reguliere_error_message,
:collapsible_explanation_enabled,
:collapsible_explanation_text,
:header_section_level
@ -184,6 +190,7 @@ class TypeDeChamp < ApplicationRecord
before_validation :check_mandatory
before_validation :normalize_libelle
before_save :remove_piece_justificative_template, if: -> { type_champ_changed? }
before_validation :remove_drop_down_list, if: -> { type_champ_changed? }
before_save :remove_block, if: -> { type_champ_changed? }
@ -414,6 +421,10 @@ class TypeDeChamp < ApplicationRecord
type_champ == TypeDeChamp.type_champs.fetch(:checkbox)
end
def expression_reguliere?
type_champ == TypeDeChamp.type_champs.fetch(:expression_reguliere)
end
def public?
!private?
end
@ -604,6 +615,21 @@ class TypeDeChamp < ApplicationRecord
type_champ.in?(ROUTABLE_TYPES)
end
def invalid_regexp?
return false if expression_reguliere.blank?
return false if expression_reguliere_exemple_text.blank?
return false if expression_reguliere_exemple_text.match?(Regexp.new(expression_reguliere, timeout: 2.0))
self.errors.add(:expression_reguliere_exemple_text, I18n.t('errors.messages.mismatch_regexp'))
true
rescue Regexp::TimeoutError
self.errors.add(:expression_reguliere, I18n.t('errors.messages.evil_regexp'))
true
rescue RegexpError
self.errors.add(:expression_reguliere, I18n.t('errors.messages.syntax_error_regexp'))
true
end
private
DEFAULT_EMPTY = ['']

View file

@ -0,0 +1,2 @@
class TypesDeChamp::ExpressionReguliereTypeDeChamp < TypesDeChamp::TypeDeChampBase
end

View file

@ -0,0 +1,11 @@
class ExpressionReguliereValidator < ActiveModel::Validator
def validate(record)
if record.value.present?
if !record.value.match?(Regexp.new(record.expression_reguliere, timeout: 5.0))
record.errors.add(:value, :invalid_regexp)
end
end
rescue Regexp::TimeoutError
record.errors.add(:expression_reguliere, :evil_regexp)
end
end

View file

@ -684,6 +684,9 @@ en:
procedure_archived:
with_service_and_phone_email: This procedure has been closed, it is no longer possible to submit a file. For more information, please contact the service %{service_name}, available at %{service_phone_number} or by email %{service_email}
with_organisation_only: This procedure has been closed, it is no longer possible to submit a file. For more information, please contact the organisation %{organisation_name}
evil_regexp: The regular expression you have entered is potentially dangerous and could lead to performance issues.
mismatch_regexp: The provided example must match the regular expression
syntax_error_regexp: The syntax of the regular expression is invalid
# # procedure_not_draft: "This procedure is not a draft anymore."
# cadastres_empty:
# one: "Aucune parcelle cadastrale sur la zone sélectionnée"

View file

@ -689,7 +689,9 @@ fr:
procedure_archived:
with_service_and_phone_email: Cette démarche en ligne a été close, il nest plus possible de déposer de dossier. Pour plus dinformations veuillez contacter le service %{service_name} au %{service_phone_number} ou par email à %{service_email}
with_organisation_only: Cette démarche en ligne a été close, il nest plus possible de déposer de dossier. Pour plus dinformations veuillez contacter le service %{organisation_name}
evil_regexp: L'expression régulière que vous avez entrée est potentiellement dangereuse et pourrait entraîner des problèmes de performance
mismatch_regexp: L'exemple doit correspondre à l'expression régulière fournie
syntax_error_regexp: La syntaxe de l'expression régulière n'est pas valide
empty_repetition: '« %{value} » doit comporter au moins un champ répétable'
empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable'
# procedure_not_draft: "Cette démarche nest maintenant plus en brouillon."

View file

@ -0,0 +1,8 @@
en:
activerecord:
errors:
models:
champs/expression_reguliere_champ:
attributes:
value:
invalid_regexp: does not match expected format

View file

@ -0,0 +1,8 @@
fr:
activerecord:
errors:
models:
champs/expression_reguliere_champ:
attributes:
value:
invalid_regexp: ne correspond pas au format attendu

View file

@ -55,6 +55,7 @@ en:
mesri: "Data from Ministère de lEnseignement Supérieur, de la Recherche et de lInnovation"
epci: "EPCI"
cojo: "Accreditation Paris 2024"
expression_reguliere: 'Regular expression'
errors:
type_de_champ:
attributes:

View file

@ -55,6 +55,7 @@ fr:
mesri: "Données du Ministère de lEnseignement Supérieur, de la Recherche et de lInnovation"
epci: "EPCI"
cojo: "Accréditation Paris 2024"
expression_reguliere: 'Expression régulière'
errors:
type_de_champ:
attributes:

View file

@ -243,6 +243,10 @@ FactoryBot.define do
type_de_champ { association :type_de_champ_cojo, procedure: dossier.procedure }
end
factory :champ_expression_reguliere, class: 'Champs::ExpressionReguliereChamp' do
type_de_champ { association :type_de_champ_expression_reguliere, procedure: dossier.procedure }
end
factory :champ_repetition, class: 'Champs::RepetitionChamp' do
type_de_champ { association :type_de_champ_repetition, procedure: dossier.procedure }

View file

@ -99,6 +99,9 @@ FactoryBot.define do
type_champ { TypeDeChamp.type_champs.fetch(:linked_drop_down_list) }
drop_down_list_value { "--primary--\nsecondary\n" }
end
factory :type_de_champ_expression_reguliere do
type_champ { TypeDeChamp.type_champs.fetch(:expression_reguliere) }
end
factory :type_de_champ_pays do
type_champ { TypeDeChamp.type_champs.fetch(:pays) }
end

View file

@ -14,9 +14,9 @@ describe '20220705164551_remove_unused_champs' do
describe 'remove_unused_champs' do
it "with bad champs" do
expect(Champ.where(dossier: dossier).count).to eq(41)
expect(Champ.where(dossier: dossier).count).to eq(42)
run_task
expect(Champ.where(dossier: dossier).count).to eq(40)
expect(Champ.where(dossier: dossier).count).to eq(41)
end
end
end

View file

@ -124,6 +124,21 @@ describe DossierRebaseConcern do
end
end
context 'with type de champ regexp and regexp change' do
let(:procedure) { create(:procedure, types_de_champ_public: [{ mandatory: true }, { type: :expression_reguliere }], types_de_champ_private: [{}]) }
before do
procedure.draft_revision.find_and_ensure_exclusive_use(type_de_champ.stable_id).update(expression_reguliere: /\d+/)
procedure.publish_revision!
dossier.reload
end
it 'should be false' do
expect(dossier.pending_changes).not_to be_empty
expect(dossier.can_rebase?).to be_falsey
end
end
context 'with removed type de champ' do
before do
procedure.draft_revision.remove_type_de_champ(type_de_champ.stable_id)

View file

@ -1575,6 +1575,44 @@ describe Dossier, type: :model do
end
end
describe "#check_expressions_regulieres_champs" do
let(:procedure) { create(:procedure, types_de_champ_public: types_de_champ) }
let(:dossier) { create(:dossier, procedure: procedure) }
let(:types_de_champ) { [type_de_champ] }
let(:type_de_champ) { { type: :expression_reguliere, expression_reguliere:, expression_reguliere_exemple_text: } }
context "with bad example" do
let(:expression_reguliere_exemple_text) { "01234567" }
let(:expression_reguliere) { "[A-Z]+" }
before do
champ = dossier.champs_public.first
champ.value = expression_reguliere_exemple_text
dossier.save
end
it 'should have errors' do
expect(dossier.errors).not_to be_empty
expect(dossier.errors.full_messages.join(',')).to include("ne correspond pas au format attendu")
end
end
context "with good example" do
let(:expression_reguliere_exemple_text) { "AZERTY" }
let(:expression_reguliere) { "[A-Z]+" }
before do
champ = dossier.champs_public.first
champ.value = expression_reguliere_exemple_text
dossier.save
end
it 'should not have errors' do
expect(dossier.errors).to be_empty
end
end
end
describe 'index_for_section_header' do
let(:procedure) { create(:procedure, types_de_champ_public: types_de_champ) }
let(:dossier) { create(:dossier, procedure: procedure) }

View file

@ -908,6 +908,75 @@ describe ProcedureRevision do
end
end
describe "expressions_regulieres_are_valid" do
let(:procedure) do
create(:procedure).tap do |p|
p.draft_revision.add_type_de_champ(type_champ: :expression_reguliere, libelle: 'exemple', expression_reguliere:, expression_reguliere_exemple_text:)
end
end
let(:draft_revision) { procedure.draft_revision }
subject do
draft_revision.save
draft_revision.errors
end
context "When no regexp and no example" do
let(:expression_reguliere_exemple_text) { nil }
let(:expression_reguliere) { nil }
it { is_expected.to be_empty }
end
context "When expression_reguliere but no example" do
let(:expression_reguliere) { "[A-Z]+" }
let(:expression_reguliere_exemple_text) { nil }
it { is_expected.to be_empty }
end
context "When expression_reguliere and bad example" do
let(:expression_reguliere_exemple_text) { "01234567" }
let(:expression_reguliere) { "[A-Z]+" }
it { is_expected.not_to be_empty }
end
context "When expression_reguliere and good example" do
let(:expression_reguliere_exemple_text) { "A" }
let(:expression_reguliere) { "[A-Z]+" }
it { is_expected.to be_empty }
end
context "When bad expression_reguliere" do
let(:expression_reguliere_exemple_text) { "0123456789" }
let(:expression_reguliere) { "(" }
it { is_expected.not_to be_empty }
end
context "When repetition" do
let(:procedure) do
create(:procedure,
types_de_champ_public: [{ type: :repetition, children: [{ type: :expression_reguliere, expression_reguliere:, expression_reguliere_exemple_text: }] }])
end
context "When bad expression_reguliere" do
let(:expression_reguliere_exemple_text) { "0123456789" }
let(:expression_reguliere) { "(" }
it { is_expected.not_to be_empty }
end
context "When expression_reguliere and bad example" do
let(:expression_reguliere_exemple_text) { "01234567" }
let(:expression_reguliere) { "[A-Z]+" }
it { is_expected.not_to be_empty }
end
end
end
describe "#dependent_conditions" do
include Logic

View file

@ -166,6 +166,31 @@ describe TypeDeChamp do
end
end
describe "validate_regexp" do
let(:tdc) { create(:type_de_champ_expression_reguliere, expression_reguliere:, expression_reguliere_exemple_text:) }
subject { tdc.invalid_regexp? }
context "expression_reguliere and bad example" do
let(:expression_reguliere_exemple_text) { "01234567" }
let(:expression_reguliere) { "[A-Z]+" }
it "should add error message" do
expect(subject).to be_truthy
expect(tdc.errors.messages[:expression_reguliere_exemple_text]).to be_present
end
end
context "Bad expression_reguliere" do
let(:expression_reguliere_exemple_text) { "0123456789" }
let(:expression_reguliere) { "(" }
it "should add error message" do
expect(subject).to be_truthy
expect(tdc.errors.messages[:expression_reguliere]).to be_present
end
end
end
describe '#drop_down_list_options' do
let(:value) do
<<~EOS

View file

@ -89,7 +89,8 @@ describe ProcedureExportService do
"epci",
"epci (Code)",
"epci (Département)",
"cojo"
"cojo",
"expression_reguliere"
]
end
@ -200,7 +201,8 @@ describe ProcedureExportService do
"epci",
"epci (Code)",
"epci (Département)",
"cojo"
"cojo",
"expression_reguliere"
]
end
@ -294,7 +296,8 @@ describe ProcedureExportService do
"epci",
"epci (Code)",
"epci (Département)",
"cojo"
"cojo",
"expression_reguliere"
]
end