Merge pull request #10591 from demarches-simplifiees/9827-export-select

ETQ instructeur, je peux créer un modèle d'export tabulaire
This commit is contained in:
krichtof 2024-11-15 05:55:17 +00:00 committed by GitHub
commit 4b740f8f29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 1537 additions and 228 deletions

View file

@ -551,3 +551,22 @@ textarea::placeholder {
.resize-y {
resize: vertical;
}
.checkbox-group-bordered {
border: 1px solid var(--border-default-grey);
flex: 1 1 100%; // copied from fr-fieldset-element
max-width: 100%; // copied from fr-fieldset-element
}
.fieldset-bordered {
position: relative;
}
.fieldset-bordered::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 2px solid var(--border-default-blue-france);
}

View file

@ -44,8 +44,12 @@
position: relative;
}
.dropdown-export .dropdown-content {
.dropdown-export.dropdown-content {
width: 450px;
a {
text-decoration: underline;
}
}
.dropdown-label.dropdown-content {

View file

@ -3,13 +3,14 @@
class Dossiers::ExportDropdownComponent < ApplicationComponent
include ApplicationHelper
def initialize(procedure:, export_templates: nil, statut: nil, count: nil, class_btn: nil, export_url: nil)
def initialize(procedure:, export_templates: nil, statut: nil, count: nil, class_btn: nil, export_url: nil, show_export_template_tab: true)
@procedure = procedure
@export_templates = export_templates
@statut = statut
@count = count
@class_btn = class_btn
@export_url = export_url
@show_export_template_tab = show_export_template_tab
end
def formats

View file

@ -1,26 +1,68 @@
= render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu|
- menu.with_menu_header_html do
%p.menu-component-header.fr-px-2w.fr-pt-2w.fr-mb-0
%span.fr-icon-info-line{ aria: { hidden: true } }
Des macros ? Lisez la
= link_to('doc', t('.macros_doc.url'),
title: t('.macros_doc.title'),
**external_link_attributes)
= render Dropdown::MenuComponent.new(wrapper: :div, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu|
- menu.with_button_inner_html do
= @count.nil? ? t(".download_all") : t(".download", count: @count)
- formats.each do |format|
- menu.with_item do
= link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do
= t(".everything_#{format}_html")
- menu.with_form do
.fr-container
.fr-tabs.fr-my-3w
%ul.fr-tabs__list{ role: 'tablist' }
%li{ role: 'presentation' }
%button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-standard#{@count}", tabindex: "0", role: "tab", "aria-selected": "true", "aria-controls": "tabpanel-standard#{@count}-panel" } Standard
- if @procedure.feature_enabled?(:export_template)
- if export_templates.present?
- export_templates.each do |export_template|
- menu.with_item do
= link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do
= "Exporter à partir du modèle #{export_template.name}"
- menu.with_item do
= link_to [:new, :instructeur, @procedure, :export_template], role: 'menuitem' do
Ajouter un modèle d'export
- if @show_export_template_tab
%li{ role: 'presentation' }
%button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-template#{@count}", tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "tabpanel-template#{@count}-panel" } A partir d'un modèle
.fr-tabs__panel.fr-pb-8w.fr-tabs__panel--selected{ id: "tabpanel-standard#{@count}-panel", role: "tabpanel", "aria-labelledby": "tabpanel-standard#{@count}", tabindex: "0" }
= form_with url: download_export_path, namespace: "export#{@count}", data: { turbo_method: :post, turbo: true } do |f|
= f.hidden_field :statut, value: @statut
%fieldset.fr-fieldset#radio-hint{ "aria-labelledby": "radio-hint-legend" }
%legend.fr-fieldset__legend--regular.fr-fieldset__legend#radio-hint-legend Séletionner le format de l'export
.fr-fieldset__element
.fr-radio-group
= f.radio_button :export_format, 'xlsx'
= f.label :export_format_xlsx, 'Fichier xlsx'
.fr-fieldset__element
.fr-radio-group
= f.radio_button :export_format, 'ods'
= f.label :export_format_ods, 'Fichier ods'
.fr-fieldset__element
.fr-radio-group
= f.radio_button :export_format, 'csv'
= f.label :export_format_csv do
Fichier csv
%span.fr-hint-text Uniquement les dossiers, sans les champs répétables
.fr-fieldset__element
.fr-radio-group
= f.radio_button :export_format, 'zip'
= f.label :export_format_zip do
Fichier zip
%span.fr-hint-text ne contient pas l'horodatage ni le journal de log
.fr-fieldset__element
%ul.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline
%li
%button.fr-btn.fr-btn--secondary{ type: 'button', "data-action": "click->menu-button#close" } Annuler
%li
= f.submit "Demander l'export", "data-action": "click->menu-button#close", class: 'fr-btn'
- if @show_export_template_tab
.fr-tabs__panel.fr-pr-3w.fr-pb-8w{ id: "tabpanel-template#{@count}-panel", role: "tabpanel", "aria-labelledby": "tabpanel-template", tabindex: "0" }
= form_with url: download_export_path, namespace: "export_template_#{@count}", data: { turbo_method: :post, turbo: true } do |f|
= f.hidden_field :statut, value: @statut
.fr-select-group
- if export_templates.present?
%label.fr-label{ for: 'select' }
Sélectionner le modèle d'export
= f.collection_select :export_template_id, export_templates, :id, :name, {}, { class: "fr-select fr-mb-2w" }
- else
%p
%i Aucun modèle configuré
%p
= link_to "Configurer les modèles d'export", exports_instructeur_procedure_path(procedure_id: params[:procedure_id]), class: 'fr-link'
%ul.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline
%li
%button.fr-btn.fr-btn--secondary{ type: 'button', "data-action": "click->menu-button#close" } Annuler
%li
= f.submit "Demander l'export", "data-action": "click->menu-button#close", class: 'fr-btn'

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
class ExportTemplate::ChampsComponent < ApplicationComponent
attr_reader :export_template, :title
def initialize(title, export_template, types_de_champ)
@title = title
@export_template = export_template
@types_de_champ = types_de_champ
end
def historical_libelle(column)
historical_exported_column = export_template.exported_columns.find { _1.column == column }
if historical_exported_column
historical_exported_column.libelle
else
column.label
end
end
def sections
@types_de_champ
.reject { _1.header_section? && _1.header_section_level_value > 1 }
.slice_before(&:header_section?)
.filter_map do |(head, *rest)|
libelle = head.libelle if head.header_section?
columns = [head.header_section? ? nil : head, *rest].compact.map { tdc_to_columns(_1) }
{ libelle:, columns: } if columns.present?
end
end
def component_prefix
title.parameterize
end
private
def tdc_to_columns(type_de_champ)
prefix = type_de_champ.repetition? ? "Bloc répétable" : nil
type_de_champ.columns(procedure: export_template.procedure, prefix:).map do |column|
ExportedColumn.new(column:,
libelle: historical_libelle(column))
end
end
end

View file

@ -0,0 +1,23 @@
%fieldset.fr-fieldset{ id: "#{component_prefix}-fieldset", data: { controller: 'checkbox-select-all' } }
%legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0
= title
.checkbox-group-bordered.fr-mx-1w.fr-mb-2w
.fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w
.fr-checkbox-group
= check_box_tag "#{component_prefix}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' }
= label_tag "#{component_prefix}-select-all", "Tout sélectionner"
- sections.each.with_index do |section, idx|
- if section[:libelle]
.fr-fieldset__element.fr-text--bold.fr-px-4w{ class: idx > 0 ? "fr-pt-1w" : "" }= section[:libelle]
- section[:columns].each do |grouped_columns|
- if grouped_columns.many?
.fr-fieldset__element
.fieldset-bordered.fr-ml-3v
- grouped_columns.each do |exported_column|
.fr-fieldset__element.fr-px-3v
.fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:)
- else
- grouped_columns.each do |exported_column|
.fr-fieldset__element.fr-px-4w
.fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:)

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
class ExportTemplate::CheckboxComponent < ApplicationComponent
attr_reader :exported_column, :export_template
def initialize(export_template:, exported_column:)
@export_template = export_template
@exported_column = exported_column
end
def call
safe_join([
check_box,
label_tag(label_id, exported_column.libelle)
])
end
def check_box
check_box_tag(
'export_template[exported_columns][]',
exported_column.id,
export_template.in_export?(exported_column),
class: 'fr-checkbox',
id: sanitize_to_id(label_id), # sanitize_to_id is used by rails in label_tag
data: { "checkbox-select-all-target": 'checkbox' }
)
end
def label_id
exported_column.column.id
end
end

View file

@ -11,7 +11,7 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent
def classname(column)
return 'status-col' if column.dossier_state?
return 'number-col' if column.type == :number
return 'number-col' if column.dossier_id?
return 'sva-col' if column.column == 'sva_svr_decision_on'
end

View file

@ -5,9 +5,10 @@ module Instructeurs
before_action :set_procedure_and_groupe_instructeurs
before_action :set_export_template, only: [:edit, :update, :destroy]
before_action :ensure_legitimate_groupe_instructeur, only: [:create, :update]
before_action :set_types_de_champ, only: [:new, :edit]
def new
@export_template = ExportTemplate.default(groupe_instructeur: @groupe_instructeurs.first)
@export_template = export_template
end
def create
@ -49,9 +50,29 @@ module Instructeurs
private
def export_template = @export_template ||= ExportTemplate.default(groupe_instructeur: @groupe_instructeurs.first, kind:)
def kind = params[:kind] == 'zip' ? 'zip' : 'xlsx'
def set_types_de_champ
if export_template.tabular?
@types_de_champ_public = @procedure.all_revisions_types_de_champ(parent: nil, with_header_section: true).public_only
@types_de_champ_private = @procedure.all_revisions_types_de_champ(parent: nil, with_header_section: true).private_only
end
end
def export_template_params
params.require(:export_template)
.permit(:name, :kind, :groupe_instructeur_id, dossier_folder: [:enabled, :template], export_pdf: [:enabled, :template], pjs: [:stable_id, :enabled, :template])
params
.require(:export_template)
.permit(
:name,
:kind,
:groupe_instructeur_id,
dossier_folder: [:enabled, :template],
export_pdf: [:enabled, :template],
pjs: [:stable_id, :enabled, :template],
exported_columns: []
)
end
def set_procedure_and_groupe_instructeurs

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
module ExportTemplateHelper
def pretty_kind(kind)
icon = kind == 'zip' ? 'archive' : 'table'
pretty = tag.span nil, class: "fr-icon-#{icon}-line fr-mr-1v"
pretty + kind.upcase
end
end

View file

@ -0,0 +1,71 @@
import { ApplicationController } from './application_controller';
export class CheckboxSelectAll extends ApplicationController {
declare readonly hasCheckboxAllTarget: boolean;
declare readonly checkboxTargets: HTMLInputElement[];
declare readonly checkboxAllTarget: HTMLInputElement;
static targets: string[] = ['checkboxAll', 'checkbox'];
initialize() {
this.toggle = this.toggle.bind(this);
this.refresh = this.refresh.bind(this);
}
checkboxAllTargetConnected(checkbox: HTMLInputElement): void {
checkbox.addEventListener('change', this.toggle);
this.refresh();
}
checkboxTargetConnected(checkbox: HTMLInputElement): void {
checkbox.addEventListener('change', this.refresh);
this.refresh();
}
checkboxAllTargetDisconnected(checkbox: HTMLInputElement): void {
checkbox.removeEventListener('change', this.toggle);
this.refresh();
}
checkboxTargetDisconnected(checkbox: HTMLInputElement): void {
checkbox.removeEventListener('change', this.refresh);
this.refresh();
}
toggle(e: Event): void {
e.preventDefault();
this.checkboxTargets.forEach((checkbox) => {
// @ts-expect-error faut savoir hein
checkbox.checked = e.target.checked;
this.triggerInputEvent(checkbox);
});
}
refresh(): void {
const checkboxesCount = this.checkboxTargets.length;
const checkboxesCheckedCount = this.checked.length;
this.checkboxAllTarget.checked = checkboxesCheckedCount > 0;
this.checkboxAllTarget.indeterminate =
checkboxesCheckedCount > 0 && checkboxesCheckedCount < checkboxesCount;
}
triggerInputEvent(checkbox: HTMLInputElement): void {
const event = new Event('input', { bubbles: false, cancelable: true });
checkbox.dispatchEvent(event);
}
get checked(): HTMLInputElement[] {
return this.checkboxTargets.filter((checkbox) => checkbox.checked);
}
get unchecked(): HTMLInputElement[] {
return this.checkboxTargets.filter((checkbox) => !checkbox.checked);
}
}

View file

@ -61,6 +61,12 @@ export class MenuButtonController extends ApplicationController {
});
}
close() {
this.buttonTarget.setAttribute('aria-expanded', 'false');
this.menuTarget.parentElement?.classList.remove('open');
this.setFocusToMenuitem(null);
}
private open(focusMenuItem: 'first' | 'last' = 'first') {
this.buttonTarget.setAttribute('aria-expanded', 'true');
this.menuTarget.parentElement?.classList.add('open');
@ -75,12 +81,6 @@ export class MenuButtonController extends ApplicationController {
});
}
private close() {
this.buttonTarget.setAttribute('aria-expanded', 'false');
this.menuTarget.parentElement?.classList.remove('open');
this.setFocusToMenuitem(null);
}
private isClickOutside(target: HTMLElement) {
return (
target.isConnected &&

View file

@ -40,11 +40,11 @@ class Champs::RepetitionChamp < Champ
self[attribute]
end
def spreadsheet_columns(types_de_champ)
def spreadsheet_columns(types_de_champ, export_template: nil, format:)
[
['Dossier ID', :dossier_id],
['Ligne', :index]
] + dossier.champs_for_export(types_de_champ, row_id)
] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:, format:)
end
end
end

View file

@ -32,6 +32,7 @@ class Column
def ==(other) = h_id == other.h_id # using h_id instead of id to avoid inversion of keys
def notifications? = [table, column] == ['notifications', 'notifications']
def dossier_id? = [table, column] == ['self', 'id']
def dossier_state? = [table, column] == ['self', 'state']
def groupe_instructeur? = [table, column] == ['groupe_instructeur', 'id']
def dossier_labels? = [table, column] == ['dossier_labels', 'label_id']
@ -47,6 +48,9 @@ class Column
procedure.find_column(h_id: h_id)
end
def dossier_column? = false
def champ_column? = false
private
def column_id = "#{table}/#{column}"

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Columns::ChampColumn < Column
attr_reader :stable_id
attr_reader :stable_id, :tdc_type
def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, options_for_select: [])
@stable_id = stable_id
@ -45,6 +45,8 @@ class Columns::ChampColumn < Column
end
end
def champ_column? = true
private
def column_id = "type_de_champ/#{stable_id}"

View file

@ -15,4 +15,6 @@ class Columns::DossierColumn < Column
dossier.followers_instructeurs.map(&:email).join(' ')
end
end
def dossier_column? = true
end

View file

@ -46,10 +46,10 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn
def column_id = "type_de_champ/#{stable_id}->#{path}"
def typed_value(champ)
return nil if path == :value
primary_value, secondary_value = unpack_values(champ.value)
case path
when :value
"#{primary_value} / #{secondary_value}"
when :primary
primary_value
when :secondary

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Columns::PieceJustificativeColumn < Column
private
def typed_value(champ)
champ.piece_justificative_file.map { _1.blob.filename.to_s }.join(', ')
end
end

View file

@ -51,7 +51,7 @@ module ColumnsConcern
columns.filter { _1.id.in?(self.columns.map(&:id)) }
end
def dossier_id_column = dossier_col(table: 'self', column: 'id', type: :number)
def dossier_id_column = dossier_col(table: 'self', column: 'id', type: :integer)
def dossier_state_column
options_for_select = I18n.t('instructeurs.dossiers.filterable_state').map(&:to_a).map(&:reverse)
@ -87,13 +87,13 @@ module ColumnsConcern
def followers_instructeurs_email_column = dossier_col(table: 'followers_instructeurs', column: 'email')
def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :text, displayable: false, filterable: false);
def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :boolean, displayable: false, filterable: false);
def dossier_motivation_column = dossier_col(table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false);
def user_email_for_display_column = dossier_col(table: 'self', column: 'user_email_for_display', filterable: false, displayable: false)
def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false)
def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', type: :boolean, filterable: false, displayable: false)
def dossier_labels_column = dossier_col(table: 'dossier_labels', column: 'label_id', type: :enum, options_for_select: labels.map { [_1.name, _1.id] })
@ -109,7 +109,7 @@ module ColumnsConcern
def dossier_dates_columns
['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at']
.map { |column| dossier_col(table: 'self', column:, type: :date) }
.map { |column| dossier_col(table: 'self', column:, type: :datetime) }
end
def email_column
@ -140,7 +140,8 @@ module ColumnsConcern
def individual_columns
['gender', 'nom', 'prenom'].map { |column| dossier_col(table: 'individual', column:) }
.concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) }
.concat ['mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) }
.concat ['for_tiers'].map { |column| dossier_col(table: 'self', column:, type: :boolean) }
end
def moral_columns

View file

@ -82,15 +82,6 @@ module DossierChampsConcern
.map { _1.repetition? ? project_champ(_1, nil) : champ_for_update(_1, nil, updated_by: nil) }
end
def champs_for_export(types_de_champ, row_id = nil)
types_de_champ.flat_map do |type_de_champ|
champ = filled_champ(type_de_champ, row_id)
type_de_champ.libelles_for_export.map do |(libelle, path)|
[libelle, type_de_champ.champ_value_for_export(champ, path)]
end
end
end
def champ_value_for_tag(type_de_champ, path = :value)
champ = filled_champ(type_de_champ, nil)
type_de_champ.champ_value_for_tag(champ, path)

View file

@ -0,0 +1,125 @@
# frozen_string_literal: true
module DossierExportConcern
extend ActiveSupport::Concern
def spreadsheet_columns_csv(types_de_champ:, export_template: nil)
spreadsheet_columns(with_etablissement: true, types_de_champ:, export_template:, format: :csv)
end
def spreadsheet_columns_xlsx(types_de_champ:, export_template: nil)
spreadsheet_columns(types_de_champ:, export_template:, format: :xlsx)
end
def spreadsheet_columns_ods(types_de_champ:, export_template: nil)
spreadsheet_columns(types_de_champ:, export_template:, format: :ods)
end
def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil, format:)
types_de_champ.flat_map do |type_de_champ|
champ = filled_champ(type_de_champ, row_id)
if export_template.present?
export_template
.columns_for_stable_id(type_de_champ.stable_id)
.map { |exported_column| exported_column.libelle_with_value(champ, format:) }
else
type_de_champ.libelles_for_export.map do |(libelle, path)|
[libelle, type_de_champ.champ_value_for_export(champ, path)]
end
end
end
end
def spreadsheet_columns(types_de_champ:, with_etablissement: false, export_template: nil, format: nil)
dossier_values_for_export(with_etablissement:, export_template:, format:) + champ_values_for_export(types_de_champ, export_template:, format:)
end
private
def dossier_values_for_export(with_etablissement: false, export_template: nil, format:)
if export_template.present?
return export_template.dossier_exported_columns.map { _1.libelle_with_value(self, format:) }
end
columns = [
['ID', id.to_s],
['Email', user_email_for(:display)],
['FranceConnect ?', user_from_france_connect?]
]
if procedure.for_individual?
columns += [
['Civilité', individual&.gender],
['Nom', individual&.nom],
['Prénom', individual&.prenom],
['Dépôt pour un tiers', :for_tiers],
['Nom du mandataire', :mandataire_last_name],
['Prénom du mandataire', :mandataire_first_name]
]
if procedure.ask_birthday
columns += [['Date de naissance', individual&.birthdate]]
end
elsif with_etablissement
columns += [
['Établissement SIRET', etablissement&.siret],
['Établissement siège social', etablissement&.siege_social],
['Établissement NAF', etablissement&.naf],
['Établissement libellé NAF', etablissement&.libelle_naf],
['Établissement Adresse', etablissement&.adresse],
['Établissement numero voie', etablissement&.numero_voie],
['Établissement type voie', etablissement&.type_voie],
['Établissement nom voie', etablissement&.nom_voie],
['Établissement complément adresse', etablissement&.complement_adresse],
['Établissement code postal', etablissement&.code_postal],
['Établissement localité', etablissement&.localite],
['Établissement code INSEE localité', etablissement&.code_insee_localite],
['Entreprise SIREN', etablissement&.entreprise_siren],
['Entreprise capital social', etablissement&.entreprise_capital_social],
['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire],
['Entreprise forme juridique', etablissement&.entreprise_forme_juridique],
['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code],
['Entreprise nom commercial', etablissement&.entreprise_nom_commercial],
['Entreprise raison sociale', etablissement&.entreprise_raison_sociale],
['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social],
['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise],
['Entreprise date de création', etablissement&.entreprise_date_creation],
['Entreprise état administratif', etablissement&.entreprise_etat_administratif],
['Entreprise nom', etablissement&.entreprise_nom],
['Entreprise prénom', etablissement&.entreprise_prenom],
['Association RNA', etablissement&.association_rna],
['Association titre', etablissement&.association_titre],
['Association objet', etablissement&.association_objet],
['Association date de création', etablissement&.association_date_creation],
['Association date de déclaration', etablissement&.association_date_declaration],
['Association date de publication', etablissement&.association_date_publication]
]
else
columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale]
end
if procedure.chorusable? && procedure.chorus_configuration.complete?
columns += [
['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }],
['Référentiel De Programmation', procedure.chorus_configuration.referentiel_de_programmation&.fetch("code") { '' }],
['Centre De Coût', procedure.chorus_configuration.centre_de_cout&.fetch("code") { '' }]
]
end
columns += [
['Archivé', :archived],
['État du dossier', Dossier.human_attribute_name("state.#{state}")],
['Dernière mise à jour le', :updated_at],
['Dernière mise à jour du dossier le', :last_champ_updated_at],
['Déposé le', :depose_at],
['Passé en instruction le', :en_instruction_at],
procedure.sva_svr_enabled? ? ["Date décision #{procedure.sva_svr_configuration.human_decision}", :sva_svr_decision_on] : nil,
['Traité le', :processed_at],
['Motivation de la décision', :motivation],
['Instructeurs', followers_instructeurs.map(&:email).join(' ')]
].compact
if procedure.routing_enabled?
columns << ['Groupe instructeur', groupe_instructeur.label]
end
columns
end
end

View file

@ -13,6 +13,7 @@ class Dossier < ApplicationRecord
include DossierStateConcern
include DossierChampsConcern
include DossierEmptyConcern
include DossierExportConcern
enum state: {
brouillon: 'brouillon',
@ -944,100 +945,6 @@ class Dossier < ApplicationRecord
log_dossier_operation(avis.claimant, :demander_un_avis, avis)
end
def spreadsheet_columns_csv(types_de_champ:)
spreadsheet_columns(with_etablissement: true, types_de_champ: types_de_champ)
end
def spreadsheet_columns_xlsx(types_de_champ:)
spreadsheet_columns(types_de_champ: types_de_champ)
end
def spreadsheet_columns_ods(types_de_champ:)
spreadsheet_columns(types_de_champ: types_de_champ)
end
def spreadsheet_columns(with_etablissement: false, types_de_champ:)
columns = [
['ID', id.to_s],
['Email', user_email_for(:display)],
['FranceConnect ?', user_from_france_connect?]
]
if procedure.for_individual?
columns += [
['Civilité', individual&.gender],
['Nom', individual&.nom],
['Prénom', individual&.prenom],
['Dépôt pour un tiers', :for_tiers],
['Nom du mandataire', :mandataire_last_name],
['Prénom du mandataire', :mandataire_first_name]
]
if procedure.ask_birthday
columns += [['Date de naissance', individual&.birthdate]]
end
elsif with_etablissement
columns += [
['Établissement SIRET', etablissement&.siret],
['Établissement siège social', etablissement&.siege_social],
['Établissement NAF', etablissement&.naf],
['Établissement libellé NAF', etablissement&.libelle_naf],
['Établissement Adresse', etablissement&.adresse],
['Établissement numero voie', etablissement&.numero_voie],
['Établissement type voie', etablissement&.type_voie],
['Établissement nom voie', etablissement&.nom_voie],
['Établissement complément adresse', etablissement&.complement_adresse],
['Établissement code postal', etablissement&.code_postal],
['Établissement localité', etablissement&.localite],
['Établissement code INSEE localité', etablissement&.code_insee_localite],
['Entreprise SIREN', etablissement&.entreprise_siren],
['Entreprise capital social', etablissement&.entreprise_capital_social],
['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire],
['Entreprise forme juridique', etablissement&.entreprise_forme_juridique],
['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code],
['Entreprise nom commercial', etablissement&.entreprise_nom_commercial],
['Entreprise raison sociale', etablissement&.entreprise_raison_sociale],
['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social],
['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise],
['Entreprise date de création', etablissement&.entreprise_date_creation],
['Entreprise état administratif', etablissement&.entreprise_etat_administratif],
['Entreprise nom', etablissement&.entreprise_nom],
['Entreprise prénom', etablissement&.entreprise_prenom],
['Association RNA', etablissement&.association_rna],
['Association titre', etablissement&.association_titre],
['Association objet', etablissement&.association_objet],
['Association date de création', etablissement&.association_date_creation],
['Association date de déclaration', etablissement&.association_date_declaration],
['Association date de publication', etablissement&.association_date_publication]
]
else
columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale]
end
if procedure.chorusable? && procedure.chorus_configuration.complete?
columns += [
['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }],
['Référentiel De Programmation', procedure.chorus_configuration.referentiel_de_programmation&.fetch("code") { '' }],
['Centre De Coût', procedure.chorus_configuration.centre_de_cout&.fetch("code") { '' }]
]
end
columns += [
['Archivé', :archived],
['État du dossier', Dossier.human_attribute_name("state.#{state}")],
['Dernière mise à jour le', :updated_at],
['Dernière mise à jour du dossier le', :last_champ_updated_at],
['Déposé le', :depose_at],
['Passé en instruction le', :en_instruction_at],
procedure.sva_svr_enabled? ? ["Date décision #{procedure.sva_svr_configuration.human_decision}", :sva_svr_decision_on] : nil,
['Traité le', :processed_at],
['Motivation de la décision', :motivation],
['Instructeurs', followers_instructeurs.map(&:email).join(' ')]
].compact
if procedure.routing_enabled?
columns << ['Groupe instructeur', groupe_instructeur.label]
end
columns + champs_for_export(types_de_champ)
end
def linked_dossiers_for(instructeur_or_expert)
dossier_ids = filled_champs.filter(&:dossier_link?).filter_map(&:value)
instructeur_or_expert.dossiers.where(id: dossier_ids)

View file

@ -9,12 +9,14 @@ class ExportTemplate < ApplicationRecord
has_one :procedure, through: :groupe_instructeur
has_many :exports, dependent: :nullify
enum kind: { zip: "zip" }, _prefix: :template
enum kind: { zip: 'zip', csv: 'csv', xlsx: 'xlsx', ods: 'ods' }, _prefix: :template
attribute :dossier_folder, :export_item
attribute :export_pdf, :export_item
attribute :pjs, :export_item, array: true
attribute :exported_columns, :exported_column, array: true
before_validation :ensure_pjs_are_legit
validates_with ExportTemplateValidator
@ -28,6 +30,7 @@ class ExportTemplate < ApplicationRecord
end
def self.default(name: nil, kind: 'zip', groupe_instructeur:)
# TODO: remove default values for tabular export
dossier_folder = ExportItem.default(prefix: 'dossier')
export_pdf = ExportItem.default(prefix: 'export')
pjs = groupe_instructeur.procedure.exportables_pieces_jointes.map { |tdc| ExportItem.default_pj(tdc) }
@ -35,6 +38,10 @@ class ExportTemplate < ApplicationRecord
new(name:, kind:, groupe_instructeur:, dossier_folder:, export_pdf:, pjs:)
end
def tabular?
kind != 'zip'
end
def tags
tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten
end
@ -58,6 +65,19 @@ class ExportTemplate < ApplicationRecord
File.join(dossier_folder.path(dossier), file_path) if file_path.present?
end
def dossier_exported_columns = exported_columns.filter { _1.column.dossier_column? }
def columns_for_stable_id(stable_id)
exported_columns
.filter { _1.column.champ_column? }
.filter { _1.column.stable_id == stable_id }
end
def in_export?(exported_column)
@template_exported_columns ||= exported_columns.map(&:column)
@template_exported_columns.include?(exported_column.column)
end
private
def ensure_pjs_are_legit

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class ExportedColumn
attr_reader :column, :libelle
def initialize(column:, libelle:)
@column = column
@libelle = libelle
end
def id = { id: column.id, libelle: }.to_json
def libelle_with_value(champ_or_dossier, format:)
[libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:, format:), spreadsheet_architect_type]
end
def spreadsheet_architect_type
case @column.type
when :boolean
:boolean
when :decimal, :integer
:float
when :datetime
:time
when :date
:date
else
:string
end
end
end

View file

@ -71,10 +71,11 @@ class Procedure < ApplicationRecord
brouillon? ? draft_revision : published_revision
end
def all_revisions_types_de_champ(parent: nil)
def all_revisions_types_de_champ(parent: nil, with_header_section: false)
types_de_champ_scope = with_header_section ? TypeDeChamp.with_header_section : TypeDeChamp.fillable
if brouillon?
if parent.nil?
TypeDeChamp.fillable
types_de_champ_scope
.joins(:revision_types_de_champ)
.where(revision_types_de_champ: { revision_id: draft_revision_id, parent_id: nil })
.order(:private, :position)
@ -82,8 +83,8 @@ class Procedure < ApplicationRecord
draft_revision.children_of(parent)
end
else
cache_key = ['all_revisions_types_de_champ', published_revision, parent].compact
Rails.cache.fetch(cache_key, expires_in: 1.month) { published_revisions_types_de_champ(parent) }
cache_key = ['all_revisions_types_de_champ', published_revision, parent, with_header_section].compact
Rails.cache.fetch(cache_key, expires_in: 1.month) { published_revisions_types_de_champ(parent:, with_header_section:) }
end
end
@ -875,7 +876,7 @@ class Procedure < ApplicationRecord
@stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact.uniq
end
def published_revisions_types_de_champ(parent = nil)
def published_revisions_types_de_champ(parent: nil, with_header_section: false)
# all published revisions
revision_ids = revisions.ids - [draft_revision_id]
# fetch all parent types de champ
@ -889,8 +890,8 @@ class Procedure < ApplicationRecord
# fetch all type_de_champ.stable_id for all the revisions expect draft
# and for each stable_id take the bigger (more recent) type_de_champ.id
recent_ids = TypeDeChamp
.fillable
types_de_champ_scope = with_header_section ? TypeDeChamp.with_header_section : TypeDeChamp.fillable
recent_ids = types_de_champ_scope
.joins(:revision_types_de_champ)
.where(revision_types_de_champ: { revision_id: revision_ids, parent_id: parent_ids })
.group(:stable_id).select('MAX(types_de_champ.id)')

View file

@ -24,7 +24,6 @@ class TypeDeChamp < ApplicationRecord
TYPE_DE_CHAMP_TO_CATEGORIE = {
engagement_juridique: REFERENTIEL_EXTERNE,
header_section: STRUCTURE,
repetition: STRUCTURE,
dossier_link: STRUCTURE,
@ -171,6 +170,7 @@ class TypeDeChamp < ApplicationRecord
scope :not_repetition, -> { where.not(type_champ: type_champs.fetch(:repetition)) }
scope :not_condition, -> { where(condition: nil) }
scope :fillable, -> { where.not(type_champ: [type_champs.fetch(:header_section), type_champs.fetch(:explication)]) }
scope :with_header_section, -> { where.not(type_champ: TypeDeChamp.type_champs[:explication]) }
scope :dubious, -> {
where("unaccent(types_de_champ.libelle) ~* unaccent(?)", DubiousProcedure.forbidden_regexp)

View file

@ -27,6 +27,31 @@ class TypesDeChamp::CommuneTypeDeChamp < TypesDeChamp::TypeDeChampBase
champ.code_postal? ? "#{champ.name} (#{champ.code_postal})" : champ.name
end
def columns(procedure:, displayable: true, prefix: nil)
super.concat(
[
Columns::JSONPathColumn.new(
procedure_id: procedure.id,
stable_id:,
tdc_type: type_champ,
label: "#{libelle_with_prefix(prefix)} - code postal (5 chiffres)",
jsonpath: '$.code_postal',
displayable:,
type: :text
),
Columns::JSONPathColumn.new(
procedure_id: procedure.id,
stable_id:,
tdc_type: type_champ,
label: "#{libelle_with_prefix(prefix)} - département",
jsonpath: '$.code_departement',
displayable:,
type: :number
)
]
)
end
private
def paths

View file

@ -26,9 +26,11 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase
end
def columns(procedure:, displayable: nil, prefix: nil)
prefix = prefix.present? ? "(#{prefix} #{libelle})" : libelle
procedure
.all_revisions_types_de_champ(parent: @type_de_champ)
.flat_map { _1.columns(procedure:, displayable: false, prefix: libelle) }
.flat_map { _1.columns(procedure:, displayable: false, prefix:) }
end
def champ_blank?(champ) = champ.dossier.repetition_row_ids(@type_de_champ).blank?

View file

@ -81,7 +81,7 @@ class DossierFilterService
else
case table
when 'self'
if filtered_column.type == :date
if filtered_column.type == :date || filtered_column.type == :datetime
dates = values
.filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil }

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
class ExportedColumnFormatter
def self.format(column:, champ_or_dossier:, format:)
return if champ_or_dossier.nil?
raw_value = column.value(champ_or_dossier)
case column.type
when :boolean
format_boolean(column:, raw_value:, format:)
when :attachements
format_attachments(column:, raw_value:)
when :enum
format_enum(column:, raw_value:)
when :enums
format_enums(column:, raw_values: raw_value)
else
raw_value
end
end
private
def self.format_boolean(column:, raw_value:, format:)
if format == :ods
raw_value ? 1 : 0
else
raw_value
end
end
def self.format_attachments(column:, raw_value:)
case column.tdc_type
when TypeDeChamp.type_champs[:titre_identite]
raw_value.present? ? 'présent' : 'absent'
when TypeDeChamp.type_champs[:piece_justificative]
raw_value.map { _1.blob.filename }.join(", ")
end
end
def self.format_enums(column:, raw_values:)
raw_values.map { format_enum(column:, raw_value: _1) }.join(', ')
end
def self.format_enum(column:, raw_value:)
# options for select store ["trad", :enum_value]
selected_option = column.options_for_select.find { _1[1].to_s == raw_value }
selected_option ? selected_option.first : raw_value
end
end

View file

@ -18,7 +18,7 @@ class ProcedureExportService
def to_xlsx
@dossiers = @dossiers.downloadable_sorted_batch
tables = [:dossiers, :etablissements, :avis] + champs_repetables_options
tables = [:dossiers, :etablissements, :avis] + champs_repetables_options(format: :xlsx)
# We recursively build multi page spreadsheet
io = tables.reduce(nil) do |package, table|
@ -29,7 +29,7 @@ class ProcedureExportService
def to_ods
@dossiers = @dossiers.downloadable_sorted_batch
tables = [:dossiers, :etablissements, :avis] + champs_repetables_options
tables = [:dossiers, :etablissements, :avis] + champs_repetables_options(format: :ods)
# We recursively build multi page spreadsheet
io = StringIO.new(tables.reduce(nil) do |spreadsheet, table|
@ -103,7 +103,7 @@ class ProcedureExportService
@avis ||= dossiers.flat_map(&:avis)
end
def champs_repetables_options
def champs_repetables_options(format:)
procedure
.all_revisions_types_de_champ
.repetition
@ -115,7 +115,7 @@ class ProcedureExportService
{
sheet_name: type_de_champ_repetition.libelle_for_export,
instances: rows,
spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ) }
spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ, export_template: @export_template, format:) }
}
end
end
@ -152,7 +152,7 @@ class ProcedureExportService
types_de_champ = procedure.types_de_champ_for_procedure_export.to_a
Proc.new do |instance|
instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ)
instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ, export_template: @export_template)
end
end
end

View file

@ -29,7 +29,10 @@ class ExportItemType < ActiveRecord::Type::Value
# ruby -> db
def serialize(value)
if value.is_a?(ExportItem)
case value
in NilClass
nil
in ExportItem
JSON.generate({
template: value.template,
enabled: value.enabled,

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
class ExportedColumnType < ActiveRecord::Type::Value
# form_input or setter -> type
def cast(value)
value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys)
case value
in ExportedColumn
value
in NilClass # default value
nil
# from db
in { id: String|Hash, libelle: String } => h
ExportedColumn.new(column: ColumnType.new.cast(h[:id]), libelle: h[:libelle])
# from form
in String
h = JSON.parse(value).deep_symbolize_keys
ExportedColumn.new(column: ColumnType.new.cast(h[:id]), libelle: h[:libelle])
end
end
# db -> ruby
def deserialize(value) = cast(value&.then { JSON.parse(_1) })
# ruby -> db
def serialize(value)
case value
in NilClass
nil
in ExportedColumn
JSON.generate({
id: value.column.h_id,
libelle: value.libelle
})
else
raise ArgumentError, "Invalid value for ExportedColumn serialization: #{value}"
end
end
end

View file

@ -2,6 +2,8 @@
class ExportTemplateValidator < ActiveModel::Validator
def validate(export_template)
return if !export_template.template_zip?
validate_all_templates(export_template)
return if export_template.errors.any? # no need to continue if the templates are invalid

View file

@ -4,11 +4,12 @@
['Export et Archives']] }
.container
%h1.mb-2
.container.flex
%h1.mb-2.mr-2
Archives
-# index not renderable as administrateur flagged as manager, so render it anyway
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_admin_procedure_exports_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_admin_procedure_exports_path), show_export_template_tab: false)
.container
= render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_admin_procedure_exports_path))
= render partial: "shared/archives/notice"

View file

@ -0,0 +1,14 @@
%fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } }
%legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0
= title
.checkbox-group-bordered.fr-mx-1w.fr-mb-2w
.fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w
.fr-checkbox-group
= check_box_tag "#{title.parameterize}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' }
= label_tag "#{title.parameterize}-select-all", "Tout sélectionner"
- all_columns.each do |column|
.fr-fieldset__element.fr-px-4w
.fr-checkbox-group
= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column: ExportedColumn.new(libelle: column.label, column:))

View file

@ -0,0 +1,57 @@
#export_template-edit.fr-my-4w
.fr-mb-6w
= render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c|
- c.with_body do
= t('.info_html', mailto: mail_to(CONTACT_EMAIL, subject: 'Editeur de modèle d\'export'))
.fr-grid-row.fr-grid-row--gutters
.fr-col-12.fr-col-md-8
= form_with model: [:instructeur, @procedure, export_template], local: true do |f|
%h2 Paramètres de l'export
= f.hidden_field "[dossier_folder][template]", value: export_template.dossier_folder.template_json
= f.hidden_field "[export_pdf][template]", value: export_template.export_pdf.template_json
= render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field)
- if groupe_instructeurs.many?
.fr-input-group
= f.label :groupe_instructeur_id, class: 'fr-label' do
= f.object.class.human_attribute_name(:groupe_instructeur_id)
= render EditableChamp::AsteriskMandatoryComponent.new
%span.fr-hint-text
Avec quel groupe instructeur souhaitez-vous partager ce modèle d'export ?
= f.collection_select :groupe_instructeur_id, groupe_instructeurs, :id, :label, {}, class: 'fr-select'
- else
= f.hidden_field :groupe_instructeur_id
%fieldset.fr-fieldset.fr-fieldset--inline
%legend#radio-inline-legend.fr-fieldset__legend.fr-text--regular
Format export
= asterisk
.fr-fieldset__element.fr-fieldset__element--inline
.fr-radio-group
= f.radio_button :kind, "xlsx", id: "xlsx"
%label.fr-label{ for: "xlsx" } xlsx
.fr-radio-group
= f.radio_button :kind, "ods", id: "ods"
%label.fr-label{ for: "ods" } ods
.fr-radio-group
= f.radio_button :kind, "csv", id: "csv"
%label.fr-label{ for: "csv" } csv
%h2 Contenu de l'export
%p Sélectionnez les colonnes que vous souhaitez voir affichées dans le tableau de votre export.
= render partial: 'checkbox_group', locals: { title: 'Informations usager', all_columns: @export_template.procedure.usager_columns_for_export, export_template: @export_template }
= render partial: 'checkbox_group', locals: { title: 'Informations dossier', all_columns: @export_template.procedure.dossier_columns_for_export, export_template: @export_template }
= render ExportTemplate::ChampsComponent.new("Formulaire usager", @export_template, @types_de_champ_public)
= render ExportTemplate::ChampsComponent.new("Annotations privées", @export_template, @types_de_champ_private) if @types_de_champ_private.any?
.fixed-footer
.fr-container
%ul.fr-btns-group.fr-btns-group--inline-md
%li
= link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary"
%li
= f.submit "Enregistrer", class: "fr-btn", data: @export_template.persisted? ? { confirm: t('.warning') } : {}

View file

@ -4,4 +4,7 @@
.fr-container
%h1 Mise à jour modèle d'export
= render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs }
- if @export_template.tabular?
= render partial: 'form_tabular', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs }
- else
= render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs }

View file

@ -3,4 +3,7 @@
[t('.title')]] }
.fr-container
%h1 Nouveau modèle d'export
= render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs }
- if @export_template.tabular?
= render partial: 'form_tabular', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs }
- else
= render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs }

View file

@ -6,41 +6,59 @@
[t('.title')]] }
.fr-container
%h1= t('.title')
= render Dsfr::CalloutComponent.new(title: nil) do |c|
- c.with_body do
%p= t('.export_description', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i)
.fr-tabs.mb-3
%ul.fr-tabs__list{ role: 'tablist' }
%li{ role: 'presentation' }
%button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-exports", tabindex: "0", role: "tab", "aria-selected": "true", "aria-controls": "tabpanel-exports-panel" } Liste des exports
%li{ role: 'presentation' }
%button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-export-templates", tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "tabpanel-export-templates-panel" } Modèles d'export
- if @exports.present?
%div{ data: @exports.any?(&:pending?) ? { controller: "turbo-poll", turbo_poll_url_value: "", turbo_poll_interval_value: 10_000, turbo_poll_max_checks_value: 6 } : {} }
= render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))
- if @exports.any?{_1.format == Export.formats.fetch(:zip)}
= render Dsfr::AlertComponent.new(title: t('.title_zip'), state: :info, extra_class_names: 'fr-mb-3w') do |c|
.fr-tabs__panel.fr-tabs__panel--selected{ id: "tabpanel-exports-panel", role: "tabpanel", "aria-labelledby": "tabpanel-exports", tabindex: "0" }
= render Dsfr::CalloutComponent.new(title: nil) do |c|
- c.with_body do
%p= t('.export_description_zip_html')
%p= t('.export_description', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i)
- else
= t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i )
- if @exports.present?
%div{ data: @exports.any?(&:pending?) ? { controller: "turbo-poll", turbo_poll_url_value: "", turbo_poll_interval_value: 10_000, turbo_poll_max_checks_value: 6 } : {} }
= render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))
- if @procedure.feature_enabled?(:export_template)
%h2.fr-mb-1w.fr-mt-8w
Liste des modèles d'export
%p.fr-hint-text
Un modèle d'export permet de personnaliser le nom des fichiers (pour un export au format Zip)
- if @export_templates.any?
.fr-table.fr-table--no-caption.fr-mt-5w
%table
%thead
%tr
%th{ scope: 'col' } Nom du modèle
%th{ scope: 'col' }= "Groupe instructeur" if @procedure.groupe_instructeurs.many?
%tbody
- @export_templates.each do |export_template|
%tr
%td= link_to export_template.name, [:edit, :instructeur, @procedure, export_template]
%td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many?
- if @exports.any?{_1.format == Export.formats.fetch(:zip)}
= render Dsfr::AlertComponent.new(title: t('.title_zip'), state: :info, extra_class_names: 'fr-mb-3w') do |c|
- c.with_body do
%p= t('.export_description_zip_html')
%p
= link_to [:new, :instructeur, @procedure, :export_template], class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do
Ajouter un modèle d'export
- else
= t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i )
.fr-tabs__panel.fr-tabs__panel{ id: "tabpanel-export-templates-panel", role: "tabpanel", "aria-labelledby": "tabpanel-export-templates", tabindex: "0" }
= render Dsfr::AlertComponent.new(state: :info) do |c|
- c.with_body do
%p= t('.export_template_list_description_html')
.fr-mt-5w
= link_to t('.new_zip_export_template'), new_instructeur_procedure_export_template_path(@procedure), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line fr-mr-1w"
= link_to t('.new_tabular_export_template'), new_instructeur_procedure_export_template_path(@procedure, kind: 'tabular'), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line"
.fr-table.fr-table--bordered.fr-table--no-caption.fr-mt-5w
.fr-table__wrapper
.fr-table__container
.fr-table__content
%table
%thead
%tr
= tag.th "Nom du modèle", scope: 'col'
= tag.th "Format", scope: 'col'
= tag.th "Date de création", scope: 'col'
= tag.th "Partagé avec (groupe instructeurs)", scope: 'col' if @procedure.groupe_instructeurs.many?
= tag.th "Actions", scope: 'col'
%tbody
- @export_templates.each do |export_template|
%tr
%td= link_to export_template.name, [:edit, :instructeur, @procedure, export_template]
%td= pretty_kind(export_template.kind)
%td= l(export_template.created_at)
= tag.td export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many?
%td
= link_to "Modifier", [:edit, :instructeur, @procedure, export_template], class: "fr-btn fr-btn--icon-left fr-icon-edit-line fr-mr-1w"
= link_to "Supprimer", [:instructeur, @procedure, export_template], method: :delete, data: { confirm: "Voulez-vous vraiment supprimer ce modèle ? Il sera supprimé pour tous les instructeurs du groupe"}, class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-delete-line"

View file

@ -4,10 +4,12 @@ require Rails.root.join("app/types/column_type")
require Rails.root.join("app/types/export_item_type")
require Rails.root.join("app/types/sorted_column_type")
require Rails.root.join("app/types/filtered_column_type")
require Rails.root.join("app/types/exported_column_type")
ActiveSupport.on_load(:active_record) do
ActiveRecord::Type.register(:column, ColumnType)
ActiveRecord::Type.register(:export_item, ExportItemType)
ActiveRecord::Type.register(:sorted_column, SortedColumnType)
ActiveRecord::Type.register(:filtered_column, FilteredColumnType)
ActiveRecord::Type.register(:exported_column, ExportedColumnType)
end

View file

@ -17,3 +17,12 @@ en:
dossier_number_required: "must contain dossier's number"
different_templates: "Files must have different names"
invalid_template: "A file name is invalid"
base:
invalid: "is invalid"
instructeurs:
export_templates:
form_tabular:
info_html: |
This page allows you to edit a tabular export template and select fields that you want to export.
Try it and let us know what you think by sending an e-mail to %{mailto}.
warning: If you modify this template, it will also be modified for all instructors who have access to this template.

View file

@ -17,3 +17,13 @@ fr:
dossier_number_required: doit contenir le numéro du dossier
different_templates: Les fichiers doivent avoir des noms différents
invalid_template: Un nom de fichier est invalide
base:
invalid: "est invalide"
instructeurs:
export_templates:
form_tabular:
info_html: |
Cette page permet d'éditer un modèle d'export tabulaire et ainsi sélectionner les champs que vous souhaitez exporter.
Essayez-le et donnez-nous votre avis
en nous envoyant un email à %{mailto}.
warning: Si vous modifiez ce modèle, il sera également modifié pour tous les instructeurs qui ont accès à ce modèle.

View file

@ -12,7 +12,7 @@ en:
button_delay_expiration: "Keep for one more month"
notification_management: notification management
administrators_list: administrators list
exports_list: exports list
exports_list: exports and export templates
exports_notification_label: A new export is ready to download
statistics: statistics
instructeurs: instructors

View file

@ -13,7 +13,7 @@ fr:
button_delay_expiration: "Conserver un mois de plus"
notification_management: Gestion des notifications
administrators_list: Voir les administrateurs
exports_list: Voir les exports
exports_list: Voir les exports et modèles d'export
exports_notification_label: Un nouvel export est prêt à être téléchargé
statistics: Statistiques
instructeurs: instructeurs

View file

@ -18,3 +18,9 @@ en:
no_export_html: You have no export at the moment. <br> Can't find an export? It may have expired, exports are deleted after %{expiration_time} hours.
export_template_list_description_html: |
Each instructor can <b>configure an export template</b> to customize exports (attachments name for a zip export, columns selection for a tabular export). It will be made <b>available to all instructors</b> assigned to the procedure.</br>
<a href="https://doc.demarches-simplifiees.fr" target="_blank" rel="noopener noreferrer">Find out more about export template configuration</a>
new_zip_export_template: Create zip export template
new_tabular_export_template: Create tabular export template

View file

@ -17,3 +17,8 @@ fr:
Vous n'arrivez pas à extraire un export au format .zip sur un réseau d'entreprise ? Essayer de renommer l'archive avec un nom plus court et ré-essayer de l'extraire.
no_export_html: Vous n'avez pas d'export pour le moment. <br> Vous ne trouvez pas un export ? Il a peut-être expiré, les exports sont supprimés au bout de %{expiration_time} heures.
export_template_list_description_html: |
Chaque instructeur a la possibilité de <b>configurer un modèle</b> d'export pour personnaliser les exports (nom des pièces jointes pour un export au format zip, sélection des colonnes pour un export tabulaire). Il sera <b>mis à disposition de l'ensemble des instructeurs</b> affectés à la démarche<br>
<a href="https://doc.demarches-simplifiees.fr" target="_blank" rel="noopener noreferrer">En savoir plus sur la configuration des modèles d'export</a>
new_zip_export_template: Créer un modèle d'export zip
new_tabular_export_template: Créer un modèle d'export tabulaire

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddColumnsToExportTemplate < ActiveRecord::Migration[7.0]
def change
add_column :export_templates, :exported_columns, :jsonb, array: true, default: [], null: false
end
end

View file

@ -626,6 +626,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_11_12_090128) do
t.datetime "created_at", null: false
t.jsonb "dossier_folder", null: false
t.jsonb "export_pdf", null: false
t.jsonb "exported_columns", default: [], null: false, array: true
t.bigint "groupe_instructeur_id", null: false
t.string "kind", null: false
t.string "name", null: false

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
describe ExportTemplate::ChampsComponent, type: :component do
let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) }
let(:export_template) { build(:export_template, kind: 'csv', groupe_instructeur:) }
let(:procedure) { create(:procedure_with_dossiers, :published, types_de_champ_public:, for_individual:) }
let(:for_individual) { true }
let(:types_de_champ_public) do
[
{ type: :text, libelle: "Ca va ?", mandatory: true, stable_id: 1 },
{ type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 },
{ type: :siret, libelle: 'Siret', stable_id: 20 },
{ type: :repetition, mandatory: true, stable_id: 7, libelle: "Amis", children: [{ type: 'text', libelle: 'Prénom', stable_id: 8 }] }
]
end
let(:component) { described_class.new("Champs publics", export_template, procedure.all_revisions_types_de_champ(parent: nil, with_header_section: true)) }
before { render_inline(component).to_html }
it 'renders champs within fieldset' do
procedure
expect(page).to have_unchecked_field "Ca va ?"
expect(page).to have_unchecked_field "Commune"
expect(page).to have_unchecked_field "Siret"
expect(page).to have_unchecked_field "(Bloc répétable Amis) Prénom"
end
end

View file

@ -86,6 +86,42 @@ describe Instructeurs::ExportTemplatesController, type: :controller do
expect(ExportTemplate.last.pjs).to match_array([])
end
end
context 'with tabular params' do
let(:procedure) do
create(
:procedure, instructeurs: [instructeur],
types_de_champ_public: [{ type: :text, libelle: 'un texte', stable_id: 1 }]
)
end
let(:exported_columns) do
[
{ id: procedure.find_column(label: 'Demandeur').id, libelle: 'Demandeur' },
{ id: procedure.find_column(label: 'Date du dernier évènement').id, libelle: 'Date du dernier évènement' }
].map(&:to_json)
end
let(:create_params) do
{
name: "ExportODS",
kind: "ods",
groupe_instructeur_id: groupe_instructeur.id,
export_pdf: item_params(text: "export"),
dossier_folder: item_params(text: "dossier"),
exported_columns:
}
end
context 'with valid params' do
it 'redirect to some page' do
subject
expect(response).to redirect_to(exports_instructeur_procedure_path(procedure))
expect(flash.notice).to eq "Le modèle d'export ExportODS a bien été créé"
expect(ExportTemplate.last.exported_columns.map(&:libelle)).to match_array ['Demandeur', 'Date du dernier évènement']
end
end
end
end
describe '#edit' do
@ -146,6 +182,35 @@ describe Instructeurs::ExportTemplatesController, type: :controller do
expect(flash.alert).to be_present
end
end
context 'for tabular' do
let(:exported_columns) do
[
{ id: procedure.find_column(label: 'Demandeur').id, libelle: 'Demandeur' },
{ id: procedure.find_column(label: 'Date du dernier évènement').id, libelle: 'Date du dernier évènement' }
].map(&:to_json)
end
let(:export_template_params) do
{
name: "ExportODS",
kind: "ods",
groupe_instructeur_id: groupe_instructeur.id,
export_pdf: item_params(text: "export"),
dossier_folder: item_params(text: "dossier"),
exported_columns:
}
end
context 'with valid params' do
it 'redirect to some page' do
subject
expect(response).to redirect_to(exports_instructeur_procedure_path(procedure))
expect(flash.notice).to eq "Le modèle d'export ExportODS a bien été modifié"
expect(ExportTemplate.last.exported_columns.map(&:libelle)).to match_array ['Demandeur', 'Date du dernier évènement']
end
end
end
end
describe '#destroy' do

View file

@ -170,15 +170,20 @@ FactoryBot.define do
factory :champ_do_not_use_rna, class: 'Champs::RNAChamp' do
value { 'W173847273' }
value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself) }
end
factory :champ_do_not_use_engagement_juridique, class: 'Champs::EngagementJuridiqueChamp' do
value { 'EJ' }
end
factory :champ_do_not_use_cojo, class: 'Champs::COJOChamp' do
end
factory :champ_do_not_use_rnf, class: 'Champs::RNFChamp' do
value { '075-FDD-00003-01' }
external_id { '075-FDD-00003-01' }
value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself) }
end
factory :champ_do_not_use_expression_reguliere, class: 'Champs::ExpressionReguliereChamp' do

View file

@ -13,7 +13,7 @@ describe Columns::ChampColumn do
expect_type_de_champ_values('email', eq(['yoda@beta.gouv.fr']))
expect_type_de_champ_values('phone', eq(['0666666666']))
expect_type_de_champ_values('address', eq(["2 rue des Démarches"]))
expect_type_de_champ_values('communes', eq(["Coye-la-Forêt"]))
expect_type_de_champ_values('communes', eq(["Coye-la-Forêt", "60580", "60"]))
expect_type_de_champ_values('departements', eq(['01']))
expect_type_de_champ_values('regions', eq(['01']))
expect_type_de_champ_values('pays', eq(['France']))
@ -30,7 +30,7 @@ describe Columns::ChampColumn do
expect_type_de_champ_values('checkbox', eq([true]))
expect_type_de_champ_values('drop_down_list', eq(['val1']))
expect_type_de_champ_values('multiple_drop_down_list', eq([["val1", "val2"]]))
expect_type_de_champ_values('linked_drop_down_list', eq([nil, "primary", "secondary"]))
expect_type_de_champ_values('linked_drop_down_list', eq(["primary / secondary", "primary", "secondary"]))
expect_type_de_champ_values('yes_no', eq([true]))
expect_type_de_champ_values('annuaire_education', eq([nil]))
expect_type_de_champ_values('piece_justificative', be_an_instance_of(Array))

View file

@ -14,6 +14,9 @@ describe Columns::DossierColumn do
expect(procedure.find_column(label: "Prénom").value(dossier)).to eq("Paul")
expect(procedure.find_column(label: "Nom").value(dossier)).to eq("Sim")
expect(procedure.find_column(label: "Civilité").value(dossier)).to eq("M.")
expect(procedure.find_column(label: "Dépôt pour un tiers").value(dossier)).to eq(true)
expect(procedure.find_column(label: "Nom du mandataire").value(dossier)).to eq("Christophe")
expect(procedure.find_column(label: "Prénom du mandataire").value(dossier)).to eq("Martin")
end
end
@ -22,7 +25,67 @@ describe Columns::DossierColumn do
let(:dossier) { create(:dossier, :en_instruction, :with_entreprise, procedure:) }
it 'retrieve entreprise information' do
expect(procedure.find_column(label: " dossier").value(dossier)).to eq(dossier.id)
expect(procedure.find_column(label: "Email").value(dossier)).to eq(dossier.user_email_for(:display))
expect(procedure.find_column(label: "France connecté ?").value(dossier)).to eq(false)
expect(procedure.find_column(label: "Entreprise forme juridique").value(dossier)).to eq("SA à conseil d'administration (s.a.i.)")
expect(procedure.find_column(label: "Entreprise SIREN").value(dossier)).to eq('440117620')
expect(procedure.find_column(label: "Entreprise nom commercial").value(dossier)).to eq('GRTGAZ')
expect(procedure.find_column(label: "Entreprise raison sociale").value(dossier)).to eq('GRTGAZ')
expect(procedure.find_column(label: "Entreprise SIRET siège social").value(dossier)).to eq('44011762001530')
expect(procedure.find_column(label: "Date de création").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone)
expect(procedure.find_column(label: "Établissement SIRET").value(dossier)).to eq('44011762001530')
expect(procedure.find_column(label: "Libellé NAF").value(dossier)).to eq('Transports par conduites')
expect(procedure.find_column(label: "Établissement code postal").value(dossier)).to eq('92270')
expect(procedure.find_column(label: "Établissement siège social").value(dossier)).to eq(true)
expect(procedure.find_column(label: "Établissement NAF").value(dossier)).to eq('4950Z')
expect(procedure.find_column(label: "Établissement Adresse").value(dossier)).to eq("GRTGAZ\r IMMEUBLE BORA\r 6 RUE RAOUL NORDLING\r 92270 BOIS COLOMBES\r")
expect(procedure.find_column(label: "Établissement numero voie").value(dossier)).to eq('6')
expect(procedure.find_column(label: "Établissement type voie").value(dossier)).to eq('RUE')
expect(procedure.find_column(label: "Établissement nom voie").value(dossier)).to eq('RAOUL NORDLING')
expect(procedure.find_column(label: "Établissement complément adresse").value(dossier)).to eq('IMMEUBLE BORA')
expect(procedure.find_column(label: "Établissement localité").value(dossier)).to eq('BOIS COLOMBES')
expect(procedure.find_column(label: "Établissement code INSEE localité").value(dossier)).to eq('92009')
expect(procedure.find_column(label: "Entreprise SIREN").value(dossier)).to eq('440117620')
expect(procedure.find_column(label: "Entreprise capital social").value(dossier)).to eq(537_100_000)
expect(procedure.find_column(label: "Entreprise numero TVA intracommunautaire").value(dossier)).to eq('FR27440117620')
expect(procedure.find_column(label: "Entreprise forme juridique code").value(dossier)).to eq('5599')
expect(procedure.find_column(label: "Entreprise code effectif entreprise").value(dossier)).to eq('51')
expect(procedure.find_column(label: "Entreprise état administratif").value(dossier)).to eq("actif")
expect(procedure.find_column(label: "Entreprise nom").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Entreprise prénom").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Association RNA").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Association titre").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Association objet").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Association date de création").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Association date de déclaration").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Association date de publication").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Date de création").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone)
expect(procedure.find_column(label: "Date du dernier évènement").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone)
expect(procedure.find_column(label: "Date de dépot").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone)
expect(procedure.find_column(label: "Date de passage en construction").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone)
expect(procedure.find_column(label: "Date de passage en instruction").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone)
expect(procedure.find_column(label: "Date de traitement").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "État du dossier").value(dossier)).to eq('en_instruction')
expect(procedure.find_column(label: "Archivé").value(dossier)).to eq(false)
expect(procedure.find_column(label: "Motivation de la décision").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Date de dernière modification (usager)").value(dossier)).to eq(nil)
expect(procedure.find_column(label: "Instructeurs").value(dossier)).to eq('')
end
end
context 'when procedure for entreprise which is also an association' do
let(:procedure) { create(:procedure, for_individual: false, groupe_instructeurs: [groupe_instructeur]) }
let(:etablissement) { create(:etablissement, :is_association) }
let(:dossier) { create(:dossier, :en_instruction, procedure:, etablissement:) }
it 'retrieve also association information' do
expect(procedure.find_column(label: "Association RNA").value(dossier)).to eq("W072000535")
expect(procedure.find_column(label: "Association titre").value(dossier)).to eq("ASSOCIATION POUR LA PROMOTION DE SPECTACLES AU CHATEAU DE ROCHEMAURE")
expect(procedure.find_column(label: "Association objet").value(dossier)).to eq("mise en oeuvre et réalisation de spectacles au chateau de rochemaure")
expect(procedure.find_column(label: "Association date de création").value(dossier)).to eq(Date.parse("1990-04-24"))
expect(procedure.find_column(label: "Association date de déclaration").value(dossier)).to eq(Date.parse("2014-11-28"))
expect(procedure.find_column(label: "Association date de publication").value(dossier)).to eq(Date.parse("1990-05-16"))
end
end

View file

@ -183,8 +183,8 @@ RSpec.describe DossierChampsConcern do
it { row_id; subject; expect(row_id).not_to be_in(row_ids) }
end
describe "#champs_for_export" do
subject { dossier.champs_for_export(dossier.revision.types_de_champ_public) }
describe "#champ_values_for_export" do
subject { dossier.champ_values_for_export(dossier.revision.types_de_champ_public, format: :xlsx) }
it { expect(subject.size).to eq(4) }
it { expect(subject.first).to eq(["Un champ text", nil]) }

View file

@ -1982,7 +1982,7 @@ describe Dossier, type: :model do
end
end
describe "champs_for_export" do
describe "champ_values_for_export" do
context 'with integer_number' do
let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :integer_number, libelle: 'c1' }]) }
let(:dossier) { create(:dossier, :with_populated_champs, procedure:) }
@ -1993,7 +1993,7 @@ describe Dossier, type: :model do
expect {
integer_number_type_de_champ.update(type_champ: :decimal_number)
procedure.update(published_revision: procedure.draft_revision, draft_revision: procedure.create_new_revision)
}.to change { dossier.reload.champs_for_export(procedure.all_revisions_types_de_champ.not_repetition.to_a) }
}.to change { dossier.reload.champ_values_for_export(procedure.all_revisions_types_de_champ.not_repetition.to_a, format: :xlsx) }
.from([["c1", 42]]).to([["c1", 42.0]])
end
end
@ -2020,8 +2020,8 @@ describe Dossier, type: :model do
let(:repetition_second_revision_champ) { dossier_second_revision.project_champs_public.find(&:repetition?) }
let(:dossier) { create(:dossier, procedure: procedure) }
let(:dossier_second_revision) { create(:dossier, procedure: procedure) }
let(:dossier_champs_for_export) { dossier.champs_for_export(procedure.types_de_champ_for_procedure_export) }
let(:dossier_second_revision_champs_for_export) { dossier_second_revision.champs_for_export(procedure.types_de_champ_for_procedure_export) }
let(:dossier_champ_values_for_export) { dossier.champ_values_for_export(procedure.types_de_champ_for_procedure_export, format: :xlsx) }
let(:dossier_second_revision_champ_values_for_export) { dossier_second_revision.champ_values_for_export(procedure.types_de_champ_for_procedure_export, format: :xlsx) }
context "when procedure published" do
before do
@ -2040,8 +2040,8 @@ describe Dossier, type: :model do
it "should have champs from all revisions" do
expect(dossier.types_de_champ.map(&:libelle)).to eq([text_type_de_champ.libelle, datetime_type_de_champ.libelle, "Yes/no", explication_type_de_champ.libelle, commune_type_de_champ.libelle, repetition_type_de_champ.libelle])
expect(dossier_second_revision.types_de_champ.map(&:libelle)).to eq([datetime_type_de_champ.libelle, "Updated yes/no", explication_type_de_champ.libelle, 'Commune de naissance', "Repetition", "New text field"])
expect(dossier_champs_for_export.map { |(libelle)| libelle }).to eq([datetime_type_de_champ.libelle, text_type_de_champ.libelle, "Updated yes/no", "Commune de naissance", "Commune de naissance (Code INSEE)", "Commune de naissance (Département)", "New text field"])
expect(dossier_champs_for_export).to eq(dossier_second_revision_champs_for_export)
expect(dossier_champ_values_for_export.map { |(libelle)| libelle }).to eq([datetime_type_de_champ.libelle, text_type_de_champ.libelle, "Updated yes/no", "Commune de naissance", "Commune de naissance (Code INSEE)", "Commune de naissance (Département)", "New text field"])
expect(dossier_champ_values_for_export).to eq(dossier_second_revision_champ_values_for_export)
end
context 'within a repetition having a type de champs commune (multiple values for export)' do
@ -2056,7 +2056,7 @@ describe Dossier, type: :model do
dossier_test = create(:dossier, procedure: proc_test)
type_champs = proc_test.all_revisions_types_de_champ(parent: tdc_repetition).to_a
expect(type_champs.size).to eq(1)
expect(dossier.champs_for_export(type_champs).size).to eq(3)
expect(dossier.champ_values_for_export(type_champs, format: :xlsx).size).to eq(3)
end
end
end
@ -2065,7 +2065,7 @@ describe Dossier, type: :model do
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }, { type: :explication }]) }
it "should not contain non-exportable types de champ" do
expect(dossier_champs_for_export.map { |(libelle)| libelle }).to eq([text_type_de_champ.libelle])
expect(dossier_champ_values_for_export.map { |(libelle)| libelle }).to eq([text_type_de_champ.libelle])
end
end
end
@ -2079,7 +2079,7 @@ describe Dossier, type: :model do
let(:text_tdc) { procedure.active_revision.types_de_champ_public.second }
let(:tdcs) { dossier.project_champs_public.map(&:type_de_champ) }
subject { dossier.champs_for_export(tdcs) }
subject { dossier.champ_values_for_export(tdcs, format: :xlsx) }
before do
text_tdc.update(condition: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)))

View file

@ -0,0 +1,144 @@
# frozen_string_literal: true
describe ExportTemplate do
let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) }
let(:export_template) { build(:export_template, kind: 'csv', groupe_instructeur:) }
let(:tabular_export_template) { build(:tabular_export_template, groupe_instructeur:) }
let(:procedure) { create(:procedure_with_dossiers, :published, types_de_champ_public:, for_individual:) }
let(:for_individual) { true }
let(:types_de_champ_public) do
[
{ type: :text, libelle: "Ca va ?", mandatory: true, stable_id: 1 },
{ type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 },
{ type: :siret, libelle: 'siret', stable_id: 20 },
{ type: :repetition, mandatory: true, stable_id: 7, libelle: "Champ répétable", children: [{ type: 'text', libelle: 'Qqchose à rajouter?', stable_id: 8 }] }
]
end
describe '#exported_columns=' do
it 'is assignable/readable with ExportedColumn object' do
expect do
export_template.exported_columns = [
ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?"))
]
export_template.save!
export_template.exported_columns
end.not_to raise_error
end
it 'create exported_column' do
export_template.exported_columns = [
ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?"))
]
export_template.save!
expect(export_template.exported_columns.size).to eq 1
end
context 'when there is a previous revision with a renamed tdc' do
context 'with already column in export template' do
let(:previous_tdc) { procedure.published_revision.types_de_champ_public.find_by(stable_id: 1) }
let(:changed_tdc) { { libelle: "Ca roule ?" } }
context 'with already column in export template' do
before do
export_template.exported_columns = [
ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?"))
]
export_template.save!
type_de_champ = procedure.draft_revision.find_and_ensure_exclusive_use(previous_tdc.stable_id)
type_de_champ.update(changed_tdc)
procedure.publish_revision!
end
it 'update columns with original libelle for champs with new revision' do
Current.procedure_columns = {}
procedure.reload
export_template.reload
expect(export_template.exported_columns.find { _1.column.stable_id.to_s == "1" }.libelle).to eq('Ça va ?')
end
end
end
context 'without columns in export template' do
let(:previous_tdc) { procedure.published_revision.types_de_champ_public.find_by(stable_id: 1) }
let(:changed_tdc) { { libelle: "Ca roule ?" } }
before do
type_de_champ = procedure.draft_revision.find_and_ensure_exclusive_use(previous_tdc.stable_id)
type_de_champ.update(changed_tdc)
procedure.publish_revision!
export_template.exported_columns = [
ExportedColumn.new(libelle: 'Ça roule ?', column: procedure.find_column(label: "Ca roule ?"))
]
export_template.save!
end
it 'update columns with original libelle for champs with new revision' do
Current.procedure_columns = {}
procedure.reload
export_template.reload
expect(export_template.exported_columns.find { _1.column.stable_id.to_s == "1" }.libelle).to eq('Ça roule ?')
end
end
end
end
describe 'dossier_exported_columns' do
context 'when exported_columns is empty' do
it 'returns an empty array' do
expect(export_template.dossier_exported_columns).to eq([])
end
end
context 'when exported_columns is not empty' do
before do
export_template.exported_columns = [
ExportedColumn.new(libelle: 'Colonne usager', column: procedure.find_column(label: "Email")),
ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?"))
]
end
it 'returns all columns except tdc columns' do
expect(export_template.dossier_exported_columns.size).to eq(1) # exclude tdc
expect(export_template.dossier_exported_columns.first.libelle).to eq("Colonne usager")
end
end
end
describe 'columns_for_stable_id' do
before do
export_template.exported_columns = procedure.published_revision.types_de_champ.first.columns(procedure: procedure).map do |column|
ExportedColumn.new(libelle: column.label, column:)
end
end
context 'when procedure has a TypeDeChamp::Commune' do
let(:types_de_champ_public) do
[
{ type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 }
]
end
it 'is able to resolve stable_id' do
expect(export_template.columns_for_stable_id(17).size).to eq(3)
end
end
context 'when procedure has a TypeDeChamp::Siret' do
let(:types_de_champ_public) do
[
{ type: :siret, libelle: 'siret', stable_id: 20 }
]
end
it 'is able to resolve stable_id' do
expect(export_template.columns_for_stable_id(20).size).to eq(5)
end
end
context 'when procedure has a TypeDeChamp::Text' do
let(:types_de_champ_public) do
[
{ type: :text, libelle: "Text", mandatory: true, stable_id: 15 }
]
end
it 'is able to resolve stable_id' do
expect(export_template.columns_for_stable_id(15).size).to eq(1)
end
end
end
end

View file

@ -1885,6 +1885,49 @@ describe Procedure do
end
end
describe '#all_revisions_types_de_champ' do
let(:types_de_champ_public) do
[
{ type: :text },
{ type: :header_section }
]
end
context 'when procedure brouillon' do
let(:procedure) { create(:procedure, types_de_champ_public:) }
it 'returns one type de champ' do
expect(procedure.all_revisions_types_de_champ.size).to eq 1
end
it 'returns also section type de champ' do
expect(procedure.all_revisions_types_de_champ(with_header_section: true).size).to eq 2
end
it "returns types de champ on draft revision" do
procedure.draft_revision.add_type_de_champ(type_champ: :text, libelle: 'onemorechamp')
expect(procedure.reload.all_revisions_types_de_champ.size).to eq 2
end
end
context 'when procedure is published' do
let(:procedure) { create(:procedure, :published, types_de_champ_public:) }
it 'returns one type de champ' do
expect(procedure.all_revisions_types_de_champ.size).to eq 1
end
it 'returns also section type de champ' do
expect(procedure.all_revisions_types_de_champ(with_header_section: true).size).to eq 2
end
it "doesn't return types de champ on draft revision" do
procedure.draft_revision.add_type_de_champ(type_champ: :text, libelle: 'onemorechamp')
expect(procedure.reload.all_revisions_types_de_champ.size).to eq 1
end
end
end
private
def create_dossier_with_pj_of_size(size, procedure)

View file

@ -1,7 +1,6 @@
# frozen_string_literal: true
describe TypesDeChamp::CommuneTypeDeChamp do
let(:subject) { create(:type_de_champ_communes, libelle: 'Ma commune') }
it { expect(subject.libelles_for_export).to match_array([['Ma commune', :value], ['Ma commune (Code INSEE)', :code], ['Ma commune (Département)', :departement]]) }
let(:tdc_commune) { create(:type_de_champ_communes, libelle: 'Ma commune') }
it { expect(tdc_commune.libelles_for_export).to match_array([['Ma commune', :value], ['Ma commune (Code INSEE)', :code], ['Ma commune (Département)', :departement]]) }
end

View file

@ -0,0 +1,282 @@
# frozen_string_literal: true
require 'csv'
describe ProcedureExportService do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:procedure, types_de_champ_public:, for_individual:, ask_birthday: true, instructeurs: [instructeur]) }
let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) }
let(:for_individual) { true }
let(:types_de_champ_public) do
[
{ type: :text, libelle: "first champ", mandatory: true, stable_id: 1 },
{ type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 },
{ type: :piece_justificative, libelle: "PJ", stable_id: 30 },
{
type: :repetition, mandatory: true, stable_id: 7, libelle: "Champ répétable", children:
[
{ type: 'text', libelle: 'child first champ', stable_id: 8 },
{ type: 'text', libelle: 'child second champ', stable_id: 9 }
]
}
]
end
let(:exported_columns) { [] }
describe 'to_xlsx' do
let(:kind) { 'xlsx' }
let(:export_template) { create(:export_template, kind:, exported_columns:, groupe_instructeur: procedure.defaut_groupe_instructeur) }
let(:dossiers_sheet) { subject.sheets.first }
let(:etablissements_sheet) { subject.sheets.second }
let(:avis_sheet) { subject.sheets.third }
let(:repetition_sheet) { subject.sheets.fourth }
subject do
service
.to_xlsx
.open { |f| SimpleXlsxReader.open(f.path) }
end
describe 'sheets' do
it 'should have a sheet for each record type' do
expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis'])
end
end
describe 'Dossiers sheet' do
context 'with multiple columns' do
let(:exported_columns) do
[
ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'Date du dernier évènement')),
ExportedColumn.new(libelle: 'Email', column: procedure.find_column(label: 'Email')),
ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur')),
ExportedColumn.new(libelle: 'État du dossier', column: procedure.dossier_state_column),
ExportedColumn.new(libelle: 'first champ', column: procedure.find_column(label: 'first champ')),
ExportedColumn.new(libelle: 'Commune', column: procedure.find_column(label: 'Commune')),
ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ'))
]
end
let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) }
let(:selected_headers) { ["Email", "first champ", "Commune", "Groupe instructeur", "Date du dernier évènement", "État du dossier", "PJ"] }
it 'should have only headers from export template' do
expect(dossiers_sheet.headers).to match_array(selected_headers)
end
it 'should have data' do
expect(procedure.dossiers.count).to eq 1
expect(dossiers_sheet.data.size).to eq 1
expect(dossiers_sheet.data).to match_array([[anything, dossier.user_email_for_display, "défaut", "En instruction", "text", "Coye-la-Forêt", "toto.txt"]])
end
end
context 'with multiple groupe instructeur' do
let(:exported_columns) { [ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur'))] }
let(:types_de_champ_public) { [] }
before do
create(:groupe_instructeur, label: '2', procedure:)
create(:dossier, :en_instruction, procedure:)
end
it 'find groupe instructeur data' do
expect(dossiers_sheet.headers).to include('Groupe instructeur')
expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut')
end
end
context 'with multiple pjs' do
let(:types_de_champ_public) { [{ type: :piece_justificative, libelle: "PJ" }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ'))] }
before do
dossier = create(:dossier, :en_instruction, :with_populated_champs, procedure:)
dossier.filled_champs_public
.find { _1.is_a? Champs::PieceJustificativeChamp }
.piece_justificative_file
.attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain")
end
it { expect(dossiers_sheet.data.last.last).to eq "toto.txt, toto.txt" }
end
context 'with TypeDeChamp::MutlipleDropDownListTypeDeChamp' do
let(:types_de_champ_public) { [{ type: :multiple_drop_down_list, libelle: "multiple_drop_down_list", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'multiple_drop_down_list'))] }
before { create(:dossier, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to eq "val1, val2" }
end
context 'with TypeDeChamp:YesNoTypeDeChamp' do
let(:types_de_champ_public) { [{ type: :yes_no, libelle: "yes_no", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'yes_no', column: procedure.find_column(label: 'yes_no'))] }
before { create(:dossier, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to eq true }
end
context 'with TypeDeChamp:CheckboxTypeDeChamp' do
let(:types_de_champ_public) { [{ type: :checkbox, libelle: "checkbox", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'checkbox', column: procedure.find_column(label: 'checkbox'))] }
before { create(:dossier, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to eq true }
end
context 'with TypeDeChamp:DecimalNumberTypeDeChamp' do
let(:types_de_champ_public) { [{ type: :decimal_number, libelle: "decimal", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'decimal', column: procedure.find_column(label: 'decimal'))] }
before { create(:dossier, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to eq 42.1 }
end
context 'with TypeDeChamp:IntegerNumberTypeDeChamp' do
let(:types_de_champ_public) { [{ type: :integer_number, libelle: "integer", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'integer', column: procedure.find_column(label: 'integer'))] }
before { create(:dossier, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to eq 42.0 }
end
context 'with TypesDeChamp::LinkedDropDownListTypeDeChamp' do
let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: "linked_drop_down_list", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'linked_drop_down_list', column: procedure.find_column(label: 'linked_drop_down_list'))] }
before { create(:dossier, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to eq "primary / secondary" }
end
context 'with TypesDeChamp::DateTimeTypeDeChamp' do
let(:types_de_champ_public) { [{ type: :datetime, libelle: "datetime", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'datetime', column: procedure.find_column(label: 'datetime'))] }
let(:dossier) { create(:dossier, :with_populated_champs, procedure:) }
before { dossier }
it do
champ_value = Time.zone.parse(dossier.champs.first.value)
offset = champ_value.utc_offset
sheet_value = Time.zone.at(dossiers_sheet.data.last.last - offset.seconds)
expect(sheet_value).to eq(champ_value.round)
end
end
context 'with TypesDeChamp::Date' do
let(:types_de_champ_public) { [{ type: :date, libelle: "date", mandatory: true }] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'date', column: procedure.find_column(label: 'date'))] }
before { create(:dossier, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to be_an_instance_of(Date) }
end
context 'with DossierColumn as datetime' do
let(:types_de_champ_public) { [] }
let(:exported_columns) { [ExportedColumn.new(libelle: 'Date de passage en instruction', column: procedure.find_column(label: 'Date de passage en instruction'))] }
before { create(:dossier, :en_instruction, :with_populated_champs, procedure:) }
it { expect(dossiers_sheet.data.last.last).to be_an_instance_of(Time) }
end
end
describe 'Etablissement sheet' do
let(:types_de_champ_public) { [{ type: :siret, libelle: 'siret', stable_id: 40 }] }
let(:exported_columns) do
[
ExportedColumn.new(libelle: " dossier", column: procedure.find_column(label: " dossier")),
ExportedColumn.new(libelle: "Demandeur", column: procedure.find_column(label: "Demandeur")),
ExportedColumn.new(libelle: "siret", column: procedure.find_column(label: "siret"))
]
end
let(:procedure) { create(:procedure, :published, types_de_champ_public:) }
let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_entreprise, procedure: procedure) }
let(:dossier_etablissement) { etablissements_sheet.data[1] }
let(:champ_etablissement) { etablissements_sheet.data[0] }
it 'should have siret header in dossiers sheet' do
expect(dossiers_sheet.headers).to include('siret')
end
it 'should have headers in etablissement sheet' do
expect(etablissements_sheet.headers).to eq([
"Dossier ID",
"Champ",
"Établissement SIRET",
"Etablissement enseigne",
"Établissement siège social",
"Établissement NAF",
"Établissement libellé NAF",
"Établissement Adresse",
"Établissement numero voie",
"Établissement type voie",
"Établissement nom voie",
"Établissement complément adresse",
"Établissement code postal",
"Établissement localité",
"Établissement code INSEE localité",
"Entreprise SIREN",
"Entreprise capital social",
"Entreprise numero TVA intracommunautaire",
"Entreprise forme juridique",
"Entreprise forme juridique code",
"Entreprise nom commercial",
"Entreprise raison sociale",
"Entreprise SIRET siège social",
"Entreprise code effectif entreprise",
"Entreprise date de création",
"Entreprise état administratif",
"Entreprise nom",
"Entreprise prénom",
"Association RNA",
"Association titre",
"Association objet",
"Association date de création",
"Association date de déclaration",
"Association date de publication"
])
end
end
describe 'Avis sheet' do
let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) }
let!(:avis) { create(:avis, :with_answer, dossier: dossier) }
it 'should have headers and data' do
expect(avis_sheet.headers).to eq([
"Dossier ID",
"Introduction",
"Réponse",
"Question",
"Réponse oui/non",
"Créé le",
"Répondu le",
"Instructeur",
"Expert"
])
expect(avis_sheet.data.size).to eq(1)
end
end
describe 'Repetitions sheet' do
let(:exported_columns) do
[
ExportedColumn.new(libelle: "Champ répétable child second champ", column: procedure.find_column(label: "Champ répétable child second champ"))
]
end
let!(:dossiers) do
[
create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure),
create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure)
]
end
describe 'sheets' do
it 'should have a sheet for repetition' do
expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', '(7) Champ repetable'])
end
end
it 'should have headers' do
expect(repetition_sheet.headers).to eq([
"Dossier ID", "Ligne", "Champ répétable child second champ"
])
end
it 'should have data' do
expect(repetition_sheet.data.size).to eq 4
end
end
end
end

View file

@ -41,7 +41,10 @@ describe 'Creating a new procedure', js: true do
click_on "Télécharger tous les dossiers"
expect {
click_on "Demander un export au format .xlsx"
within(:css, '#tabpanel-standard-panel') do
choose "Fichier xlsx", allow_label_click: true
click_on "Demander l'export"
end
expect(page).to have_content("Nous générons cet export. Veuillez revenir dans quelques minutes pour le télécharger.")
}.to have_enqueued_job(ExportJob).with(an_instance_of(Export))
end

View file

@ -132,13 +132,14 @@ describe 'Instructing a dossier:', js: true do
test_statut_bar(a_suivre: 1, tous_les_dossiers: 1)
click_on "Télécharger un dossier"
within(:css, '.dossiers-export') do
click_on "Demander un export au format .csv"
within(:css, '#tabpanel-standard1-panel') do
choose "Fichier csv", allow_label_click: true
click_on "Demander l'export"
end
expect(page).to have_text('Nous générons cet export.')
click_on "Voir les exports"
click_on "Voir les exports et modèles d'export"
expect(page).to have_text("Export .csv dun dossier « à suivre » demandé il y a moins d'une minute")
expect(page).to have_text("En préparation")

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true
describe "procedure exports" do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:procedure, :published, types_de_champ_public:, instructeurs: [instructeur]) }
let(:types_de_champ_public) { [{ type: :text }] }
before { login_as(instructeur.user, scope: :user) }
scenario "create an export_template tabular and u", js: true do
Flipper.enable(:export_template, procedure)
visit instructeur_procedure_path(procedure)
click_on "Voir les exports et modèles d'export"
click_on "Modèles d'export"
click_on "Créer un modèle d'export tabulaire"
fill_in "Nom du modèle", with: "Mon modèle"
find("#informations-usager-fieldset label", text: "Tout sélectionner").click
within '#informations-usager-fieldset' do
expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy
end
find("#informations-dossier-fieldset label", text: "Tout sélectionner").click
within '#informations-dossier-fieldset' do
expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy
end
click_on "Enregistrer"
find("#tabpanel-export-templates", wait: 5, visible: true)
find("#tabpanel-export-templates").click
within 'table' do
expect(page).to have_content('Mon modèle')
end
# check if all usager colonnes are selected
#
click_on 'Mon modèle'
within '#informations-usager-fieldset' do
expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy
end
within '#informations-dossier-fieldset' do
expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy
end
# uncheck checkboxes
find("#informations-dossier-fieldset label", text: "Tout sélectionner").click
within '#informations-dossier-fieldset' do
expect(all('input[type=checkbox]').none?(&:checked?)).to be_truthy
end
end
end

View file

@ -209,7 +209,7 @@ describe 'The routing with rules', js: true do
## on the dossiers list
click_on procedure.libelle
expect(page).to have_current_path(instructeur_procedure_path(procedure))
expect(find('.fr-tabs')).to have_css('span.notifications')
expect(find('nav.fr-tabs')).to have_css('span.notifications')
## on the dossier itself
click_on 'suivi'

View file

@ -20,7 +20,7 @@ module Maintenance
end
it 'updates value_json' do
expect { subject }.to change { element.reload.value_json }
.from(nil)
.from(anything)
.to({
"street_number" => "33",
"street_name" => "de Modagor",

View file

@ -53,7 +53,7 @@ module Maintenance
it 'updates value_json' do
expect { subject }.to change { element.reload.value_json }
.from(nil)
.from(anything)
.to({
"street_number" => "16",
"street_name" => "Rue du Général de Boissieu",
@ -79,7 +79,7 @@ module Maintenance
it 'updates value_json' do
expect { subject }.to change { element.reload.value_json }
.from(nil)
.from(anything)
.to({
"street_number" => "16",
"street_name" => "Rue du Général de Boissieu",