From b2c3887520d60527c70684ce75c032e9c51017f5 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:19:56 +0200 Subject: [PATCH 01/20] add exported_column with its type Co-authored-by: mfo --- app/models/exported_column.rb | 12 ++++++++++ app/types/exported_column_type.rb | 40 +++++++++++++++++++++++++++++++ config/initializers/types.rb | 2 ++ 3 files changed, 54 insertions(+) create mode 100644 app/models/exported_column.rb create mode 100644 app/types/exported_column_type.rb diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb new file mode 100644 index 000000000..5f754c98f --- /dev/null +++ b/app/models/exported_column.rb @@ -0,0 +1,12 @@ +# 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 +end diff --git a/app/types/exported_column_type.rb b/app/types/exported_column_type.rb new file mode 100644 index 000000000..dddd35532 --- /dev/null +++ b/app/types/exported_column_type.rb @@ -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 diff --git a/config/initializers/types.rb b/config/initializers/types.rb index 46f40f05d..ecdf990d7 100644 --- a/config/initializers/types.rb +++ b/config/initializers/types.rb @@ -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 From 94b3655ff71a694901ff7c4830ec6db029ac53a1 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:21:45 +0200 Subject: [PATCH 02/20] add exported_columns to export template model Co-authored-by: mfo --- app/models/export_template.rb | 2 ++ .../20241015125024_add_columns_to_export_template.rb | 7 +++++++ db/schema.rb | 1 + 3 files changed, 10 insertions(+) create mode 100644 db/migrate/20241015125024_add_columns_to_export_template.rb diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 1a6f070d7..0e6e503e4 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -15,6 +15,8 @@ class ExportTemplate < ApplicationRecord 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 diff --git a/db/migrate/20241015125024_add_columns_to_export_template.rb b/db/migrate/20241015125024_add_columns_to_export_template.rb new file mode 100644 index 000000000..2e9e01dc8 --- /dev/null +++ b/db/migrate/20241015125024_add_columns_to_export_template.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index ca309c9b4..8da54cc84 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 From 16b89951911d438d6cc75b9a032771fc4db0bb7e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:09:18 +0200 Subject: [PATCH 03/20] extend procedure.all_revision_types_de_champ for header section Co-authored-by: mfo --- app/models/procedure.rb | 15 ++++++------ app/models/type_de_champ.rb | 1 + spec/models/procedure_spec.rb | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 05c3e3346..90344edc9 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -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)') diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 9a4b32d99..729f23955 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -171,6 +171,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) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index b8724a106..50db50eb1 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -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) From f383e1c502287a3c704495daa539a790d568b26b Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:36:38 +0200 Subject: [PATCH 04/20] prepare export template to be used for tabular template Co-authored-by: mfo --- app/models/column.rb | 3 + app/models/columns/champ_column.rb | 2 + app/models/columns/dossier_column.rb | 2 + app/models/export_template.rb | 20 ++++- app/types/export_item_type.rb | 5 +- .../instructeurs/procedures/exports.html.haml | 84 +++++++++++-------- .../locales/views/instructeurs/header/en.yml | 2 +- .../locales/views/instructeurs/header/fr.yml | 2 +- .../instructeurs/procedures/exports/en.yml | 6 ++ .../instructeurs/procedures/exports/fr.yml | 5 ++ spec/system/instructeurs/instruction_spec.rb | 2 +- 11 files changed, 95 insertions(+), 38 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 22fa01a6f..5131c77c6 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -47,6 +47,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}" diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index c032b3a35..3cdb05a92 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -45,6 +45,8 @@ class Columns::ChampColumn < Column end end + def champ_column? = true + private def column_id = "type_de_champ/#{stable_id}" diff --git a/app/models/columns/dossier_column.rb b/app/models/columns/dossier_column.rb index 72ec97405..82730b2a9 100644 --- a/app/models/columns/dossier_column.rb +++ b/app/models/columns/dossier_column.rb @@ -15,4 +15,6 @@ class Columns::DossierColumn < Column dossier.followers_instructeurs.map(&:email).join(' ') end end + + def dossier_column? = true end diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 0e6e503e4..0328e16b4 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -9,7 +9,7 @@ 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 @@ -30,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) } @@ -37,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 @@ -60,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 diff --git a/app/types/export_item_type.rb b/app/types/export_item_type.rb index e2d1f7014..04c37c6ef 100644 --- a/app/types/export_item_type.rb +++ b/app/types/export_item_type.rb @@ -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, diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index e63eb4472..3e92cd894 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -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" diff --git a/config/locales/views/instructeurs/header/en.yml b/config/locales/views/instructeurs/header/en.yml index dbec9594d..ad343adb4 100644 --- a/config/locales/views/instructeurs/header/en.yml +++ b/config/locales/views/instructeurs/header/en.yml @@ -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 diff --git a/config/locales/views/instructeurs/header/fr.yml b/config/locales/views/instructeurs/header/fr.yml index c13a4ded0..46b2d77c1 100644 --- a/config/locales/views/instructeurs/header/fr.yml +++ b/config/locales/views/instructeurs/header/fr.yml @@ -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 diff --git a/config/locales/views/instructeurs/procedures/exports/en.yml b/config/locales/views/instructeurs/procedures/exports/en.yml index 81b0dcf8e..f1f7bc753 100644 --- a/config/locales/views/instructeurs/procedures/exports/en.yml +++ b/config/locales/views/instructeurs/procedures/exports/en.yml @@ -18,3 +18,9 @@ en: no_export_html: You have no export at the moment.
Can't find an export? It may have expired, exports are deleted after %{expiration_time} hours. + + export_template_list_description_html: | + Each instructor can configure an export template to customize exports (attachments name for a zip export, columns selection for a tabular export). It will be made available to all instructors assigned to the procedure.
+ Find out more about export template configuration + new_zip_export_template: Create zip export template + new_tabular_export_template: Create tabular export template diff --git a/config/locales/views/instructeurs/procedures/exports/fr.yml b/config/locales/views/instructeurs/procedures/exports/fr.yml index 030fa8345..fa0945652 100644 --- a/config/locales/views/instructeurs/procedures/exports/fr.yml +++ b/config/locales/views/instructeurs/procedures/exports/fr.yml @@ -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.
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 configurer un modèle 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 mis à disposition de l'ensemble des instructeurs affectés à la démarche
+ En savoir plus sur la configuration des modèles d'export + new_zip_export_template: Créer un modèle d'export zip + new_tabular_export_template: Créer un modèle d'export tabulaire diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 098878767..2473c352e 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -138,7 +138,7 @@ describe 'Instructing a dossier:', js: true do 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 d’un dossier « à suivre » demandé il y a moins d'une minute") expect(page).to have_text("En préparation") From ffd1a15d91af6a3106ffcb9209000dd486049f9a Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:50:51 +0200 Subject: [PATCH 05/20] add, edit and destroy export template with exported_columns Co-authored-by: mfo Co-authored-by: LeSim --- app/assets/stylesheets/forms.scss | 19 +++++ .../export_template/champs_component.rb | 45 ++++++++++ .../champs_component.html.haml | 29 +++++++ .../export_templates_controller.rb | 27 +++++- app/helpers/export_template_helper.rb | 9 ++ .../checkbox_select_all_controller.ts | 71 ++++++++++++++++ .../repetition_type_de_champ.rb | 4 +- .../administrateurs/archives/index.html.haml | 7 +- .../_checkbox_group.html.haml | 16 ++++ .../export_templates/_form_tabular.html.haml | 57 +++++++++++++ .../export_templates/edit.html.haml | 5 +- .../export_templates/new.html.haml | 5 +- config/locales/models/export_templates/en.yml | 9 ++ config/locales/models/export_templates/fr.yml | 10 +++ .../export_template/champs_component_spec.rb | 26 ++++++ .../export_templates_controller_spec.rb | 65 ++++++++++++++ spec/models/export_template_tabular_spec.rb | 85 +++++++++++++++++++ .../procedure_export_tabular_spec.rb | 58 +++++++++++++ .../routing/rules_full_scenario_spec.rb | 2 +- 19 files changed, 539 insertions(+), 10 deletions(-) create mode 100644 app/components/export_template/champs_component.rb create mode 100644 app/components/export_template/champs_component/champs_component.html.haml create mode 100644 app/helpers/export_template_helper.rb create mode 100644 app/javascript/controllers/checkbox_select_all_controller.ts create mode 100644 app/views/instructeurs/export_templates/_checkbox_group.html.haml create mode 100644 app/views/instructeurs/export_templates/_form_tabular.html.haml create mode 100644 spec/components/export_template/champs_component_spec.rb create mode 100644 spec/models/export_template_tabular_spec.rb create mode 100644 spec/system/instructeurs/procedure_export_tabular_spec.rb diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index ce83a13bb..6073283bc 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -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); +} diff --git a/app/components/export_template/champs_component.rb b/app/components/export_template/champs_component.rb new file mode 100644 index 000000000..e12b2d5d9 --- /dev/null +++ b/app/components/export_template/champs_component.rb @@ -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 diff --git a/app/components/export_template/champs_component/champs_component.html.haml b/app/components/export_template/champs_component/champs_component.html.haml new file mode 100644 index 000000000..4ccbe41de --- /dev/null +++ b/app/components/export_template/champs_component/champs_component.html.haml @@ -0,0 +1,29 @@ +%fieldset.fr-fieldset{ id: "#{component_prefix}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = 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 + - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) + = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } + = label_tag id, historical_libelle(exported_column.column) + - else + - grouped_columns.each do |exported_column| + .fr-fieldset__element.fr-px-4w + .fr-checkbox-group + - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) + = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } + = label_tag id, historical_libelle(exported_column.column) diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb index 49d2e3db8..453673b42 100644 --- a/app/controllers/instructeurs/export_templates_controller.rb +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -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 diff --git a/app/helpers/export_template_helper.rb b/app/helpers/export_template_helper.rb new file mode 100644 index 000000000..518de7521 --- /dev/null +++ b/app/helpers/export_template_helper.rb @@ -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 diff --git a/app/javascript/controllers/checkbox_select_all_controller.ts b/app/javascript/controllers/checkbox_select_all_controller.ts new file mode 100644 index 000000000..86fbe44d4 --- /dev/null +++ b/app/javascript/controllers/checkbox_select_all_controller.ts @@ -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); + } +} diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index 043294933..7470162fd 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -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? diff --git a/app/views/administrateurs/archives/index.html.haml b/app/views/administrateurs/archives/index.html.haml index b50059d6c..86cc6465e 100644 --- a/app/views/administrateurs/archives/index.html.haml +++ b/app/views/administrateurs/archives/index.html.haml @@ -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" diff --git a/app/views/instructeurs/export_templates/_checkbox_group.html.haml b/app/views/instructeurs/export_templates/_checkbox_group.html.haml new file mode 100644 index 000000000..f6cfd0c23 --- /dev/null +++ b/app/views/instructeurs/export_templates/_checkbox_group.html.haml @@ -0,0 +1,16 @@ +%fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = 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 + - id = sanitize_to_id(field_id('export_template', 'exported_columns', { id: column.id, libelle: column.label, parent: nil }.to_json)) + = check_box_tag field_name('export_template', 'exported_columns', ''), { id: column.id, libelle: column.label, parent: nil }.to_json, checked_columns.map(&:column).include?(column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } + = label_tag id, column.label diff --git a/app/views/instructeurs/export_templates/_form_tabular.html.haml b/app/views/instructeurs/export_templates/_form_tabular.html.haml new file mode 100644 index 000000000..0da716e9e --- /dev/null +++ b/app/views/instructeurs/export_templates/_form_tabular.html.haml @@ -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 + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + = f.radio_button :kind, "ods", id: "ods" + %label.fr-label{ for: "ods" } ods + .fr-radio-group + = f.radio_button :kind, "xlsx", id: "xlsx" + %label.fr-label{ for: "xlsx" } xlsx + .fr-radio-group + = f.radio_button :kind, "csv", id: "csv" + %label.fr-label{ for: "csv" } csv + + %h2 Contenu de l'export + = render partial: 'checkbox_group', locals: { title: 'Colonnes Usager', all_columns: @export_template.procedure.usager_columns_for_export, checked_columns: @export_template.exported_columns } + = render partial: 'checkbox_group', locals: { title: 'Colonnes Infos dossier', all_columns: @export_template.procedure.dossier_columns_for_export, checked_columns: @export_template.exported_columns } + = render ExportTemplate::ChampsComponent.new("Informations formulaire", @export_template, @types_de_champ_public) + = render ExportTemplate::ChampsComponent.new("Informations annotations", @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 + = f.submit "Enregistrer", class: "fr-btn" + %li + = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" + - if @export_template.persisted? + %li + = 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" diff --git a/app/views/instructeurs/export_templates/edit.html.haml b/app/views/instructeurs/export_templates/edit.html.haml index ab4c5f8c7..32a80d8d6 100644 --- a/app/views/instructeurs/export_templates/edit.html.haml +++ b/app/views/instructeurs/export_templates/edit.html.haml @@ -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 } diff --git a/app/views/instructeurs/export_templates/new.html.haml b/app/views/instructeurs/export_templates/new.html.haml index 10962b1cd..358bab190 100644 --- a/app/views/instructeurs/export_templates/new.html.haml +++ b/app/views/instructeurs/export_templates/new.html.haml @@ -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 } diff --git a/config/locales/models/export_templates/en.yml b/config/locales/models/export_templates/en.yml index 1952e0bc0..bcc3ba867 100644 --- a/config/locales/models/export_templates/en.yml +++ b/config/locales/models/export_templates/en.yml @@ -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. diff --git a/config/locales/models/export_templates/fr.yml b/config/locales/models/export_templates/fr.yml index 9d152bbc5..f2100fe33 100644 --- a/config/locales/models/export_templates/fr.yml +++ b/config/locales/models/export_templates/fr.yml @@ -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. diff --git a/spec/components/export_template/champs_component_spec.rb b/spec/components/export_template/champs_component_spec.rb new file mode 100644 index 000000000..23baff354 --- /dev/null +++ b/spec/components/export_template/champs_component_spec.rb @@ -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 diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb index 4576090fc..32fba6ba8 100644 --- a/spec/controllers/instructeurs/export_templates_controller_spec.rb +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -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 diff --git a/spec/models/export_template_tabular_spec.rb b/spec/models/export_template_tabular_spec.rb new file mode 100644 index 000000000..56518ae1e --- /dev/null +++ b/spec/models/export_template_tabular_spec.rb @@ -0,0 +1,85 @@ +# 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 +end diff --git a/spec/system/instructeurs/procedure_export_tabular_spec.rb b/spec/system/instructeurs/procedure_export_tabular_spec.rb new file mode 100644 index 000000000..326bdbc30 --- /dev/null +++ b/spec/system/instructeurs/procedure_export_tabular_spec.rb @@ -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 diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index b38bbba68..56e1c7942 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -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' From 6d074abc3fb2ac9228f3c76d2f47734e9721a48e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:58:12 +0200 Subject: [PATCH 06/20] no validation if tabular export template Co-authored-by: mfo --- app/validators/export_template_validator.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/validators/export_template_validator.rb b/app/validators/export_template_validator.rb index 51dc51141..51099b714 100644 --- a/app/validators/export_template_validator.rb +++ b/app/validators/export_template_validator.rb @@ -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 From 10706a271201cbc762c00d1811707644f734370d Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:00:27 +0200 Subject: [PATCH 07/20] add dropdown component for traditional or with export_template export Co-authored-by: mfo --- app/assets/stylesheets/instructeur.scss | 6 +- .../dossiers/export_dropdown_component.rb | 3 +- .../export_dropdown_component.html.haml | 86 ++++++++++++++----- .../controllers/menu_button_controller.ts | 12 +-- spec/system/instructeurs/instruction_spec.rb | 5 +- 5 files changed, 80 insertions(+), 32 deletions(-) diff --git a/app/assets/stylesheets/instructeur.scss b/app/assets/stylesheets/instructeur.scss index b42345d6a..1b8105aed 100644 --- a/app/assets/stylesheets/instructeur.scss +++ b/app/assets/stylesheets/instructeur.scss @@ -44,8 +44,12 @@ position: relative; } -.dropdown-export .dropdown-content { +.dropdown-export.dropdown-content { width: 450px; + + a { + text-decoration: underline; + } } .dropdown-label.dropdown-content { diff --git a/app/components/dossiers/export_dropdown_component.rb b/app/components/dossiers/export_dropdown_component.rb index c970576e3..9710c3704 100644 --- a/app/components/dossiers/export_dropdown_component.rb +++ b/app/components/dossiers/export_dropdown_component.rb @@ -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 diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index 441149066..e9c41e5be 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -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' diff --git a/app/javascript/controllers/menu_button_controller.ts b/app/javascript/controllers/menu_button_controller.ts index a5edf9b53..b89b9025c 100644 --- a/app/javascript/controllers/menu_button_controller.ts +++ b/app/javascript/controllers/menu_button_controller.ts @@ -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 && diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 2473c352e..bfd9447d2 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -132,8 +132,9 @@ 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.') From 15cea714c160dc472f3db0a28e0ef8da4850f28e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:29:03 +0200 Subject: [PATCH 08/20] extract methods from dossier to dossier_export_concern Co-authored-by: mfo Co-authored-by: Paul Chavard --- app/models/champs/repetition_champ.rb | 4 +- app/models/concerns/dossier_champs_concern.rb | 9 -- app/models/concerns/dossier_export_concern.rb | 124 ++++++++++++++++++ app/models/dossier.rb | 95 +------------- app/models/type_de_champ.rb | 1 - .../concerns/dossier_champs_concern_spec.rb | 4 +- spec/models/dossier_spec.rb | 18 +-- .../commune_type_de_champ_spec.rb | 5 +- 8 files changed, 140 insertions(+), 120 deletions(-) create mode 100644 app/models/concerns/dossier_export_concern.rb diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index e94bbf518..f8e6dfe46 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -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) [ ['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:) end end end diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 570b744d8..f6e9baf0a 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -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) diff --git a/app/models/concerns/dossier_export_concern.rb b/app/models/concerns/dossier_export_concern.rb new file mode 100644 index 000000000..bb5449816 --- /dev/null +++ b/app/models/concerns/dossier_export_concern.rb @@ -0,0 +1,124 @@ +# 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:) + end + + def spreadsheet_columns_xlsx(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:) + end + + def spreadsheet_columns_ods(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:) + end + + def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil) + types_de_champ.flat_map do |type_de_champ| + champ = filled_champ(type_de_champ, row_id) + if export_template.present? + columns = export_template.columns_for_stable_id(type_de_champ.stable_id) + columns.map { [_1.libelle, type_de_champ.champ_value_for_export(champ, _1.column)] } + 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) + dossier_values_for_export(with_etablissement:, export_template:) + champ_values_for_export(types_de_champ, export_template:) + end + + private + + def dossier_values_for_export(with_etablissement: false, export_template: nil) + if export_template.present? + return export_template.dossier_exported_columns.map { [_1.libelle, _1.column.get_value(self)] } + 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 diff --git a/app/models/dossier.rb b/app/models/dossier.rb index d4586e55d..3d4b9ab9b 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -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) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 729f23955..0403ac864 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -24,7 +24,6 @@ class TypeDeChamp < ApplicationRecord TYPE_DE_CHAMP_TO_CATEGORIE = { engagement_juridique: REFERENTIEL_EXTERNE, - header_section: STRUCTURE, repetition: STRUCTURE, dossier_link: STRUCTURE, diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index 6b864f28a..1cafd67f7 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -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) } it { expect(subject.size).to eq(4) } it { expect(subject.first).to eq(["Un champ text", nil]) } diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 320fe595e..76e2160dd 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -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) } .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) } + let(:dossier_second_revision_champ_values_for_export) { dossier_second_revision.champ_values_for_export(procedure.types_de_champ_for_procedure_export) } 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).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) } before do text_tdc.update(condition: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) diff --git a/spec/models/types_de_champ/commune_type_de_champ_spec.rb b/spec/models/types_de_champ/commune_type_de_champ_spec.rb index 18db0b2b5..ccc66bacb 100644 --- a/spec/models/types_de_champ/commune_type_de_champ_spec.rb +++ b/spec/models/types_de_champ/commune_type_de_champ_spec.rb @@ -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 From 9530099d2362fc1f4de29c561b65fbc0f967a53e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:32:33 +0200 Subject: [PATCH 09/20] improve factories Co-authored-by: mfo --- spec/factories/champ.rb | 5 +++++ spec/tasks/maintenance/populate_rna_json_value_task_spec.rb | 2 +- spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index 2c8796138..943fc5355 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -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 diff --git a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb index bdb3fd559..df727227c 100644 --- a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb @@ -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", diff --git a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb index a71eb25ad..f74ed99a1 100644 --- a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb @@ -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", From f01400654250d2dfa33693e73e5d6fd2b6099cc1 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 16:43:39 +0100 Subject: [PATCH 10/20] dry(export_template_form): extract checkbox component --- .../champs_component.html.haml | 12 +--- .../export_template/checkbox_component.rb | 32 ++++++++++ .../_checkbox_group.html.haml | 6 +- .../export_templates/_form_tabular.html.haml | 24 ++++---- spec/models/export_template_tabular_spec.rb | 59 +++++++++++++++++++ 5 files changed, 108 insertions(+), 25 deletions(-) create mode 100644 app/components/export_template/checkbox_component.rb diff --git a/app/components/export_template/champs_component/champs_component.html.haml b/app/components/export_template/champs_component/champs_component.html.haml index 4ccbe41de..ea6f603cc 100644 --- a/app/components/export_template/champs_component/champs_component.html.haml +++ b/app/components/export_template/champs_component/champs_component.html.haml @@ -1,5 +1,5 @@ %fieldset.fr-fieldset{ id: "#{component_prefix}-fieldset", data: { controller: 'checkbox-select-all' } } - %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %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 @@ -16,14 +16,8 @@ .fieldset-bordered.fr-ml-3v - grouped_columns.each do |exported_column| .fr-fieldset__element.fr-px-3v - .fr-checkbox-group - - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) - = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } - = label_tag id, historical_libelle(exported_column.column) + .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 - - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) - = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } - = label_tag id, historical_libelle(exported_column.column) + .fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:) diff --git a/app/components/export_template/checkbox_component.rb b/app/components/export_template/checkbox_component.rb new file mode 100644 index 000000000..c5617e71d --- /dev/null +++ b/app/components/export_template/checkbox_component.rb @@ -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 diff --git a/app/views/instructeurs/export_templates/_checkbox_group.html.haml b/app/views/instructeurs/export_templates/_checkbox_group.html.haml index f6cfd0c23..80626d857 100644 --- a/app/views/instructeurs/export_templates/_checkbox_group.html.haml +++ b/app/views/instructeurs/export_templates/_checkbox_group.html.haml @@ -1,5 +1,5 @@ %fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } } - %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0 = title .checkbox-group-bordered.fr-mx-1w.fr-mb-2w @@ -11,6 +11,4 @@ - all_columns.each do |column| .fr-fieldset__element.fr-px-4w .fr-checkbox-group - - id = sanitize_to_id(field_id('export_template', 'exported_columns', { id: column.id, libelle: column.label, parent: nil }.to_json)) - = check_box_tag field_name('export_template', 'exported_columns', ''), { id: column.id, libelle: column.label, parent: nil }.to_json, checked_columns.map(&:column).include?(column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } - = label_tag id, column.label + = render ExportTemplate::CheckboxComponent.new(export_template:, exported_column: ExportedColumn.new(libelle: column.label, column:)) diff --git a/app/views/instructeurs/export_templates/_form_tabular.html.haml b/app/views/instructeurs/export_templates/_form_tabular.html.haml index 0da716e9e..36712f6f0 100644 --- a/app/views/instructeurs/export_templates/_form_tabular.html.haml +++ b/app/views/instructeurs/export_templates/_form_tabular.html.haml @@ -28,30 +28,30 @@ %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, "ods", id: "ods" - %label.fr-label{ for: "ods" } ods .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 - = render partial: 'checkbox_group', locals: { title: 'Colonnes Usager', all_columns: @export_template.procedure.usager_columns_for_export, checked_columns: @export_template.exported_columns } - = render partial: 'checkbox_group', locals: { title: 'Colonnes Infos dossier', all_columns: @export_template.procedure.dossier_columns_for_export, checked_columns: @export_template.exported_columns } - = render ExportTemplate::ChampsComponent.new("Informations formulaire", @export_template, @types_de_champ_public) - = render ExportTemplate::ChampsComponent.new("Informations annotations", @export_template, @types_de_champ_private) if @types_de_champ_private.any? + %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 - = f.submit "Enregistrer", class: "fr-btn" %li = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" - - if @export_template.persisted? - %li - = 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" + %li + = f.submit "Enregistrer", class: "fr-btn", data: @export_template.persisted? ? { confirm: t('.warning') } : {} diff --git a/spec/models/export_template_tabular_spec.rb b/spec/models/export_template_tabular_spec.rb index 56518ae1e..4751ffdd2 100644 --- a/spec/models/export_template_tabular_spec.rb +++ b/spec/models/export_template_tabular_spec.rb @@ -82,4 +82,63 @@ describe ExportTemplate do 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 From c209cac62f8aad175abdb444bd9d8fa33f0cd889 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 17:00:25 +0100 Subject: [PATCH 11/20] feat(export_template): use in export service --- app/models/concerns/dossier_export_concern.rb | 7 +- app/models/exported_column.rb | 4 + app/services/procedure_export_service.rb | 4 +- spec/models/columns/dossier_column_spec.rb | 63 ++++++ .../procedure_export_service_tabular_spec.rb | 206 ++++++++++++++++++ .../procedure_archive_and_export_spec.rb | 5 +- 6 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 spec/services/procedure_export_service_tabular_spec.rb diff --git a/app/models/concerns/dossier_export_concern.rb b/app/models/concerns/dossier_export_concern.rb index bb5449816..fe2f5bdc6 100644 --- a/app/models/concerns/dossier_export_concern.rb +++ b/app/models/concerns/dossier_export_concern.rb @@ -19,8 +19,9 @@ module DossierExportConcern types_de_champ.flat_map do |type_de_champ| champ = filled_champ(type_de_champ, row_id) if export_template.present? - columns = export_template.columns_for_stable_id(type_de_champ.stable_id) - columns.map { [_1.libelle, type_de_champ.champ_value_for_export(champ, _1.column)] } + export_template + .columns_for_stable_id(type_de_champ.stable_id) + .map { |exported_column| exported_column.libelle_with_value(champ) } else type_de_champ.libelles_for_export.map do |(libelle, path)| [libelle, type_de_champ.champ_value_for_export(champ, path)] @@ -37,7 +38,7 @@ module DossierExportConcern def dossier_values_for_export(with_etablissement: false, export_template: nil) if export_template.present? - return export_template.dossier_exported_columns.map { [_1.libelle, _1.column.get_value(self)] } + return export_template.dossier_exported_columns.map { _1.libelle_with_value(self) } end columns = [ diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index 5f754c98f..23c091c98 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -9,4 +9,8 @@ class ExportedColumn end def id = { id: column.id, libelle: }.to_json + + def libelle_with_value(champ_or_dossier) + [libelle, column.value(champ_or_dossier)] + end end diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 9482cc045..03b3281de 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -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) } } 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 diff --git a/spec/models/columns/dossier_column_spec.rb b/spec/models/columns/dossier_column_spec.rb index 5b8651d2a..6574732c0 100644 --- a/spec/models/columns/dossier_column_spec.rb +++ b/spec/models/columns/dossier_column_spec.rb @@ -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: "Nº 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 diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb new file mode 100644 index 000000000..6d994d22b --- /dev/null +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -0,0 +1,206 @@ +# 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(:export_template) { create(:export_template, kind:, exported_columns:, groupe_instructeur: procedure.defaut_groupe_instructeur) } + 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 + subject do + service + .to_xlsx + .open { |f| SimpleXlsxReader.open(f.path) } + end + + let(:kind) { 'xlsx' } + let(:dossiers_sheet) { subject.sheets.first } + let(:etablissements_sheet) { subject.sheets.second } + let(:avis_sheet) { subject.sheets.third } + let(:repetition_sheet) { subject.sheets.fourth } + + 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 + 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 (Code INSEE)', column: procedure.find_column(label: 'Commune (Code INSEE)')), + 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 (Code INSEE)", "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", "60172", "toto.txt"]]) + end + + context 'with a procedure routee' do + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } + before { create(:groupe_instructeur, label: '2', procedure:) } + + 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 a dossier having multiple pjs' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + before do + dossier_2.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 + end + + describe 'Etablissement sheet' do + let(:types_de_champ_public) { [{ type: :siret, libelle: 'siret', stable_id: 40 }] } + let(:exported_columns) do + [ + ExportedColumn.new(libelle: "Nº dossier", column: procedure.find_column(label: "Nº 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 diff --git a/spec/system/administrateurs/procedure_archive_and_export_spec.rb b/spec/system/administrateurs/procedure_archive_and_export_spec.rb index 3b1b85c9c..eb868f788 100644 --- a/spec/system/administrateurs/procedure_archive_and_export_spec.rb +++ b/spec/system/administrateurs/procedure_archive_and_export_spec.rb @@ -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 From ed3b1bc04666c3bfd38f14cde4b61f13fe0f6c7a Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 17:01:00 +0100 Subject: [PATCH 12/20] feat(pj): add specialized column for PieceJustifiativeChamp --- app/models/columns/piece_justificative_column.rb | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/models/columns/piece_justificative_column.rb diff --git a/app/models/columns/piece_justificative_column.rb b/app/models/columns/piece_justificative_column.rb new file mode 100644 index 000000000..6b29ae35b --- /dev/null +++ b/app/models/columns/piece_justificative_column.rb @@ -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 From 3b5acaad4c1833173d47a730e0bc629c9d8e3f62 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 17:01:36 +0100 Subject: [PATCH 13/20] feat(ExportedColumn): use dedicated formatter for exported column --- app/models/columns/champ_column.rb | 2 +- app/models/exported_column.rb | 2 +- app/services/exported_column_formatter.rb | 42 +++++++++++ .../procedure_export_service_tabular_spec.rb | 74 +++++++++++-------- 4 files changed, 88 insertions(+), 32 deletions(-) create mode 100644 app/services/exported_column_formatter.rb diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 3cdb05a92..2d3859774 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -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 diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index 23c091c98..a958050a5 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -11,6 +11,6 @@ class ExportedColumn def id = { id: column.id, libelle: }.to_json def libelle_with_value(champ_or_dossier) - [libelle, column.value(champ_or_dossier)] + [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:)] end end diff --git a/app/services/exported_column_formatter.rb b/app/services/exported_column_formatter.rb new file mode 100644 index 000000000..d9793072d --- /dev/null +++ b/app/services/exported_column_formatter.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ExportedColumnFormatter + def self.format(column:, champ_or_dossier:) + return if champ_or_dossier.nil? + + raw_value = column.value(champ_or_dossier) + + case column.type + 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_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 diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index 6d994d22b..6981b47db 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -44,35 +44,42 @@ describe ProcedureExportService do end describe 'Dossiers sheet' 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 (Code INSEE)', column: procedure.find_column(label: 'Commune (Code INSEE)')), - ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ')) - ] + context '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 (Code INSEE)', column: procedure.find_column(label: 'Commune (Code INSEE)')), + 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 (Code INSEE)", "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", "60172", "toto.txt"]]) + end end - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } - let(:selected_headers) { ["Email", "first champ", "Commune (Code INSEE)", "Groupe instructeur", "Date du dernier évènement", "État du dossier", "PJ"] } + context 'with a procedure having multiple groupe instructeur' do + let(:exported_columns) { [ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur'))] } + let(:types_de_champ_public) { [] } - 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", "60172", "toto.txt"]]) - end - - context 'with a procedure routee' do - let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } - before { create(:groupe_instructeur, label: '2', procedure:) } + 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') @@ -81,17 +88,24 @@ describe ProcedureExportService do end context 'with a dossier having multiple pjs' do - let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } - let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + 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_2.filled_champs_public + 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 a dossier TypeDeChamp::MutlipleDropDownList' 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 end describe 'Etablissement sheet' do From 8792cb3cfd4bc35f74bd3d6526b50f2adb45dbcf Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 12 Nov 2024 17:03:31 +0100 Subject: [PATCH 14/20] feat(type_de_champ_commune): add custom columns for iso export feat(export.type): ensure right cast type on export Co-Authored-By: LeSim Date: Tue, 12 Nov 2024 17:34:22 +0100 Subject: [PATCH 15/20] feat(export.type): ensure right cast type on export. ods expecting boolean value a 0||1 --- app/models/concerns/columns_concern.rb | 7 +++--- app/models/concerns/dossier_export_concern.rb | 18 +++++++-------- app/models/exported_column.rb | 22 +++++++++++++++++-- app/services/exported_column_formatter.rb | 12 +++++++++- .../concerns/dossier_champs_concern_spec.rb | 2 +- spec/models/dossier_spec.rb | 10 ++++----- 6 files changed, 50 insertions(+), 21 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 1cf268744..c52b52353 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -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] }) @@ -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 diff --git a/app/models/concerns/dossier_export_concern.rb b/app/models/concerns/dossier_export_concern.rb index fe2f5bdc6..887b48207 100644 --- a/app/models/concerns/dossier_export_concern.rb +++ b/app/models/concerns/dossier_export_concern.rb @@ -4,24 +4,24 @@ 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:) + 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:) + 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:) + spreadsheet_columns(types_de_champ:, export_template:, format: :ods) end - def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil) + 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) } + .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)] @@ -30,15 +30,15 @@ module DossierExportConcern end end - def spreadsheet_columns(types_de_champ:, with_etablissement: false, export_template: nil) - dossier_values_for_export(with_etablissement:, export_template:) + champ_values_for_export(types_de_champ, export_template:) + 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) + 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) } + return export_template.dossier_exported_columns.map { _1.libelle_with_value(self, format:) } end columns = [ diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index a958050a5..dfedf20b3 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -10,7 +10,25 @@ class ExportedColumn def id = { id: column.id, libelle: }.to_json - def libelle_with_value(champ_or_dossier) - [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:)] + def libelle_with_value(champ_or_dossier, format:) + [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:, format:), spreadsheet_architect_type] + end + + # see: https://github.com/westonganger/spreadsheet_architect/blob/771e2e5558fbf6e0cb830e881a7214fa710e49c3/lib/spreadsheet_architect.rb#L39 + def spreadsheet_architect_type + case @column.type + when :boolean + :boolean + when :decimal + :float + when :number + :integer + when :datetime + :time + when :date + :date + else + :string + end end end diff --git a/app/services/exported_column_formatter.rb b/app/services/exported_column_formatter.rb index d9793072d..824b26d29 100644 --- a/app/services/exported_column_formatter.rb +++ b/app/services/exported_column_formatter.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class ExportedColumnFormatter - def self.format(column:, champ_or_dossier:) + 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 @@ -20,6 +22,14 @@ class ExportedColumnFormatter 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] diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index 1cafd67f7..1b8a920e8 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -184,7 +184,7 @@ RSpec.describe DossierChampsConcern do end describe "#champ_values_for_export" do - subject { dossier.champ_values_for_export(dossier.revision.types_de_champ_public) } + 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]) } diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 76e2160dd..cba6e5188 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -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.champ_values_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_champ_values_for_export) { dossier.champ_values_for_export(procedure.types_de_champ_for_procedure_export) } - let(:dossier_second_revision_champ_values_for_export) { dossier_second_revision.champ_values_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 @@ -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.champ_values_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 @@ -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.champ_values_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))) From db053d36c9e115fcfd09f3496a4cdcfb5213f7f2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 12 Nov 2024 18:15:40 +0100 Subject: [PATCH 16/20] fix: give format to repetition --- app/models/champs/repetition_champ.rb | 4 ++-- app/services/procedure_export_service.rb | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index f8e6dfe46..0e9e8ef64 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -40,11 +40,11 @@ class Champs::RepetitionChamp < Champ self[attribute] end - def spreadsheet_columns(types_de_champ, export_template: nil) + def spreadsheet_columns(types_de_champ, export_template: nil, format:) [ ['Dossier ID', :dossier_id], ['Ligne', :index] - ] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:) + ] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:, format:) end end end diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 03b3281de..181352d5c 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -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, export_template: @export_template) } + spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ, export_template: @export_template, format:) } } end end From 1fccf0fd184e5312760a1d82816a63a3689d9b94 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 09:12:25 +0100 Subject: [PATCH 17/20] feat(export.numbers): cast to float, otherwise it is implicitly casted as string --- app/models/exported_column.rb | 5 +-- .../procedure_export_service_tabular_spec.rb | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index dfedf20b3..651407a71 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -14,15 +14,12 @@ class ExportedColumn [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:, format:), spreadsheet_architect_type] end - # see: https://github.com/westonganger/spreadsheet_architect/blob/771e2e5558fbf6e0cb830e881a7214fa710e49c3/lib/spreadsheet_architect.rb#L39 def spreadsheet_architect_type case @column.type when :boolean :boolean - when :decimal + when :decimal, :integer :float - when :number - :integer when :datetime :time when :date diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index b2eb2367d..0a20341f7 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -6,7 +6,6 @@ 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(:export_template) { create(:export_template, kind:, exported_columns:, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:for_individual) { true } let(:types_de_champ_public) do [ @@ -25,18 +24,19 @@ describe ProcedureExportService do 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 - let(:kind) { 'xlsx' } - let(:dossiers_sheet) { subject.sheets.first } - let(:etablissements_sheet) { subject.sheets.second } - let(:avis_sheet) { subject.sheets.third } - let(:repetition_sheet) { subject.sheets.fourth } - describe 'sheets' do it 'should have a sheet for each record type' do expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis']) @@ -106,6 +106,33 @@ describe ProcedureExportService do before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq "val1, val2" } end + + context 'with a dossier TypeDeChamp:YesNo' 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 a dossier TypeDeChamp:Checkbox' 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 a dossier TypeDeChamp:DecimalNumber' 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 a dossier TypeDeChamp:IntegerNumber' 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 end describe 'Etablissement sheet' do From 6cdd8013266ba2824d213ef4c5d51e9ac87542c4 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 09:55:10 +0100 Subject: [PATCH 18/20] feat(export.linked_drop_down_list): render compound value --- app/models/columns/linked_drop_down_column.rb | 4 ++-- spec/models/columns/champ_column_spec.rb | 2 +- .../procedure_export_service_tabular_spec.rb | 24 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 1d25d3eea..6399137b0 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -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 diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index d21bd67f2..ee5e0615c 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -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)) diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index 0a20341f7..1ebeb64f0 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -44,7 +44,7 @@ describe ProcedureExportService do end describe 'Dossiers sheet' do - context 'multiple columns' 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')), @@ -72,7 +72,7 @@ describe ProcedureExportService do end end - context 'with a procedure having multiple groupe instructeur' do + 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) { [] } @@ -87,7 +87,7 @@ describe ProcedureExportService do end end - context 'with a dossier having multiple pjs' do + 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 @@ -100,39 +100,47 @@ describe ProcedureExportService do it { expect(dossiers_sheet.data.last.last).to eq "toto.txt, toto.txt" } end - context 'with a dossier TypeDeChamp::MutlipleDropDownList' do + 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 a dossier TypeDeChamp:YesNo' do + 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 a dossier TypeDeChamp:Checkbox' do + 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 a dossier TypeDeChamp:DecimalNumber' do + 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 a dossier TypeDeChamp:IntegerNumber' do + + 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 having 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 end describe 'Etablissement sheet' do From ac6f5ba0250fe6b1f8e451684bad5bb6d998ccab Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 14:04:33 +0100 Subject: [PATCH 19/20] feat(export.datetime): ensure to cast date and time --- app/models/concerns/columns_concern.rb | 2 +- app/services/dossier_filter_service.rb | 2 +- .../procedure_export_service_tabular_spec.rb | 29 ++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index c52b52353..0a4a98299 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -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 diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index a322dc8dd..ce32fefc2 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -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 } diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index 1ebeb64f0..3bff4c42d 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -135,12 +135,39 @@ describe ProcedureExportService do it { expect(dossiers_sheet.data.last.last).to eq 42.0 } end - context 'with having TypesDeChamp::LinkedDropDownListTypeDeChamp' do + 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 From 9f2aff34596b0adb2873b8ef71b55e9298ac197c Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 14:16:08 +0100 Subject: [PATCH 20/20] feat(export.dossier_id): not a number, a string. --- app/components/instructeurs/column_table_header_component.rb | 2 +- app/models/column.rb | 1 + app/models/concerns/columns_concern.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index fa4ff6f90..4cb90dd93 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -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 diff --git a/app/models/column.rb b/app/models/column.rb index 5131c77c6..b02feda10 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -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'] diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 0a4a98299..5d61dc982 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -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)