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/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/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..ea6f603cc --- /dev/null +++ b/app/components/export_template/champs_component/champs_component.html.haml @@ -0,0 +1,23 @@ +%fieldset.fr-fieldset{ id: "#{component_prefix}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0 + = title + .checkbox-group-bordered.fr-mx-1w.fr-mb-2w + .fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w + .fr-checkbox-group + = check_box_tag "#{component_prefix}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' } + = label_tag "#{component_prefix}-select-all", "Tout sélectionner" + - sections.each.with_index do |section, idx| + - if section[:libelle] + .fr-fieldset__element.fr-text--bold.fr-px-4w{ class: idx > 0 ? "fr-pt-1w" : "" }= section[:libelle] + + - section[:columns].each do |grouped_columns| + - if grouped_columns.many? + .fr-fieldset__element + .fieldset-bordered.fr-ml-3v + - grouped_columns.each do |exported_column| + .fr-fieldset__element.fr-px-3v + .fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:) + - else + - grouped_columns.each do |exported_column| + .fr-fieldset__element.fr-px-4w + .fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:) 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/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/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/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/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index e94bbf518..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) + def spreadsheet_columns(types_de_champ, export_template: nil, format:) [ ['Dossier ID', :dossier_id], ['Ligne', :index] - ] + dossier.champs_for_export(types_de_champ, row_id) + ] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:, format:) end end end diff --git a/app/models/column.rb b/app/models/column.rb index 22fa01a6f..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'] @@ -47,6 +48,9 @@ class Column procedure.find_column(h_id: h_id) end + def dossier_column? = false + def champ_column? = false + private def column_id = "#{table}/#{column}" diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index c032b3a35..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 @@ -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/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/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 diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 1cf268744..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) @@ -87,13 +87,13 @@ module ColumnsConcern def followers_instructeurs_email_column = dossier_col(table: 'followers_instructeurs', column: 'email') - def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); + def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :boolean, displayable: false, filterable: false); def dossier_motivation_column = dossier_col(table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); def user_email_for_display_column = dossier_col(table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) - def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', type: :boolean, filterable: false, displayable: false) def dossier_labels_column = dossier_col(table: 'dossier_labels', column: 'label_id', type: :enum, options_for_select: labels.map { [_1.name, _1.id] }) @@ -109,7 +109,7 @@ module ColumnsConcern def dossier_dates_columns ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| dossier_col(table: 'self', column:, type: :date) } + .map { |column| dossier_col(table: 'self', column:, type: :datetime) } end def email_column @@ -140,7 +140,8 @@ module ColumnsConcern def individual_columns ['gender', 'nom', 'prenom'].map { |column| dossier_col(table: 'individual', column:) } - .concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) } + .concat ['mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) } + .concat ['for_tiers'].map { |column| dossier_col(table: 'self', column:, type: :boolean) } end def moral_columns 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..887b48207 --- /dev/null +++ b/app/models/concerns/dossier_export_concern.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module DossierExportConcern + extend ActiveSupport::Concern + + def spreadsheet_columns_csv(types_de_champ:, export_template: nil) + spreadsheet_columns(with_etablissement: true, types_de_champ:, export_template:, format: :csv) + end + + def spreadsheet_columns_xlsx(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:, format: :xlsx) + end + + def spreadsheet_columns_ods(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:, format: :ods) + end + + def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil, format:) + types_de_champ.flat_map do |type_de_champ| + champ = filled_champ(type_de_champ, row_id) + if export_template.present? + export_template + .columns_for_stable_id(type_de_champ.stable_id) + .map { |exported_column| exported_column.libelle_with_value(champ, format:) } + else + type_de_champ.libelles_for_export.map do |(libelle, path)| + [libelle, type_de_champ.champ_value_for_export(champ, path)] + end + end + end + end + + def spreadsheet_columns(types_de_champ:, with_etablissement: false, export_template: nil, format: nil) + dossier_values_for_export(with_etablissement:, export_template:, format:) + champ_values_for_export(types_de_champ, export_template:, format:) + end + + private + + def dossier_values_for_export(with_etablissement: false, export_template: nil, format:) + if export_template.present? + return export_template.dossier_exported_columns.map { _1.libelle_with_value(self, format:) } + end + + columns = [ + ['ID', id.to_s], + ['Email', user_email_for(:display)], + ['FranceConnect ?', user_from_france_connect?] + ] + + if procedure.for_individual? + columns += [ + ['Civilité', individual&.gender], + ['Nom', individual&.nom], + ['Prénom', individual&.prenom], + ['Dépôt pour un tiers', :for_tiers], + ['Nom du mandataire', :mandataire_last_name], + ['Prénom du mandataire', :mandataire_first_name] + ] + if procedure.ask_birthday + columns += [['Date de naissance', individual&.birthdate]] + end + elsif with_etablissement + columns += [ + ['Établissement SIRET', etablissement&.siret], + ['Établissement siège social', etablissement&.siege_social], + ['Établissement NAF', etablissement&.naf], + ['Établissement libellé NAF', etablissement&.libelle_naf], + ['Établissement Adresse', etablissement&.adresse], + ['Établissement numero voie', etablissement&.numero_voie], + ['Établissement type voie', etablissement&.type_voie], + ['Établissement nom voie', etablissement&.nom_voie], + ['Établissement complément adresse', etablissement&.complement_adresse], + ['Établissement code postal', etablissement&.code_postal], + ['Établissement localité', etablissement&.localite], + ['Établissement code INSEE localité', etablissement&.code_insee_localite], + ['Entreprise SIREN', etablissement&.entreprise_siren], + ['Entreprise capital social', etablissement&.entreprise_capital_social], + ['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire], + ['Entreprise forme juridique', etablissement&.entreprise_forme_juridique], + ['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code], + ['Entreprise nom commercial', etablissement&.entreprise_nom_commercial], + ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale], + ['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social], + ['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise], + ['Entreprise date de création', etablissement&.entreprise_date_creation], + ['Entreprise état administratif', etablissement&.entreprise_etat_administratif], + ['Entreprise nom', etablissement&.entreprise_nom], + ['Entreprise prénom', etablissement&.entreprise_prenom], + ['Association RNA', etablissement&.association_rna], + ['Association titre', etablissement&.association_titre], + ['Association objet', etablissement&.association_objet], + ['Association date de création', etablissement&.association_date_creation], + ['Association date de déclaration', etablissement&.association_date_declaration], + ['Association date de publication', etablissement&.association_date_publication] + ] + else + columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] + end + if procedure.chorusable? && procedure.chorus_configuration.complete? + columns += [ + ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], + ['Référentiel De Programmation', procedure.chorus_configuration.referentiel_de_programmation&.fetch("code") { '' }], + ['Centre De Coût', procedure.chorus_configuration.centre_de_cout&.fetch("code") { '' }] + ] + end + columns += [ + ['Archivé', :archived], + ['État du dossier', Dossier.human_attribute_name("state.#{state}")], + ['Dernière mise à jour le', :updated_at], + ['Dernière mise à jour du dossier le', :last_champ_updated_at], + ['Déposé le', :depose_at], + ['Passé en instruction le', :en_instruction_at], + procedure.sva_svr_enabled? ? ["Date décision #{procedure.sva_svr_configuration.human_decision}", :sva_svr_decision_on] : nil, + ['Traité le', :processed_at], + ['Motivation de la décision', :motivation], + ['Instructeurs', followers_instructeurs.map(&:email).join(' ')] + ].compact + + if procedure.routing_enabled? + columns << ['Groupe instructeur', groupe_instructeur.label] + end + + columns + end +end 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/export_template.rb b/app/models/export_template.rb index 1a6f070d7..0328e16b4 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -9,12 +9,14 @@ class ExportTemplate < ApplicationRecord has_one :procedure, through: :groupe_instructeur has_many :exports, dependent: :nullify - enum kind: { zip: "zip" }, _prefix: :template + enum kind: { zip: 'zip', csv: 'csv', xlsx: 'xlsx', ods: 'ods' }, _prefix: :template attribute :dossier_folder, :export_item attribute :export_pdf, :export_item attribute :pjs, :export_item, array: true + attribute :exported_columns, :exported_column, array: true + before_validation :ensure_pjs_are_legit validates_with ExportTemplateValidator @@ -28,6 +30,7 @@ class ExportTemplate < ApplicationRecord end def self.default(name: nil, kind: 'zip', groupe_instructeur:) + # TODO: remove default values for tabular export dossier_folder = ExportItem.default(prefix: 'dossier') export_pdf = ExportItem.default(prefix: 'export') pjs = groupe_instructeur.procedure.exportables_pieces_jointes.map { |tdc| ExportItem.default_pj(tdc) } @@ -35,6 +38,10 @@ class ExportTemplate < ApplicationRecord new(name:, kind:, groupe_instructeur:, dossier_folder:, export_pdf:, pjs:) end + def tabular? + kind != 'zip' + end + def tags tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten end @@ -58,6 +65,19 @@ class ExportTemplate < ApplicationRecord File.join(dossier_folder.path(dossier), file_path) if file_path.present? end + def dossier_exported_columns = exported_columns.filter { _1.column.dossier_column? } + + def columns_for_stable_id(stable_id) + exported_columns + .filter { _1.column.champ_column? } + .filter { _1.column.stable_id == stable_id } + end + + def in_export?(exported_column) + @template_exported_columns ||= exported_columns.map(&:column) + @template_exported_columns.include?(exported_column.column) + end + private def ensure_pjs_are_legit diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb new file mode 100644 index 000000000..651407a71 --- /dev/null +++ b/app/models/exported_column.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ExportedColumn + attr_reader :column, :libelle + + def initialize(column:, libelle:) + @column = column + @libelle = libelle + end + + def id = { id: column.id, libelle: }.to_json + + def libelle_with_value(champ_or_dossier, format:) + [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:, format:), spreadsheet_architect_type] + end + + def spreadsheet_architect_type + case @column.type + when :boolean + :boolean + when :decimal, :integer + :float + when :datetime + :time + when :date + :date + else + :string + end + end +end 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..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, @@ -171,6 +170,7 @@ class TypeDeChamp < ApplicationRecord scope :not_repetition, -> { where.not(type_champ: type_champs.fetch(:repetition)) } scope :not_condition, -> { where(condition: nil) } scope :fillable, -> { where.not(type_champ: [type_champs.fetch(:header_section), type_champs.fetch(:explication)]) } + scope :with_header_section, -> { where.not(type_champ: TypeDeChamp.type_champs[:explication]) } scope :dubious, -> { where("unaccent(types_de_champ.libelle) ~* unaccent(?)", DubiousProcedure.forbidden_regexp) diff --git a/app/models/types_de_champ/commune_type_de_champ.rb b/app/models/types_de_champ/commune_type_de_champ.rb index 3f2766d6c..2f3d639df 100644 --- a/app/models/types_de_champ/commune_type_de_champ.rb +++ b/app/models/types_de_champ/commune_type_de_champ.rb @@ -27,6 +27,31 @@ class TypesDeChamp::CommuneTypeDeChamp < TypesDeChamp::TypeDeChampBase champ.code_postal? ? "#{champ.name} (#{champ.code_postal})" : champ.name end + def columns(procedure:, displayable: true, prefix: nil) + super.concat( + [ + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} - code postal (5 chiffres)", + jsonpath: '$.code_postal', + displayable:, + type: :text + ), + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} - département", + jsonpath: '$.code_departement', + displayable:, + type: :number + ) + ] + ) + end + private def paths 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/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/app/services/exported_column_formatter.rb b/app/services/exported_column_formatter.rb new file mode 100644 index 000000000..824b26d29 --- /dev/null +++ b/app/services/exported_column_formatter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ExportedColumnFormatter + def self.format(column:, champ_or_dossier:, format:) + return if champ_or_dossier.nil? + + raw_value = column.value(champ_or_dossier) + + case column.type + when :boolean + format_boolean(column:, raw_value:, format:) + when :attachements + format_attachments(column:, raw_value:) + when :enum + format_enum(column:, raw_value:) + when :enums + format_enums(column:, raw_values: raw_value) + else + raw_value + end + end + + private + + def self.format_boolean(column:, raw_value:, format:) + if format == :ods + raw_value ? 1 : 0 + else + raw_value + end + end + + def self.format_attachments(column:, raw_value:) + case column.tdc_type + when TypeDeChamp.type_champs[:titre_identite] + raw_value.present? ? 'présent' : 'absent' + when TypeDeChamp.type_champs[:piece_justificative] + raw_value.map { _1.blob.filename }.join(", ") + end + end + + def self.format_enums(column:, raw_values:) + raw_values.map { format_enum(column:, raw_value: _1) }.join(', ') + end + + def self.format_enum(column:, raw_value:) + # options for select store ["trad", :enum_value] + selected_option = column.options_for_select.find { _1[1].to_s == raw_value } + + selected_option ? selected_option.first : raw_value + end +end diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 9482cc045..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) } + spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ, export_template: @export_template, format:) } } end end @@ -152,7 +152,7 @@ class ProcedureExportService types_de_champ = procedure.types_de_champ_for_procedure_export.to_a Proc.new do |instance| - instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ) + instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ, export_template: @export_template) end end end 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/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/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 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..80626d857 --- /dev/null +++ b/app/views/instructeurs/export_templates/_checkbox_group.html.haml @@ -0,0 +1,14 @@ +%fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0 + = title + + .checkbox-group-bordered.fr-mx-1w.fr-mb-2w + .fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w + .fr-checkbox-group + = check_box_tag "#{title.parameterize}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' } + = label_tag "#{title.parameterize}-select-all", "Tout sélectionner" + + - all_columns.each do |column| + .fr-fieldset__element.fr-px-4w + .fr-checkbox-group + = render ExportTemplate::CheckboxComponent.new(export_template:, exported_column: ExportedColumn.new(libelle: column.label, column:)) 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..36712f6f0 --- /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 + = asterisk + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + = f.radio_button :kind, "xlsx", id: "xlsx" + %label.fr-label{ for: "xlsx" } xlsx + .fr-radio-group + = f.radio_button :kind, "ods", id: "ods" + %label.fr-label{ for: "ods" } ods + .fr-radio-group + = f.radio_button :kind, "csv", id: "csv" + %label.fr-label{ for: "csv" } csv + + %h2 Contenu de l'export + %p Sélectionnez les colonnes que vous souhaitez voir affichées dans le tableau de votre export. + + = render partial: 'checkbox_group', locals: { title: 'Informations usager', all_columns: @export_template.procedure.usager_columns_for_export, export_template: @export_template } + = render partial: 'checkbox_group', locals: { title: 'Informations dossier', all_columns: @export_template.procedure.dossier_columns_for_export, export_template: @export_template } + = render ExportTemplate::ChampsComponent.new("Formulaire usager", @export_template, @types_de_champ_public) + = render ExportTemplate::ChampsComponent.new("Annotations privées", @export_template, @types_de_champ_private) if @types_de_champ_private.any? + + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" + %li + = f.submit "Enregistrer", class: "fr-btn", data: @export_template.persisted? ? { confirm: t('.warning') } : {} 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/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/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 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/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/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 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/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/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index 82c0fa2bd..ee5e0615c 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -13,7 +13,7 @@ describe Columns::ChampColumn do expect_type_de_champ_values('email', eq(['yoda@beta.gouv.fr'])) expect_type_de_champ_values('phone', eq(['0666666666'])) expect_type_de_champ_values('address', eq(["2 rue des Démarches"])) - expect_type_de_champ_values('communes', eq(["Coye-la-Forêt"])) + expect_type_de_champ_values('communes', eq(["Coye-la-Forêt", "60580", "60"])) expect_type_de_champ_values('departements', eq(['01'])) expect_type_de_champ_values('regions', eq(['01'])) expect_type_de_champ_values('pays', eq(['France'])) @@ -30,7 +30,7 @@ describe Columns::ChampColumn do expect_type_de_champ_values('checkbox', eq([true])) expect_type_de_champ_values('drop_down_list', eq(['val1'])) expect_type_de_champ_values('multiple_drop_down_list', eq([["val1", "val2"]])) - expect_type_de_champ_values('linked_drop_down_list', eq([nil, "primary", "secondary"])) + expect_type_de_champ_values('linked_drop_down_list', eq(["primary / secondary", "primary", "secondary"])) expect_type_de_champ_values('yes_no', eq([true])) expect_type_de_champ_values('annuaire_education', eq([nil])) expect_type_de_champ_values('piece_justificative', be_an_instance_of(Array)) 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/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index 6b864f28a..1b8a920e8 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, 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 320fe595e..cba6e5188 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, format: :xlsx) } .from([["c1", 42]]).to([["c1", 42.0]]) end end @@ -2020,8 +2020,8 @@ describe Dossier, type: :model do let(:repetition_second_revision_champ) { dossier_second_revision.project_champs_public.find(&:repetition?) } let(:dossier) { create(:dossier, procedure: procedure) } let(:dossier_second_revision) { create(:dossier, procedure: procedure) } - let(:dossier_champs_for_export) { dossier.champs_for_export(procedure.types_de_champ_for_procedure_export) } - let(:dossier_second_revision_champs_for_export) { dossier_second_revision.champs_for_export(procedure.types_de_champ_for_procedure_export) } + let(:dossier_champ_values_for_export) { dossier.champ_values_for_export(procedure.types_de_champ_for_procedure_export, format: :xlsx) } + let(:dossier_second_revision_champ_values_for_export) { dossier_second_revision.champ_values_for_export(procedure.types_de_champ_for_procedure_export, format: :xlsx) } context "when procedure published" do before do @@ -2040,8 +2040,8 @@ describe Dossier, type: :model do it "should have champs from all revisions" do expect(dossier.types_de_champ.map(&:libelle)).to eq([text_type_de_champ.libelle, datetime_type_de_champ.libelle, "Yes/no", explication_type_de_champ.libelle, commune_type_de_champ.libelle, repetition_type_de_champ.libelle]) expect(dossier_second_revision.types_de_champ.map(&:libelle)).to eq([datetime_type_de_champ.libelle, "Updated yes/no", explication_type_de_champ.libelle, 'Commune de naissance', "Repetition", "New text field"]) - expect(dossier_champs_for_export.map { |(libelle)| libelle }).to eq([datetime_type_de_champ.libelle, text_type_de_champ.libelle, "Updated yes/no", "Commune de naissance", "Commune de naissance (Code INSEE)", "Commune de naissance (Département)", "New text field"]) - expect(dossier_champs_for_export).to eq(dossier_second_revision_champs_for_export) + expect(dossier_champ_values_for_export.map { |(libelle)| libelle }).to eq([datetime_type_de_champ.libelle, text_type_de_champ.libelle, "Updated yes/no", "Commune de naissance", "Commune de naissance (Code INSEE)", "Commune de naissance (Département)", "New text field"]) + expect(dossier_champ_values_for_export).to eq(dossier_second_revision_champ_values_for_export) end context 'within a repetition having a type de champs commune (multiple values for export)' do @@ -2056,7 +2056,7 @@ describe Dossier, type: :model do dossier_test = create(:dossier, procedure: proc_test) type_champs = proc_test.all_revisions_types_de_champ(parent: tdc_repetition).to_a expect(type_champs.size).to eq(1) - expect(dossier.champs_for_export(type_champs).size).to eq(3) + expect(dossier.champ_values_for_export(type_champs, format: :xlsx).size).to eq(3) end end end @@ -2065,7 +2065,7 @@ describe Dossier, type: :model do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }, { type: :explication }]) } it "should not contain non-exportable types de champ" do - expect(dossier_champs_for_export.map { |(libelle)| libelle }).to eq([text_type_de_champ.libelle]) + expect(dossier_champ_values_for_export.map { |(libelle)| libelle }).to eq([text_type_de_champ.libelle]) end end end @@ -2079,7 +2079,7 @@ describe Dossier, type: :model do let(:text_tdc) { procedure.active_revision.types_de_champ_public.second } let(:tdcs) { dossier.project_champs_public.map(&:type_de_champ) } - subject { dossier.champs_for_export(tdcs) } + subject { dossier.champ_values_for_export(tdcs, format: :xlsx) } before do text_tdc.update(condition: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) diff --git a/spec/models/export_template_tabular_spec.rb b/spec/models/export_template_tabular_spec.rb new file mode 100644 index 000000000..4751ffdd2 --- /dev/null +++ b/spec/models/export_template_tabular_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +describe ExportTemplate do + let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } + let(:export_template) { build(:export_template, kind: 'csv', groupe_instructeur:) } + let(:tabular_export_template) { build(:tabular_export_template, groupe_instructeur:) } + let(:procedure) { create(:procedure_with_dossiers, :published, types_de_champ_public:, for_individual:) } + let(:for_individual) { true } + let(:types_de_champ_public) do + [ + { type: :text, libelle: "Ca va ?", mandatory: true, stable_id: 1 }, + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 }, + { type: :siret, libelle: 'siret', stable_id: 20 }, + { type: :repetition, mandatory: true, stable_id: 7, libelle: "Champ répétable", children: [{ type: 'text', libelle: 'Qqchose à rajouter?', stable_id: 8 }] } + ] + end + + describe '#exported_columns=' do + it 'is assignable/readable with ExportedColumn object' do + expect do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + export_template.save! + export_template.exported_columns + end.not_to raise_error + end + it 'create exported_column' do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + export_template.save! + expect(export_template.exported_columns.size).to eq 1 + end + + context 'when there is a previous revision with a renamed tdc' do + context 'with already column in export template' do + let(:previous_tdc) { procedure.published_revision.types_de_champ_public.find_by(stable_id: 1) } + let(:changed_tdc) { { libelle: "Ca roule ?" } } + + context 'with already column in export template' do + before do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + export_template.save! + + type_de_champ = procedure.draft_revision.find_and_ensure_exclusive_use(previous_tdc.stable_id) + type_de_champ.update(changed_tdc) + procedure.publish_revision! + end + + it 'update columns with original libelle for champs with new revision' do + Current.procedure_columns = {} + procedure.reload + export_template.reload + expect(export_template.exported_columns.find { _1.column.stable_id.to_s == "1" }.libelle).to eq('Ça va ?') + end + end + end + context 'without columns in export template' do + let(:previous_tdc) { procedure.published_revision.types_de_champ_public.find_by(stable_id: 1) } + let(:changed_tdc) { { libelle: "Ca roule ?" } } + + before do + type_de_champ = procedure.draft_revision.find_and_ensure_exclusive_use(previous_tdc.stable_id) + type_de_champ.update(changed_tdc) + procedure.publish_revision! + + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça roule ?', column: procedure.find_column(label: "Ca roule ?")) + ] + export_template.save! + end + + it 'update columns with original libelle for champs with new revision' do + Current.procedure_columns = {} + procedure.reload + export_template.reload + expect(export_template.exported_columns.find { _1.column.stable_id.to_s == "1" }.libelle).to eq('Ça roule ?') + end + end + end + end + + describe 'dossier_exported_columns' do + context 'when exported_columns is empty' do + it 'returns an empty array' do + expect(export_template.dossier_exported_columns).to eq([]) + end + end + + context 'when exported_columns is not empty' do + before do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Colonne usager', column: procedure.find_column(label: "Email")), + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + end + it 'returns all columns except tdc columns' do + expect(export_template.dossier_exported_columns.size).to eq(1) # exclude tdc + expect(export_template.dossier_exported_columns.first.libelle).to eq("Colonne usager") + end + end + end + + describe 'columns_for_stable_id' do + before do + export_template.exported_columns = procedure.published_revision.types_de_champ.first.columns(procedure: procedure).map do |column| + ExportedColumn.new(libelle: column.label, column:) + end + end + context 'when procedure has a TypeDeChamp::Commune' do + let(:types_de_champ_public) do + [ + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 } + ] + end + it 'is able to resolve stable_id' do + expect(export_template.columns_for_stable_id(17).size).to eq(3) + end + end + context 'when procedure has a TypeDeChamp::Siret' do + let(:types_de_champ_public) do + [ + { type: :siret, libelle: 'siret', stable_id: 20 } + ] + end + it 'is able to resolve stable_id' do + expect(export_template.columns_for_stable_id(20).size).to eq(5) + end + end + context 'when procedure has a TypeDeChamp::Text' do + let(:types_de_champ_public) do + [ + { type: :text, libelle: "Text", mandatory: true, stable_id: 15 } + ] + end + it 'is able to resolve stable_id' do + expect(export_template.columns_for_stable_id(15).size).to eq(1) + end + end + end +end 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) 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 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..3bff4c42d --- /dev/null +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require 'csv' + +describe ProcedureExportService do + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, types_de_champ_public:, for_individual:, ask_birthday: true, instructeurs: [instructeur]) } + let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } + let(:for_individual) { true } + let(:types_de_champ_public) do + [ + { type: :text, libelle: "first champ", mandatory: true, stable_id: 1 }, + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 }, + { type: :piece_justificative, libelle: "PJ", stable_id: 30 }, + { + type: :repetition, mandatory: true, stable_id: 7, libelle: "Champ répétable", children: + [ + { type: 'text', libelle: 'child first champ', stable_id: 8 }, + { type: 'text', libelle: 'child second champ', stable_id: 9 } + ] + } + ] + end + let(:exported_columns) { [] } + + describe 'to_xlsx' do + let(:kind) { 'xlsx' } + let(:export_template) { create(:export_template, kind:, exported_columns:, groupe_instructeur: procedure.defaut_groupe_instructeur) } + let(:dossiers_sheet) { subject.sheets.first } + let(:etablissements_sheet) { subject.sheets.second } + let(:avis_sheet) { subject.sheets.third } + let(:repetition_sheet) { subject.sheets.fourth } + + subject do + service + .to_xlsx + .open { |f| SimpleXlsxReader.open(f.path) } + end + + describe 'sheets' do + it 'should have a sheet for each record type' do + expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis']) + end + end + + describe 'Dossiers sheet' do + context 'with multiple columns' do + let(:exported_columns) do + [ + ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'Date du dernier évènement')), + ExportedColumn.new(libelle: 'Email', column: procedure.find_column(label: 'Email')), + ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur')), + ExportedColumn.new(libelle: 'État du dossier', column: procedure.dossier_state_column), + ExportedColumn.new(libelle: 'first champ', column: procedure.find_column(label: 'first champ')), + ExportedColumn.new(libelle: 'Commune', column: procedure.find_column(label: 'Commune')), + ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ')) + ] + end + + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + let(:selected_headers) { ["Email", "first champ", "Commune", "Groupe instructeur", "Date du dernier évènement", "État du dossier", "PJ"] } + + it 'should have only headers from export template' do + expect(dossiers_sheet.headers).to match_array(selected_headers) + end + + it 'should have data' do + expect(procedure.dossiers.count).to eq 1 + expect(dossiers_sheet.data.size).to eq 1 + + expect(dossiers_sheet.data).to match_array([[anything, dossier.user_email_for_display, "défaut", "En instruction", "text", "Coye-la-Forêt", "toto.txt"]]) + end + end + + context 'with multiple groupe instructeur' do + let(:exported_columns) { [ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur'))] } + let(:types_de_champ_public) { [] } + + before do + create(:groupe_instructeur, label: '2', procedure:) + create(:dossier, :en_instruction, procedure:) + end + + it 'find groupe instructeur data' do + expect(dossiers_sheet.headers).to include('Groupe instructeur') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') + end + end + + context 'with multiple pjs' do + let(:types_de_champ_public) { [{ type: :piece_justificative, libelle: "PJ" }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ'))] } + before do + dossier = create(:dossier, :en_instruction, :with_populated_champs, procedure:) + dossier.filled_champs_public + .find { _1.is_a? Champs::PieceJustificativeChamp } + .piece_justificative_file + .attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") + end + it { expect(dossiers_sheet.data.last.last).to eq "toto.txt, toto.txt" } + end + + context 'with TypeDeChamp::MutlipleDropDownListTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :multiple_drop_down_list, libelle: "multiple_drop_down_list", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'multiple_drop_down_list'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq "val1, val2" } + end + + context 'with TypeDeChamp:YesNoTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :yes_no, libelle: "yes_no", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'yes_no', column: procedure.find_column(label: 'yes_no'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq true } + end + + context 'with TypeDeChamp:CheckboxTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :checkbox, libelle: "checkbox", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'checkbox', column: procedure.find_column(label: 'checkbox'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq true } + end + + context 'with TypeDeChamp:DecimalNumberTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :decimal_number, libelle: "decimal", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'decimal', column: procedure.find_column(label: 'decimal'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq 42.1 } + end + + context 'with TypeDeChamp:IntegerNumberTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :integer_number, libelle: "integer", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'integer', column: procedure.find_column(label: 'integer'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq 42.0 } + end + + context 'with TypesDeChamp::LinkedDropDownListTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: "linked_drop_down_list", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'linked_drop_down_list', column: procedure.find_column(label: 'linked_drop_down_list'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq "primary / secondary" } + end + + context 'with TypesDeChamp::DateTimeTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :datetime, libelle: "datetime", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'datetime', column: procedure.find_column(label: 'datetime'))] } + let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } + before { dossier } + it do + champ_value = Time.zone.parse(dossier.champs.first.value) + offset = champ_value.utc_offset + sheet_value = Time.zone.at(dossiers_sheet.data.last.last - offset.seconds) + expect(sheet_value).to eq(champ_value.round) + end + end + + context 'with TypesDeChamp::Date' do + let(:types_de_champ_public) { [{ type: :date, libelle: "date", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'date', column: procedure.find_column(label: 'date'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to be_an_instance_of(Date) } + end + + context 'with DossierColumn as datetime' do + let(:types_de_champ_public) { [] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'Date de passage en instruction', column: procedure.find_column(label: 'Date de passage en instruction'))] } + before { create(:dossier, :en_instruction, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to be_an_instance_of(Time) } + end + end + + describe 'Etablissement sheet' do + let(:types_de_champ_public) { [{ type: :siret, libelle: 'siret', stable_id: 40 }] } + let(:exported_columns) do + [ + ExportedColumn.new(libelle: "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 diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 098878767..bfd9447d2 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -132,13 +132,14 @@ describe 'Instructing a dossier:', js: true do test_statut_bar(a_suivre: 1, tous_les_dossiers: 1) click_on "Télécharger un dossier" - within(:css, '.dossiers-export') do - click_on "Demander un export au format .csv" + within(:css, '#tabpanel-standard1-panel') do + choose "Fichier csv", allow_label_click: true + click_on "Demander l'export" end expect(page).to have_text('Nous générons cet export.') - click_on "Voir les exports" + click_on "Voir les exports et modèles d'export" expect(page).to have_text("Export .csv d’un dossier « à suivre » demandé il y a moins d'une minute") expect(page).to have_text("En préparation") 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' 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",