add, edit and destroy export template with exported_columns

Co-authored-by: mfo <mfo@users.noreply.github.com>
Co-authored-by: LeSim <mail@simon.lehericey.net>
This commit is contained in:
Christophe Robillard 2024-10-25 14:50:51 +02:00 committed by mfo
parent f383e1c502
commit ffd1a15d91
No known key found for this signature in database
GPG key ID: 7CE3E1F5B794A8EC
19 changed files with 539 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,16 @@
%fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } }
%legend.fr-fieldset__legend--regular.fr-fieldset__legend
= title
.checkbox-group-bordered.fr-mx-1w.fr-mb-2w
.fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w
.fr-checkbox-group
= check_box_tag "#{title.parameterize}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' }
= label_tag "#{title.parameterize}-select-all", "Tout sélectionner"
- all_columns.each do |column|
.fr-fieldset__element.fr-px-4w
.fr-checkbox-group
- id = sanitize_to_id(field_id('export_template', 'exported_columns', { id: column.id, libelle: column.label, parent: nil }.to_json))
= check_box_tag field_name('export_template', 'exported_columns', ''), { id: column.id, libelle: column.label, parent: nil }.to_json, checked_columns.map(&:column).include?(column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' }
= label_tag id, column.label

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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